mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-01-08 15:50:21 +00:00
Compare commits
28 Commits
8820
...
debug-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff3219935 | ||
|
|
386bdbfc22 | ||
|
|
a45453e391 | ||
|
|
07554a156b | ||
|
|
70c3c8db13 | ||
|
|
9bb1c36508 | ||
|
|
a93ca9d7c4 | ||
|
|
d2f20c49af | ||
|
|
c5f82d4f20 | ||
|
|
36a3e700e1 | ||
|
|
77464ddcc4 | ||
|
|
36ce5a1661 | ||
|
|
23c831e9b0 | ||
|
|
e6fbeb9458 | ||
|
|
e15a59c994 | ||
|
|
f5e1a97d64 | ||
|
|
cd25652182 | ||
|
|
2bf0b1922f | ||
|
|
469406d7cd | ||
|
|
60679aa2d3 | ||
|
|
319e8d1e4b | ||
|
|
40b8d6168b | ||
|
|
753d0399c9 | ||
|
|
2475aff21a | ||
|
|
121aabeb25 | ||
|
|
086f01aa5b | ||
|
|
6b6920693b | ||
|
|
566b3ba2d5 |
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -139,6 +139,12 @@ jobs:
|
||||
xcode-select -p
|
||||
sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
|
||||
xcodebuild -version
|
||||
- name: clean Xcode
|
||||
run: |
|
||||
rm -rf ios/sdk/out
|
||||
xcodebuild clean \
|
||||
-workspace ios/jitsi-meet.xcworkspace \
|
||||
-scheme JitsiMeetSDK
|
||||
- name: setup-cocoapods
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
@@ -149,9 +155,6 @@ jobs:
|
||||
working-directory: ./ios
|
||||
run: bundle exec pod install --repo-update --deployment
|
||||
- run: |
|
||||
xcodebuild clean \
|
||||
-workspace ios/jitsi-meet.xcworkspace \
|
||||
-scheme JitsiMeetSDK
|
||||
xcodebuild -downloadPlatform iOS -buildVersion 18.2
|
||||
xcodebuild archive \
|
||||
-workspace ios/jitsi-meet.xcworkspace \
|
||||
|
||||
267
CLAUDE.md
Normal file
267
CLAUDE.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Building and Development
|
||||
- `npm run lint-fix` - Automatically fix linting issues
|
||||
- `npm run tsc:ci` - Run TypeScript checks for both web and native platforms
|
||||
- `npm run tsc:web` - TypeScript check for web platform only
|
||||
- `npm run tsc:native` - TypeScript check for native platform only
|
||||
- `npm run lint:ci` - Run ESLint without type checking
|
||||
- `make dev` - Start development server with webpack-dev-server
|
||||
- `make compile` - Build production bundles
|
||||
- `make clean` - Clean build directory
|
||||
- `make all` - Full build (compile + deploy)
|
||||
|
||||
### Testing
|
||||
- `npm test` - Run full test suite using WebDriverIO
|
||||
- `npm run test-single -- <spec-file>` - Run single test file
|
||||
- `npm run test-dev` - Run tests against development environment
|
||||
- `npm run test-dev-single -- <spec-file>` - Run single test in dev mode
|
||||
|
||||
|
||||
### Language Tools
|
||||
- `npm run lang-sort` - Sort language files
|
||||
- `npm run lint:lang` - Validate JSON language files
|
||||
|
||||
### Platform-Specific TypeScript
|
||||
TypeScript configuration is split between web and native platforms with separate tsconfig files.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Multi-Platform Structure
|
||||
Jitsi Meet supports both web and React Native platforms with platform-specific file extensions and directories:
|
||||
- `.web.ts/.web.tsx` - Web-specific implementations
|
||||
- `.native.ts/.native.tsx` - React Native-specific implementations
|
||||
- `.any.ts/.any.tsx` - Shared cross-platform code
|
||||
- `.android.ts/.android.tsx` - Android-specific code
|
||||
- `.ios.ts/.ios.tsx` - iOS-specific code
|
||||
- `web/` directories - Web-specific components and modules
|
||||
- `native/` directories - React Native-specific components and modules
|
||||
- `react/features/mobile/` - Native-only features
|
||||
|
||||
### Core Directories
|
||||
- `react/features/` - Main application features organized by domain (83+ feature modules)
|
||||
- `modules/` - Legacy JavaScript modules and APIs
|
||||
- `css/` - SCSS stylesheets compiled to CSS
|
||||
- `libs/` - Compiled output directory for JavaScript bundles
|
||||
- `static/` - Static assets and HTML files
|
||||
- `tests/` - WebDriverIO end-to-end tests
|
||||
|
||||
### Feature-Driven Architecture
|
||||
The application is organized under `react/features/` with each feature containing:
|
||||
|
||||
- **`actionTypes.ts`** - Redux action type constants
|
||||
- **`actions.ts`** - Redux action creators (platform-specific variants with `.any.ts`, `.web.ts`, `.native.ts`)
|
||||
- **`reducer.ts`** - Redux reducer functions
|
||||
- **`middleware.ts`** - Redux middleware for side effects
|
||||
- **`functions.ts`** - Utility functions and selectors
|
||||
- **`constants.ts`** - Feature-specific constants
|
||||
- **`logger.ts`** - Feature-specific logger instance
|
||||
- **`types.ts`** - TypeScript type definitions
|
||||
|
||||
### Key Application Files
|
||||
- `app.js` - Main web application entry point
|
||||
- `webpack.config.js` - Multi-bundle Webpack configuration
|
||||
- `Makefile` - Build system for development and production
|
||||
- `package.json` - Dependencies and scripts with version requirements
|
||||
|
||||
### Bundle Architecture
|
||||
The application builds multiple bundles:
|
||||
- `app.bundle.js` / `app.bundle.min.js` - Main application bundle (entry: `./app.js`)
|
||||
- `external_api.js` / `external_api.min.js` - External API for embedders (entry: `./modules/API/external/index.js`)
|
||||
- `alwaysontop.js` / `alwaysontop.min.js` - Always-on-top window functionality (entry: `./react/features/always-on-top/index.tsx`)
|
||||
- `close3.js` / `close3.min.js` - Close3 functionality (entry: `./static/close3.js`)
|
||||
- `face-landmarks-worker.js` / `face-landmarks-worker.min.js` - Face landmarks detection worker (entry: `./react/features/face-landmarks/faceLandmarksWorker.ts`)
|
||||
- `noise-suppressor-worklet.js` / `noise-suppressor-worklet.min.js` - Audio noise suppression worklet (entry: `./react/features/stream-effects/noise-suppression/NoiseSuppressorWorklet.ts`)
|
||||
- `screenshot-capture-worker.js` / `screenshot-capture-worker.min.js` - Screenshot capture worker (entry: `./react/features/screenshot-capture/worker.ts`)
|
||||
|
||||
### Redux Architecture
|
||||
Features follow a Redux-based architecture with:
|
||||
- Actions, reducers, and middleware in each feature directory
|
||||
- Cross-platform state management
|
||||
- Modular feature organization with clear boundaries
|
||||
|
||||
The codebase uses a registry-based Redux architecture:
|
||||
- **ReducerRegistry** - Features register their reducers independently
|
||||
- **MiddlewareRegistry** - Features register middleware without cross-dependencies
|
||||
- **IReduxState** - Global state is strongly typed with 80+ feature states
|
||||
|
||||
### Dependencies
|
||||
- Uses `lib-jitsi-meet` as the core WebRTC library
|
||||
- React with TypeScript support
|
||||
- React Native for mobile applications
|
||||
- Webpack for bundling with development server
|
||||
|
||||
### TypeScript Configuration
|
||||
- `tsconfig.web.json` - Web platform TypeScript config (excludes native files)
|
||||
- `tsconfig.native.json` - React Native TypeScript config (excludes web files)
|
||||
- Strict TypeScript settings with ES2024 target
|
||||
- Platform-specific module suffixes (`.web`, `.native`)
|
||||
|
||||
### Key Base Features
|
||||
- **`base/app/`** - Application lifecycle management
|
||||
- **`base/conference/`** - Core conference logic
|
||||
- **`base/tracks/`** - Media track management
|
||||
- **`base/participants/`** - Participant management
|
||||
- **`base/config/`** - Configuration management
|
||||
- **`base/redux/`** - Redux infrastructure
|
||||
|
||||
### Component Patterns
|
||||
- **Abstract Components** - Base classes for cross-platform components
|
||||
- **Platform-Specific Components** - Separate implementations in `web/` and `native/` directories
|
||||
- **Hook-based patterns** - Modern React patterns for component logic
|
||||
### Testing Framework
|
||||
- WebDriverIO for end-to-end testing
|
||||
- Test files are located in `tests/specs/` and use page objects in `tests/pageobjects/`.
|
||||
- Environment configuration via `.env` files
|
||||
- Support for Chrome, Firefox, and grid testing
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Adding New Features
|
||||
1. Create feature directory under `react/features/[feature-name]/`
|
||||
2. Follow the standard file structure (actionTypes, actions, reducer, etc.)
|
||||
3. Register reducers and middleware using the registry pattern
|
||||
4. Define TypeScript interfaces for state and props
|
||||
5. Use platform-specific files for web/native differences
|
||||
6. Add feature-specific logger for debugging
|
||||
|
||||
### Working with Existing Features
|
||||
1. Check for existing `.any.ts`, `.web.ts`, `.native.ts` variants
|
||||
2. Follow established action-reducer-middleware patterns
|
||||
3. Use existing base utilities rather than creating new ones
|
||||
4. Leverage abstract components for cross-platform logic
|
||||
5. Maintain type safety across the entire state tree
|
||||
|
||||
### Testing
|
||||
The project uses WebDriver (WebdriverIO) for end-to-end testing. Test files are located in `tests/specs/` and use page objects in `tests/pageobjects/`.
|
||||
|
||||
### Build System
|
||||
- **Webpack** - Main build system for web bundles
|
||||
- **Makefile** - Coordinates build process and asset deployment
|
||||
- **Metro** - React Native bundler (configured in `metro.config.js`)
|
||||
|
||||
### Platform-Specific Notes
|
||||
- Web builds exclude files matching `**/native/*`, `**/*.native.ts`, etc.
|
||||
- Native builds exclude files matching `**/web/*`, `**/*.web.ts`, etc.
|
||||
- Use `moduleSuffixes` in TypeScript config to handle platform-specific imports
|
||||
- Check `tsconfig.web.json` and `tsconfig.native.json` for platform-specific exclusions
|
||||
|
||||
## Environment and Setup Requirements
|
||||
|
||||
### System Requirements
|
||||
- **Node.js and npm** are required
|
||||
- Development server runs at https://localhost:8080/
|
||||
- Certificate errors in development are expected (self-signed certificates)
|
||||
|
||||
### Development Workflow
|
||||
- Development server proxies to configurable target (default: https://alpha.jitsi.net)
|
||||
- Hot module replacement enabled for development
|
||||
- Bundle analysis available via `ANALYZE_BUNDLE=true` environment variable
|
||||
- Circular dependency detection via `DETECT_CIRCULAR_DEPS=true`
|
||||
|
||||
## Code Quality Requirements
|
||||
- All code must pass `npm run lint:ci` and `npm run tsc:ci` with 0 warnings before committing
|
||||
- TypeScript strict mode enabled - avoid `any` type
|
||||
- ESLint config extends `@jitsi/eslint-config`
|
||||
- Prefer TypeScript for new features, convert existing JavaScript when possible
|
||||
|
||||
## Code Style and Standards
|
||||
|
||||
### Conventional Commits Format
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org) with **mandatory scopes**:
|
||||
```
|
||||
feat(feature-name): description
|
||||
fix(feature-name): description
|
||||
docs(section): description
|
||||
```
|
||||
Available types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
|
||||
|
||||
### Feature Layout Structure
|
||||
When adding new features:
|
||||
```
|
||||
react/features/sample/
|
||||
├── actionTypes.ts
|
||||
├── actions.ts
|
||||
├── components/
|
||||
│ ├── AnotherComponent.tsx
|
||||
│ └── OneComponent.tsx
|
||||
├── middleware.ts
|
||||
└── reducer.ts
|
||||
```
|
||||
|
||||
### TypeScript Requirements
|
||||
- All new features must be written in TypeScript
|
||||
- Convert JavaScript to TypeScript when modifying existing code
|
||||
- Import middleware in `react/features/app/middlewares.{any,native,web}.js`
|
||||
- Import reducers in appropriate registry files
|
||||
- Avoid `index` files
|
||||
|
||||
### Bundle Size Management
|
||||
- Bundle size limits are enforced to prevent bloat
|
||||
- For increases, analyze first: `npx webpack -p --analyze-bundle`
|
||||
- Open analyzer: `npx webpack-bundle-analyzer build/app-stats.json`
|
||||
- Justify any dependency additions that increase bundle size
|
||||
|
||||
## Testing and Quality Assurance
|
||||
|
||||
### Tests
|
||||
- End-to-end tests are defined in the tests/
|
||||
- Tests run automatically for project member PRs via Jenkins
|
||||
- Tests cover peer-to-peer, invites, iOS, Android, and web platforms
|
||||
- Beta testing available at https://beta.meet.jit.si/
|
||||
|
||||
### Manual Testing Checklist
|
||||
- Test with 2 participants (P2P mode)
|
||||
- Test with 3+ participants (JVB mode)
|
||||
- Verify audio/video in both modes
|
||||
- Test mobile apps if changes affect mobile
|
||||
- Check that TLS certificate chain is complete for mobile app compatibility
|
||||
|
||||
## Common Issues and Debugging
|
||||
|
||||
### P2P vs JVB Problems
|
||||
- **Works with 2 participants, fails with 3+**: JVB/firewall issue, check UDP 10000
|
||||
- **Works on web, fails on mobile apps**: TLS certificate chain issue, need fullchain.pem
|
||||
- Use the tests from tests/ directory to verify functionality across platforms
|
||||
|
||||
### Development Server Issues
|
||||
- Certificate warnings are normal for development (self-signed)
|
||||
- Use different backend with WEBPACK_DEV_SERVER_PROXY_TARGET environment variable
|
||||
- Check firewall settings if local development fails
|
||||
|
||||
### Configuration and Customization
|
||||
- Extensive configuration options documented in handbook
|
||||
- See `config.js` for client-side options
|
||||
- Options marked 🚫 are not overwritable through `configOverwrite`
|
||||
- Reference [Configuration Guide](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-configuration) for details
|
||||
|
||||
## Architecture Deep Dive
|
||||
|
||||
### Core Application Files
|
||||
- **`./conference.js`** - Foundation for user-conference interactions (connection, joining, muting)
|
||||
- **`./modules/external-api`** - External API for iframe integration and events
|
||||
- **`./lang/`** - Translations in `main-[language].json` files
|
||||
- **`./css/`** - SCSS files organized by features, matching React feature structure
|
||||
|
||||
### State Management Flow
|
||||
1. Actions dispatched from components
|
||||
2. Middleware processes side effects
|
||||
3. Reducers update state
|
||||
4. Components re-render based on state changes
|
||||
5. Registry pattern keeps features decoupled
|
||||
|
||||
### Cross-Platform Strategy
|
||||
- Abstract components handle shared logic
|
||||
- Platform files (.web.ts, .native.ts) handle platform differences
|
||||
- Build system excludes irrelevant platform files
|
||||
- TypeScript configs ensure proper platform targeting
|
||||
|
||||
## External Resources
|
||||
- [Jitsi Handbook](https://jitsi.github.io/handbook/) - Comprehensive documentation
|
||||
- [Community Forum](https://community.jitsi.org/) - Ask questions and get support
|
||||
- [Architecture Guide](https://jitsi.github.io/handbook/docs/architecture) - System overview
|
||||
- [Contributing Guidelines](https://jitsi.github.io/handbook/docs/dev-guide/contributing) - Detailed contribution process
|
||||
@@ -24,6 +24,7 @@ import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.res.Configuration;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -94,7 +95,9 @@ public class JitsiMeetActivity extends AppCompatActivity
|
||||
}
|
||||
|
||||
public static void addTopBottomInsets(@NonNull Window w, @NonNull View v) {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) return;
|
||||
|
||||
// Only apply if edge-to-edge is supported (API 30+) or enforced (API 35+)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return;
|
||||
|
||||
View decorView = w.getDecorView();
|
||||
|
||||
|
||||
@@ -723,6 +723,8 @@ var config = {
|
||||
// autoKnock: false,
|
||||
// // Enables the lobby chat. Replaces `enableLobbyChat`.
|
||||
// enableChat: true,
|
||||
// // Shows the hangup button in the lobby screen.
|
||||
// showHangUp: true,
|
||||
// },
|
||||
|
||||
// Configs for the security related UI elements.
|
||||
|
||||
10
debian/jitsi-meet-prosody.postinst
vendored
10
debian/jitsi-meet-prosody.postinst
vendored
@@ -154,6 +154,16 @@ case "$1" in
|
||||
PROSODY_CONFIG_PRESENT="false"
|
||||
fi
|
||||
|
||||
# Start using the polls component
|
||||
if ! grep -q "Component \"polls.$JVB_HOSTNAME\"" $PROSODY_HOST_CONFIG ;then
|
||||
echo -e "\nComponent \"polls.$JVB_HOSTNAME\" \"polls_component\"" >> $PROSODY_HOST_CONFIG
|
||||
PROSODY_CONFIG_PRESENT="false"
|
||||
fi
|
||||
if ! grep -q -- '--"polls";' $PROSODY_HOST_CONFIG ;then
|
||||
sed -i "s/\"polls\";/--\"polls\";/g" $PROSODY_HOST_CONFIG
|
||||
PROSODY_CONFIG_PRESENT="false"
|
||||
fi
|
||||
|
||||
# Old versions of jitsi-meet-prosody come with the extra plugin path commented out (https://github.com/jitsi/jitsi-meet/commit/e11d4d3101e5228bf956a69a9e8da73d0aee7949)
|
||||
# Make sure it is uncommented, as it contains required modules.
|
||||
if grep -q -- '--plugin_paths = { "/usr/share/jitsi-meet/prosody-plugins/" }' $PROSODY_HOST_CONFIG ;then
|
||||
|
||||
@@ -83,7 +83,6 @@ Component "conference.jitmeet.example.com" "muc"
|
||||
"muc_hide_all";
|
||||
"muc_meeting_id";
|
||||
"muc_domain_mapper";
|
||||
"polls";
|
||||
--"token_verification";
|
||||
"muc_rate_limit";
|
||||
"muc_password_whitelist";
|
||||
@@ -159,9 +158,10 @@ Component "lobby.jitmeet.example.com" "muc"
|
||||
modules_enabled = {
|
||||
"muc_hide_all";
|
||||
"muc_rate_limit";
|
||||
"polls";
|
||||
}
|
||||
|
||||
Component "metadata.jitmeet.example.com" "room_metadata_component"
|
||||
muc_component = "conference.jitmeet.example.com"
|
||||
breakout_rooms_component = "breakout.jitmeet.example.com"
|
||||
|
||||
Component "polls.jitmeet.example.com" "polls_component"
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
"en": "English",
|
||||
"eo": "Esperanto",
|
||||
"es": "Español",
|
||||
"esUS": "Español (Latinoamérica)",
|
||||
"es-US": "Español (Latinoamérica)",
|
||||
"et": "Eesti",
|
||||
"eu": "Euskara",
|
||||
"fa": "فارسی",
|
||||
"fi": "Suomi",
|
||||
"fr": "Français",
|
||||
"frCA": "Français (Canada)",
|
||||
"fr-CA": "Français (Canada)",
|
||||
"gl": "Galego",
|
||||
"he": "עברית",
|
||||
"hi": "हिन्दी",
|
||||
@@ -43,7 +43,7 @@
|
||||
"oc": "Occitan",
|
||||
"pl": "Polski",
|
||||
"pt": "Português",
|
||||
"ptBR": "Português (Brasil)",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ro": "Română",
|
||||
"ru": "Русский",
|
||||
"sc": "Sardu",
|
||||
@@ -56,6 +56,6 @@
|
||||
"tr": "Türkçe",
|
||||
"uk": "Українська",
|
||||
"vi": "Tiếng Việt",
|
||||
"zhCN": "中文(简体)",
|
||||
"zhTW": "中文(繁體)"
|
||||
"zh-CN": "中文(简体)",
|
||||
"zh-TW": "中文(繁體)"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,24 +11,17 @@
|
||||
"copyStream": "Copier le lien de diffusion en direct",
|
||||
"countryNotSupported": "Nous ne prenons pas encore cette destination en charge.",
|
||||
"countryReminder": "Vous appelez en dehors des É.-U.? Veuillez vous assurer de commencer par le code de pays!",
|
||||
"defaultEmail": "Votre email par défaut",
|
||||
"disabled": "Vous ne pouvez pas inviter d'autres personnes.",
|
||||
"failedToAdd": "L'ajout de membres a échoué",
|
||||
"footerText": "Les appels sont désactivés.",
|
||||
"googleEmail": "Gmail",
|
||||
"inviteMoreHeader": "Vous êtes seul(e) dans la réunion",
|
||||
"inviteMoreMailSubject": "Rejoindre une réunion {{appName}}",
|
||||
"inviteMorePrompt": "Inviter d'autres personnes",
|
||||
"linkCopied": "Lien copié dans le presse-papiers",
|
||||
"loading": "Rechercher des personnes et des numéros de téléphone",
|
||||
"loadingNumber": "Validation du numéro de téléphone",
|
||||
"loadingPeople": "Rechercher des personnes à inviter",
|
||||
"noResults": "Aucun résultat de recherche correspondant",
|
||||
"noValidNumbers": "Veuillez entrer un numéro de téléphone",
|
||||
"outlookEmail": "Outlook",
|
||||
"phoneNumbers": "Numéros de téléphone",
|
||||
"searchNumbers": "Ajouter des numéros de téléphone",
|
||||
"searchPeople": "Rechercher des personnes",
|
||||
"searchPeopleAndNumbers": "Rechercher des personnes ou ajouter des numéros de téléphone",
|
||||
"searching": "Recherche…",
|
||||
"shareInvite": "Partager l'invitation à la réunion",
|
||||
"shareLink": "Partager le lien de la réunion pour inviter d'autres personnes",
|
||||
@@ -116,9 +109,12 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"disabled": "L'envoi de messages de chat est désactivé.",
|
||||
"enter": "Entrez dans le salon",
|
||||
"error": "Erreur : votre message n'a pas été envoyé. Raison : {{error}}",
|
||||
"everyone": "Tout le monde",
|
||||
"fieldPlaceHolder": "Tapez votre message ici",
|
||||
"guestsChatIndicator": "(invité)",
|
||||
"lobbyChatMessageTo": "Message de salle d'attente à {{recipient}}",
|
||||
"message": "Message",
|
||||
"messageAccessibleTitle": "{{user}} dit: ",
|
||||
@@ -129,12 +125,26 @@
|
||||
"nickname": {
|
||||
"popover": "Choisissez un nom d'affichage",
|
||||
"title": "Entrer un nom d'affichage pour utiliser le clavardage",
|
||||
"titleWithPolls": "Entrer un nom d'affichage pour utiliser le clavardage"
|
||||
"titleWithCC": "Entrez un pseudonyme pour utiliser le chat et les sous-titres",
|
||||
"titleWithPolls": "Entrer un nom d'affichage pour utiliser le clavardage",
|
||||
"titleWithPollsAndCC": "Entrez un pseudonyme pour utiliser le chat, les sondages et les sous-titres",
|
||||
"titleWithPollsAndCCAndFileSharing": "Entrez un pseudonyme pour utiliser le chat, les sondages, les sous-titres et les fichiers"
|
||||
},
|
||||
"noMessagesMessage": "Il n'y a pas encore de messages dans cette réunion. Démarrez une conversation ici !",
|
||||
"noMessagesMessage": "Il n'y a pas encore de messages dans cette réunion. Démarrez une conversation ici!",
|
||||
"privateNotice": "Message privé à {{recipient}}",
|
||||
"sendButton": "Envoyer",
|
||||
"smileysPanel": "Panneaux des Émojis",
|
||||
"systemDisplayName": "Système",
|
||||
"tabs": {
|
||||
"chat": "Chat",
|
||||
"closedCaptions": "ST",
|
||||
"fileSharing": "Fichiers",
|
||||
"polls": "Sondages"
|
||||
},
|
||||
"title": "Clavardage",
|
||||
"titleWithCC": "ST",
|
||||
"titleWithFeatures": "Chat et",
|
||||
"titleWithFileSharing": "Fichiers",
|
||||
"titleWithPolls": "Clavardage",
|
||||
"you": "vous"
|
||||
},
|
||||
@@ -145,6 +155,10 @@
|
||||
"dontShowAgain": "Ne plus m'afficher ceci",
|
||||
"installExtensionText": "Installer l'extension pour l'intégration de Google Calendar et Office 365"
|
||||
},
|
||||
"closedCaptionsTab": {
|
||||
"emptyState": "Le contenu des sous-titres sera disponible quand un modérateur les aura démarrés",
|
||||
"startClosedCaptionsButton": "Démarrer les sous-titres"
|
||||
},
|
||||
"connectingOverlay": {
|
||||
"joiningRoom": "Connexion à la réunion en cours…"
|
||||
},
|
||||
@@ -161,8 +175,7 @@
|
||||
"FETCH_SESSION_ID": "Obtention d'un identifiant de session…",
|
||||
"GET_SESSION_ID_ERROR": "Obtenir une erreur d'identifiant de session: {{code}}",
|
||||
"GOT_SESSION_ID": "Obtention d'un identifiant de session… Terminée",
|
||||
"LOW_BANDWIDTH": "La vidéo de {{displayName}} a été coupée pour économiser de la bande passante",
|
||||
"RECONNECTING": "Un problème de réseau est survenu. Reconnexion en cours…"
|
||||
"LOW_BANDWIDTH": "La vidéo de {{displayName}} a été coupée pour économiser de la bande passante"
|
||||
},
|
||||
"connectionindicator": {
|
||||
"address": "Adresse :",
|
||||
@@ -183,6 +196,7 @@
|
||||
"more": "Afficher plus",
|
||||
"no": "non",
|
||||
"packetloss": "Perte de paquet :",
|
||||
"participant_id": "ID du participant:",
|
||||
"quality": {
|
||||
"good": "Bonne",
|
||||
"inactive": "Inactive",
|
||||
@@ -221,8 +235,9 @@
|
||||
"joinInBrowser": "Rejoindre depuis le navigateur",
|
||||
"launchMeetingLabel": "Comment voulez-vous rejoindre la réunion ?",
|
||||
"launchWebButton": "Démarrer dans l'application Web",
|
||||
"noDesktopApp": "Vous n'avez pas l'application ?",
|
||||
"noMobileApp": "Vous n'avez pas l'application ?",
|
||||
"openApp": "Continuer vers l'application",
|
||||
"or": "OU",
|
||||
"termsAndConditions": "En continuant, vous acceptez nos <a href='{{termsAndConditionsLink}}' rel='noopener noreferrer' target='_blank'>conditions générales d’utilisation.</a>",
|
||||
"title": "Démarrage de votre réunion dans {{app}} en cours…",
|
||||
"titleNew": "Démarrage de votre réunion…",
|
||||
@@ -263,8 +278,9 @@
|
||||
"Remove": "Supprimer",
|
||||
"Share": "Partager",
|
||||
"Submit": "Envoyer",
|
||||
"WaitForHostMsg": "La conférence n'a pas encore démarré. Si vous êtes l'hôte, veuillez vous authentifier. Sinon, veuillez attendre que l'hôte arrive.",
|
||||
"WaitingForHost": "En attente de l'hôte…",
|
||||
"Understand": "Je comprends, gardez-moi en sourdine pour l'instant",
|
||||
"UnderstandAndUnmute": "Je comprends, veuillez me réactiver s'il vous plaît",
|
||||
"WaitForHostNoAuthMsg": "La réunion n'a pas encore commencé car aucun modérateur n'est encore arrivé. Veuillez patienter.",
|
||||
"WaitingForHostButton": "Attendre l'hôte",
|
||||
"WaitingForHostTitle": "En attente de l'hôte…",
|
||||
"Yes": "Oui",
|
||||
@@ -276,19 +292,27 @@
|
||||
"sharingTabs": "Options de partage"
|
||||
},
|
||||
"add": "Ajouter",
|
||||
"addMeetingNote": "Ajouter une note à cette conférence",
|
||||
"addMeetingNote": "Ajouter une note à cette réunion",
|
||||
"addOptionalNote": "Ajouter une note (optionnel):",
|
||||
"allow": "Autoriser",
|
||||
"allowToggleCameraDialog": "Autorisez-vous {{initiatorName}} à changer votre mode de caméra?",
|
||||
"allowToggleCameraTitle": "Autoriser-vous le changement de mode de caméra?",
|
||||
"alreadySharedVideoMsg": "Un autre membre partage déjà une vidéo. Cette conférence permet le partage d'une seule vidéo à la fois.",
|
||||
"alreadySharedVideoMsg": "Un autre membre partage déjà une vidéo. Cette réunion permet le partage d'une seule vidéo à la fois.",
|
||||
"alreadySharedVideoTitle": "Seulement une vidéo à la fois peut être partagée",
|
||||
"applicationWindow": "Fenêtre d'application",
|
||||
"authenticationRequired": "Authentification requise",
|
||||
"cameraCaptureDialog": {
|
||||
"description": "Prendre et envoyer une photo en utilisant votre caméra mobile",
|
||||
"ok": "Ouvrir la caméra",
|
||||
"reject": "Pas maintenant",
|
||||
"title": "Prendre une photo"
|
||||
},
|
||||
"cameraConstraintFailedError": "Votre caméra ne répond pas à certaines exigences.",
|
||||
"cameraNotFoundError": "Impossible de trouver la caméra.",
|
||||
"cameraNotSendingData": "Il est impossible d'accéder à la caméra. Veuillez vérifier si une autre application utilise actuellement ce dispositif, sélectionner un autre dispositif à partir du menu des paramètres ou essayer de recharger l'application.",
|
||||
"cameraNotSendingDataTitle": "Impossible d'accéder à la caméra",
|
||||
"cameraPermissionDeniedError": "Vous n'avez pas reçu l'autorisation d'utiliser votre caméra. Vous pouvez toujours rejoindre la conférence, mais les autres membres ne pourront pas vous voir. Utilisez le bouton de caméra dans la barre d'adresse pour corriger cela.",
|
||||
"cameraPermissionDeniedError": "Vous n'avez pas reçu l'autorisation d'utiliser votre caméra. Vous pouvez toujours rejoindre la réunion, mais les autres membres ne pourront pas vous voir. Utilisez le bouton de caméra dans la barre d'adresse pour corriger cela.",
|
||||
"cameraTimeoutError": "Impossible de démarrer la source vidéo. Délai dépassé!",
|
||||
"cameraUnknownError": "Impossible d'utiliser la caméra pour une raison inconnue.",
|
||||
"cameraUnsupportedResolutionError": "Votre caméra ne prend pas en charge la résolution vidéo nécessaire.",
|
||||
"close": "Fermer",
|
||||
@@ -297,28 +321,29 @@
|
||||
"conferenceReloadMsg": "Nous tentons de résoudre le problème. Reconnexion dans {{seconds}} sec…",
|
||||
"conferenceReloadTitle": "Malheureusement, une erreur s'est produite.",
|
||||
"confirm": "Confirmer",
|
||||
"confirmBack": "Retour",
|
||||
"confirmNo": "Non",
|
||||
"confirmYes": "Oui",
|
||||
"connectError": "Oups! Une erreur s'est produite. La connexion à la conférence a échouée.",
|
||||
"connectErrorWithMsg": "Oups! Une erreur s'est produite. La connexion à la conférence a échoué : {{msg}}",
|
||||
"connectError": "Oups! Une erreur s'est produite. La connexion à la réunion a échouée.",
|
||||
"connectErrorWithMsg": "Oups! Une erreur s'est produite. La connexion à la réunion a échoué : {{msg}}",
|
||||
"connecting": "Connexion en cours",
|
||||
"contactSupport": "Communiquez avec le service de soutien",
|
||||
"copied": "Copié",
|
||||
"copy": "Copier",
|
||||
"demoteParticipantDialog": "Êtes-vous sûr de vouloir déplacer ce participant en visiteur ?",
|
||||
"demoteParticipantTitle": "Déplacer en visiteur",
|
||||
"dismiss": "Rejeter",
|
||||
"displayNameRequired": "Un nom d'affichage est requis",
|
||||
"done": "Terminé",
|
||||
"e2eeDescription": "Le chiffrement de bout en bout est actuellement expérimental. Veuillez garder en tête que l'activation du chiffrement de bout en bout désactivera les services fournis côté serveur tels que : l'enregistrement, la diffusion en direct et la participation par téléphone. Gardez également en tête que la réunion ne fonctionnera que pour les personnes qui se connectent à partir de navigateurs prenant en charge les flux insérables.",
|
||||
"e2eeDisabledDueToMaxModeDescription": "Impossible d'activer le chiffrement de bout en bout en raison du trop grand nombre de participants à la conférence.",
|
||||
"e2eeDisabledDueToMaxModeDescription": "Impossible d'activer le chiffrement de bout en bout en raison du trop grand nombre de participants à la réunion.",
|
||||
"e2eeLabel": "Activer le chiffrement de Bout-en-Bout",
|
||||
"e2eeWarning": "ATTENTION : Tous les participants de cette réunion ne semblent pas prendre en charge le chiffrement de bout en bout. Si vous activez le chiffrement, ils ne pourront ni vous voir, ni vous entendre.",
|
||||
"e2eeWillDisableDueToMaxModeDescription": "ATTENTION: le chiffrement de bout en bout sera automatiquement arrêté si plus de participants joignent la conférence.",
|
||||
"e2eeWillDisableDueToMaxModeDescription": "ATTENTION: le chiffrement de bout en bout sera automatiquement arrêté si plus de participants joignent la réunion.",
|
||||
"embedMeeting": "Intégrer la réunion",
|
||||
"enterDisplayName": "Veuillez saisir votre nom d'affichage",
|
||||
"error": "Erreur",
|
||||
"externalInstallationMsg": "Vous devez installer notre extension de partage de bureau.",
|
||||
"externalInstallationTitle": "Extension requise",
|
||||
"goToStore": "Rendez-vous sur notre boutique en ligne",
|
||||
"errorRoomCreationRestriction": "Vous avez essayé de rejoindre trop rapidement, veuillez revenir dans un moment.",
|
||||
"gracefulShutdown": "Notre service est actuellement hors service pour l'entretien. Veuillez réessayer plus tard.",
|
||||
"grantModeratorDialog": "Êtes-vous sûr de vouloir rendre ce participant modérateur ?",
|
||||
"grantModeratorTitle": "Nommer modérateur",
|
||||
@@ -326,57 +351,65 @@
|
||||
"hideShareAudioHelper": "Ne plus afficher ce dialogue",
|
||||
"incorrectPassword": "Nom d'utilisateur ou mot de passe incorrect",
|
||||
"incorrectRoomLockPassword": "Mot de passe incorrect",
|
||||
"inlineInstallExtension": "Installer maintenant",
|
||||
"inlineInstallationMsg": "Vous devez installer notre extension de partage de bureau.",
|
||||
"internalError": "Oups! Une erreur s'est produite. L'erreur suivante est survenue : {{error}}",
|
||||
"internalErrorTitle": "Erreur interne.",
|
||||
"kickMessage": "Aïe! Vous avez été expulsé de la réunion!",
|
||||
"kickParticipantButton": "Expulser",
|
||||
"kickParticipantDialog": "Êtes-vous certain de vouloir expulser ce participant?",
|
||||
"kickParticipantTitle": "Expulser ce membre?",
|
||||
"kickSystemTitle": "Oups ! Vous avez été expulsé de la réunion",
|
||||
"kickTitle": "Expulsé de la réunion",
|
||||
"linkMeeting": "Relier la conférence",
|
||||
"linkMeetingTitle": "Relier la conférence à Salesforce",
|
||||
"learnMore": "en savoir plus",
|
||||
"linkMeeting": "Relier la réunion",
|
||||
"linkMeetingTitle": "Relier la réunion à Salesforce",
|
||||
"liveStreaming": "Diffusion en direct",
|
||||
"liveStreamingDisabledBecauseOfActiveRecordingTooltip": "Impossible durant l'enregistrement",
|
||||
"liveStreamingDisabledForGuestTooltip": "Les invités ne peuvent pas démarrer la diffusion en direct.",
|
||||
"liveStreamingDisabledTooltip": "Démarrage de la diffusion en direct désactivé.",
|
||||
"localUserControls": "Contrôles de l'utilisateur local",
|
||||
"lockMessage": "Échec du verrouillage de la conférence.",
|
||||
"lockMessage": "Échec du verrouillage de la réunion.",
|
||||
"lockRoom": "Ajouter un mot de passe à la réunion",
|
||||
"lockTitle": "Échec du verrouillage",
|
||||
"login": "Connexion",
|
||||
"loginQuestion": "Voulez-vous vraiment vous connecter et quitter la conférence?",
|
||||
"logoutQuestion": "Êtes-vous certain de vouloir vous déconnecter et arrêter la conférence?",
|
||||
"loginQuestion": "Voulez-vous vraiment vous connecter et quitter la réunion?",
|
||||
"logoutQuestion": "Êtes-vous certain de vouloir vous déconnecter et arrêter la réunion?",
|
||||
"logoutTitle": "Déconnexion",
|
||||
"maxUsersLimitReached": "La limite du nombre maximum de membres a été atteinte. La conférence est pleine. Veuillez communiquer avec l'hôte de la réunion ou réessayer plus tard.",
|
||||
"maxUsersLimitReached": "La limite du nombre maximum de membres a été atteinte. La réunion est pleine. Veuillez communiquer avec l'hôte de la réunion ou réessayer plus tard.",
|
||||
"maxUsersLimitReachedTitle": "Limite du nombre de membres maximum atteinte",
|
||||
"micConstraintFailedError": "Votre micro ne répond pas à certaines exigences",
|
||||
"micNotFoundError": "Impossible de trouver le micro.",
|
||||
"micNotSendingData": "Impossible d'accéder à votre micro. Veuillez sélectionner un autre dispositif à partir du menu des paramètres ou essayer de recharger l'application.",
|
||||
"micNotSendingDataTitle": "Impossible d'accéder à votre micro",
|
||||
"micPermissionDeniedError": "Vous n'avez pas accordé l'autorisation d'utilisation de votre micro. Vous pouvez toujours rejoindre la conférence, mais les autres membres ne pourront pas vous entendre. Utilisez le bouton de caméra dans la barre d'adresse pour remédier à cela.",
|
||||
"micPermissionDeniedError": "Vous n'avez pas accordé l'autorisation d'utilisation de votre micro. Vous pouvez toujours rejoindre la réunion, mais les autres membres ne pourront pas vous entendre. Utilisez le bouton de caméra dans la barre d'adresse pour remédier à cela.",
|
||||
"micTimeoutError": "Impossible de démarrer la source audio. Délai dépassé!",
|
||||
"micUnknownError": "Impossible d'utiliser le micro pour une raison inconnue.",
|
||||
"moderationAudioLabel": "Autoriser les participants à réactiver leur micro",
|
||||
"moderationDesktopLabel": "Autoriser les non-modérateurs à partager leur écran",
|
||||
"moderationVideoLabel": "Autoriser les participants à démarrer leur vidéo",
|
||||
"muteEveryoneDialog": "Êtes-vous sûr de vouloir couper les micros de tout le monde? Vous ne pourrez plus réactiver leur micro, mais ils pourront l'activer par eux-mêmes à tout moment.",
|
||||
"muteEveryoneDialogModerationOn": "Les participants peuvent demander à parler n'importe quand",
|
||||
"muteEveryoneElseDialog": "Une fois leur micro coupé, vous ne pourrez plus le réactiver, mais ils pourront l'activer par eux-mêmes à tout moment.",
|
||||
"muteEveryoneElseTitle": "Couper le micro de tout le monde sauf de {{whom}}?",
|
||||
"muteEveryoneElsesDesktopDialog": "Une fois le partage arrêté, vous ne pourrez pas le redémarrer, mais ils peuvent le faire à tout moment.",
|
||||
"muteEveryoneElsesDesktopTitle": "Arrêter le partage d'écran de tout le monde sauf {{whom}} ?",
|
||||
"muteEveryoneElsesVideoDialog": "Une fois la caméra coupée, vous ne pourrez plus la rallumer, mais ils peuvent la rallumer à tout moment.",
|
||||
"muteEveryoneElsesVideoTitle": "Couper la vidéo de tout le monde sauf {{whom}}?",
|
||||
"muteEveryoneSelf": "vous",
|
||||
"muteEveryoneStartMuted": "Tout le monde démarre avec le micro coupé",
|
||||
"muteEveryoneTitle": "Couper le micro de tout le monde ?",
|
||||
"muteEveryonesDesktopDialog": "Les participants peuvent partager leur écran à tout moment.",
|
||||
"muteEveryonesDesktopDialogModerationOn": "Les participants peuvent envoyer une demande pour partager leur écran à tout moment.",
|
||||
"muteEveryonesDesktopTitle": "Arrêter le partage d'écran de tout le monde ?",
|
||||
"muteEveryonesVideoDialog": "Êtes-vous sûr de vouloir couper la caméra de tout le monde? Vous ne pourrez pas la réactiver, mais ils peuvent la remettre à tout moment.",
|
||||
"muteEveryonesVideoDialogModerationOn": "Les participants peuvent demander à activer leur caméra n'importe quand.",
|
||||
"muteEveryonesVideoDialogOk": "Désactiver",
|
||||
"muteEveryonesVideoTitle": "Couper la caméra de tout le monde?",
|
||||
"muteParticipantBody": "Vous ne pourrez pas réactiver leur micro, mais ils peuvent le réactiver eux-mêmes à tout moment.",
|
||||
"muteParticipantButton": "Discrétion",
|
||||
"muteParticipantDialog": "Êtes-vous certain de vouloir désactiver le micro de ce participant? Vous ne pourrez pas le réactiver, mais il peut le réactiver lui-même à tout moment.",
|
||||
"muteParticipantTitle": "Désactiver le micro de ce membre?",
|
||||
"muteParticipantsDesktopBody": "Vous ne pourrez pas démarrer leur partage d'écran, mais ils peuvent le faire à tout moment.",
|
||||
"muteParticipantsDesktopBodyModerationOn": "Vous ne pourrez pas démarrer leur partage d'écran et eux non plus.",
|
||||
"muteParticipantsDesktopButton": "Arrêter le partage d'écran",
|
||||
"muteParticipantsDesktopDialog": "Êtes-vous sûr de vouloir désactiver le partage d'écran de ce participant ? Vous ne pourrez pas le redémarrer, mais ils peuvent le faire à tout moment.",
|
||||
"muteParticipantsDesktopDialogModerationOn": "Êtes-vous sûr de vouloir désactiver le partage d'écran de ce participant ? Vous ne pourrez pas réactiver l'écran et eux non plus.",
|
||||
"muteParticipantsDesktopTitle": "Désactiver le partage d'écran de ce participant ?",
|
||||
"muteParticipantsVideoBody": "Vous ne pourrez pas rallumer la caméra, mais ils peuvent la rallumer à tout moment.",
|
||||
"muteParticipantsVideoBodyModerationOn": "Ni vous ni le participant ne pourront rallumer la caméra.",
|
||||
"muteParticipantsVideoButton": "Couper la caméra",
|
||||
@@ -392,14 +425,14 @@
|
||||
"permissionCameraRequiredError": "L'autorisation caméra est nécessaire pour participer aux réunions avec vidéo. Merci de l'accorder dans les paramètres",
|
||||
"permissionErrorTitle": "Permission nécessaire",
|
||||
"permissionMicRequiredError": "L'autorisation microphone est nécessaire pour participer aux réunions avec son. Merci de l'accorder dans les paramètres",
|
||||
"popupError": "Votre navigateur bloque les fenêtres surgissantes provenant de ce site. Veuillez activer les fenêtres surgissantes dans les paramètres de sécurité de votre navigateur et réessayer.",
|
||||
"popupErrorTitle": "Fenêtre surgissante bloquée",
|
||||
"readMore": "plus",
|
||||
"recentlyUsedObjects": "Vos objets récemment utilisés",
|
||||
"recording": "Enregistrement",
|
||||
"recordingDisabledBecauseOfActiveLiveStreamingTooltip": "Impossible durant le direct",
|
||||
"recordingDisabledForGuestTooltip": "Les invités ne peuvent pas démarrer l'enregistrement.",
|
||||
"recordingDisabledTooltip": "Démarrage de l'enregistrement désactivé.",
|
||||
"recordingInProgressDescription": "Cette réunion est en cours d'enregistrement et d'analyse par IA{{learnMore}}. Votre audio et vidéo ont été coupés. Si vous choisissez de vous réactiver, vous consentez à être enregistré.",
|
||||
"recordingInProgressDescriptionFirstHalf": "Cette réunion est en cours d'enregistrement et d'analyse par IA",
|
||||
"recordingInProgressDescriptionSecondHalf": ". Votre audio et vidéo ont été coupés. Si vous choisissez de vous réactiver, vous consentez à être enregistré.",
|
||||
"recordingInProgressTitle": "Enregistrement en cours",
|
||||
"rejoinNow": "Rejoindre maintenant",
|
||||
"remoteControlAllowedMessage": "{{user}} a accepté votre demande de contrôle à distance!",
|
||||
"remoteControlDeniedMessage": "{{user}} a refusé votre demande de contrôle à distance!",
|
||||
@@ -408,6 +441,7 @@
|
||||
"remoteControlShareScreenWarning": "Notez que si vous appuyez sur « Permettre », vous partagerez votre écran!",
|
||||
"remoteControlStopMessage": "La séance de contrôle à distance est terminée!",
|
||||
"remoteControlTitle": "Contrôle du bureau à distance",
|
||||
"remoteUserControls": "Contrôles de l'utilisateur distant {{username}}",
|
||||
"removePassword": "Supprimer un mot de passe",
|
||||
"removeSharedVideoMsg": "Êtes-vous certain de vouloir supprimer votre vidéo partagée?",
|
||||
"removeSharedVideoTitle": "Supprimer la vidéo partagée",
|
||||
@@ -419,10 +453,6 @@
|
||||
"screenSharingAudio": "Partager l'audio",
|
||||
"screenSharingFailed": "Oups! Quelque chose s'est mal passé, nous n'avons pas pu démarrer le partage d'écran!",
|
||||
"screenSharingFailedTitle": "Echec du partage d'écran!",
|
||||
"screenSharingFailedToInstall": "Oups! L'installation de votre extension de partage d'écran a échouée.",
|
||||
"screenSharingFailedToInstallTitle": "L'installation de l'extension de partage d'écran a échouée",
|
||||
"screenSharingFirefoxPermissionDeniedError": "Une erreur s'est produite lors de la tentative de partage d'écran. Veuillez vous assurer d'avoir donné votre autorisation.",
|
||||
"screenSharingFirefoxPermissionDeniedTitle": "Oups! Il est impossible de démarrer le partage d'écran!",
|
||||
"screenSharingPermissionDeniedError": "Oups! Une erreur s'est produite avec les autorisations de l'extension de partage d'écran. Veuillez recharger et réessayer.",
|
||||
"searchInSalesforce": "Rechercher dans Salesforce",
|
||||
"searchResults": "Résultats de recherche ({{count}})",
|
||||
@@ -450,11 +480,13 @@
|
||||
"shareScreenWarningD2": "vous devez arrêter le partage d'audio, démarrer le partage d'écran et cocher l'option \"Partager l'audio\".",
|
||||
"shareScreenWarningH1": "Si vous voulez partager uniquement votre écran:",
|
||||
"shareScreenWarningTitle": "Vous devez cesser de partager votre audio avant de partager votre écran",
|
||||
"shareVideoConfirmPlay": "Vous êtes sur le point d'ouvrir un site web externe. Voulez-vous continuer ?",
|
||||
"shareVideoConfirmPlayTitle": "{{name}} a partagé une vidéo avec vous.",
|
||||
"shareVideoLinkError": "Veuillez fournir un lien correct.",
|
||||
"shareVideoLinkStopped": "La vidéo de {{name}} a été arrêtée.",
|
||||
"shareVideoTitle": "Partager une vidéo",
|
||||
"shareYourScreen": "Partager votre écran",
|
||||
"shareYourScreenDisabled": "Le partage d'écran est désactivé.",
|
||||
"shareYourScreenDisabledForGuest": "Les invités ne peuvent pas partager leur écran.",
|
||||
"sharedVideoDialogError": "Erreur: URL invalide",
|
||||
"sharedVideoLinkPlaceholder": "lien YouTube ou lien vidéo direct",
|
||||
"show": "Afficher",
|
||||
@@ -512,7 +544,7 @@
|
||||
"title": "Document partagé"
|
||||
},
|
||||
"e2ee": {
|
||||
"labelToolTip": "Le son et la vidéo de cette conférence sont chiffrés de bout en bout"
|
||||
"labelToolTip": "Le son et la vidéo de cette réunion sont chiffrés de bout en bout"
|
||||
},
|
||||
"embedMeeting": {
|
||||
"title": "Intégrer cette réunion"
|
||||
@@ -525,10 +557,28 @@
|
||||
"bad": "Mauvaise",
|
||||
"detailsLabel": "Dites-nous en plus.",
|
||||
"good": "Bonne",
|
||||
"rateExperience": "Évaluez votre expérience de cette conférence",
|
||||
"rateExperience": "Évaluez votre expérience de cette réunion",
|
||||
"star": "Étoile",
|
||||
"veryBad": "Très mauvaise",
|
||||
"veryGood": "Très bonne"
|
||||
},
|
||||
"fileSharing": {
|
||||
"downloadFailedDescription": "Veuillez réessayer.",
|
||||
"downloadFailedTitle": "Échec du téléchargement",
|
||||
"downloadFile": "Télécharger",
|
||||
"downloadStarted": "Téléchargement de fichier démarré",
|
||||
"dragAndDrop": "Glisser-déposer des fichiers ici ou n'importe où à l'écran",
|
||||
"fileAlreadyUploaded": "Ce fichier a déjà été téléversé dans cette réunion.",
|
||||
"fileTooLargeDescription": "Veuillez vous assurer que le fichier ne dépasse pas {{ maxFileSize }}.",
|
||||
"fileTooLargeTitle": "Le fichier choisi est trop volumineux",
|
||||
"fileUploadProgress": "Progression du téléchargement de fichier",
|
||||
"fileUploadedSuccessfully": "Fichier téléversé avec succès",
|
||||
"removeFile": "Supprimer",
|
||||
"removeFileSuccess": "Fichier supprimé avec succès",
|
||||
"uploadFailedDescription": "Veuillez réessayer.",
|
||||
"uploadFailedTitle": "Échec du téléchargement",
|
||||
"uploadFile": "Partager un fichier"
|
||||
},
|
||||
"filmstrip": {
|
||||
"accessibilityLabel": {
|
||||
"heading": "Vignettes vidéos"
|
||||
@@ -576,6 +626,7 @@
|
||||
"noNumbers": "Aucun numéro d'appel trouvé",
|
||||
"noPassword": "Aucun",
|
||||
"noRoom": "Vous n'avez pas précisé de salle pour l'appel interne.",
|
||||
"noWhiteboard": "Impossible de charger le tableau blanc.",
|
||||
"numbers": "Numéros d'appel",
|
||||
"password": "Mot de passe:",
|
||||
"reachedLimit": "Vous avez atteint la limite de votre abonnement.",
|
||||
@@ -583,7 +634,8 @@
|
||||
"sipAudioOnly": "Adresse SIP en audio uniquement",
|
||||
"title": "Partager",
|
||||
"tooltip": "Lien de partage et informations d'appel interne pour cette réunion",
|
||||
"upgradeOptions": "Veuillez vérifier les options de mise à niveau"
|
||||
"upgradeOptions": "Veuillez vérifier les options de mise à niveau",
|
||||
"whiteboardError": "Erreur de chargement du tableau blanc. Veuillez réessayer plus tard."
|
||||
},
|
||||
"inlineDialogFailure": {
|
||||
"msg": "Nous avons rencontré un obstacle.",
|
||||
@@ -613,10 +665,10 @@
|
||||
"showSpeakerStats": "Afficher les statistiques d'intervenant",
|
||||
"toggleChat": "Ouvrir ou fermer le clavardage",
|
||||
"toggleFilmstrip": "Afficher ou masquer les icônes vidéos",
|
||||
"toggleParticipantsPane": "Afficher ou masquer le volet des participants",
|
||||
"toggleScreensharing": "Basculer entre la caméra et le partage d'écran",
|
||||
"toggleShortcuts": "Afficher ou masquer les raccourcis clavier",
|
||||
"videoMute": "Démarrer ou arrêter votre caméra",
|
||||
"videoQuality": "Gérer la qualité d'appel"
|
||||
"videoMute": "Démarrer ou arrêter votre caméra"
|
||||
},
|
||||
"largeVideo": {
|
||||
"screenIsShared": "Vous êtes en train de partager votre écran",
|
||||
@@ -647,6 +699,7 @@
|
||||
"on": "Diffusion en direct",
|
||||
"onBy": "{{name}} démarré la diffusion en continu",
|
||||
"pending": "Démarrage de la diffusion en direct…",
|
||||
"policyError": "Vous avez essayé de démarrer une diffusion en direct trop rapidement. Veuillez réessayer plus tard !",
|
||||
"serviceName": "Service de diffusion en direct",
|
||||
"sessionAlreadyActive": "Cette session est déjà en cours d'enregistrement ou de diffusion.",
|
||||
"signIn": "Se connecter avec Google",
|
||||
@@ -694,7 +747,8 @@
|
||||
"notificationTitle": "Salle d'attente",
|
||||
"passwordJoinButton": "Rejoindre",
|
||||
"title": "Salle d'attente",
|
||||
"toggleLabel": "Activer la salle d'attente"
|
||||
"toggleLabel": "Activer la salle d'attente",
|
||||
"waitForModerator": "La réunion n'a pas encore commencé car aucun modérateur n'est encore arrivé. Si vous souhaitez devenir modérateur, veuillez vous connecter. Sinon, veuillez attendre."
|
||||
},
|
||||
"localRecording": {
|
||||
"clientState": {
|
||||
@@ -737,27 +791,37 @@
|
||||
"me": "moi",
|
||||
"notify": {
|
||||
"OldElectronAPPTitle": "Faille de sécurité !",
|
||||
"allowAction": "Permettre",
|
||||
"allowAll": "Tout autoriser",
|
||||
"allowAudio": "Autoriser l'audio",
|
||||
"allowDesktop": "Autoriser le partage d'écran",
|
||||
"allowVideo": "Autoriser la vidéo",
|
||||
"allowedUnmute": "Vous pouvez réactiver votre écran, votre caméra ou partager votre écran.",
|
||||
"audioUnmuteBlockedDescription": "Le rétablissement du son a été bloqué temporairement en raison de limites système.",
|
||||
"audioUnmuteBlockedTitle": "Rétablissement du son bloqué!",
|
||||
"chatMessages": "Messages de chat",
|
||||
"connectedOneMember": "{{name}} a rejoint la réunion",
|
||||
"connectedThreePlusMembers": "{{name}} et {{count}} autres ont rejoint la réunion",
|
||||
"connectedTwoMembers": "{{first}} et {{second}} ont rejoint la réunion",
|
||||
"connectedOneMember": "{{name}} a rerejoint la réunion",
|
||||
"connectedThreePlusMembers": "{{name}} et {{count}} autres ont rerejoint la réunion",
|
||||
"connectedTwoMembers": "{{first}} et {{second}} ont rerejoint la réunion",
|
||||
"connectionFailed": "Connexion échouée. Veuillez réessayer plus tard !",
|
||||
"dataChannelClosed": "Qualité vidéo dégradée",
|
||||
"dataChannelClosedDescription": "Le canal de communication avec le Bridge a été interrompu, la qualité vidéo se trouve limitée à sa valeur la plus faible.",
|
||||
"dataChannelClosedDescriptionWithAudio": "Le canal de pont est fermé, ce qui peut entraîner des perturbations de l'audio et de la vidéo.",
|
||||
"dataChannelClosedWithAudio": "La qualité de l'audio et de la vidéo peut être altérée",
|
||||
"desktopMutedRemotelyTitle": "Votre partage d'écran a été arrêté par {{participantDisplayName}}",
|
||||
"disabledIframe": "L'intégration Iframe est uniquement destinée à des démos, cet appel se terminera dans {{timeout}} minutes.",
|
||||
"disabledIframeSecondary": "L'intégration Iframe de {{domaine}} est uniquement destinée à des démos, cet appel se terminera dans {{timeout}} minutes.",
|
||||
"disabledIframeSecondaryNative": "L'intégration de {{domain}} est uniquement destinée aux fins de démonstration, cet appel se terminera dans {{timeout}} minutes.",
|
||||
"disabledIframeSecondaryWeb": "L'intégration de {{domain}} est uniquement destinée aux fins de démonstration, cet appel se terminera dans {{timeout}} minutes. Veuillez utiliser <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi as a Service</a> pour l'intégration en production !",
|
||||
"disconnected": "déconnecté",
|
||||
"displayNotifications": "Afficher les notifications pour",
|
||||
"dontRemindMe": "Ne pas me le rappeler",
|
||||
"focus": "Sujet de la conférence",
|
||||
"focus": "Sujet de la réunion",
|
||||
"focusFail": "{{component}} non disponible; réessayez dans {{ms}} sec",
|
||||
"gifsMenu": "GIPHY",
|
||||
"grantedTo": "Droits de modérateur accordés à {{to}}!",
|
||||
"groupTitle": "Notifications",
|
||||
"hostAskedUnmute": "Le modérateur souhaite vous donner la parole",
|
||||
"invalidTenant": "Tenant invalide",
|
||||
"invalidTenantHyphenDescription": "Le tenant que vous utilisez est invalide (commence ou se termine par '-').",
|
||||
"invalidTenantLengthDescription": "Le tenant que vous utilisez est trop long.",
|
||||
"invitedOneMember": "{{displayName}} a été invité",
|
||||
"invitedThreePlusMembers": "{{name}} et {{count}} autres ont été invités",
|
||||
"invitedTwoMembers": "{{first}} et {{second}} ont été invités",
|
||||
@@ -767,11 +831,11 @@
|
||||
"leftThreePlusMembers": "{{name}} et beaucoup d'autres ont quitté la réunion",
|
||||
"leftTwoMembers": "{{first}} et {{second}} ont quitté la réunion",
|
||||
"linkToSalesforce": "Lien à Salesforce",
|
||||
"linkToSalesforceDescription": "Vous pouvez lier le résumé de la conférence à un objet Salesforce.",
|
||||
"linkToSalesforceError": "Impossible de relier la conférence à Salesforce",
|
||||
"linkToSalesforceKey": "Relier cette conférence",
|
||||
"linkToSalesforceProgress": "Liaison de la conférence à Salesforce…",
|
||||
"linkToSalesforceSuccess": "La conférence a été reliée à Salesforce",
|
||||
"linkToSalesforceDescription": "Vous pouvez lier le résumé de la réunion à un objet Salesforce.",
|
||||
"linkToSalesforceError": "Impossible de relier la réunion à Salesforce",
|
||||
"linkToSalesforceKey": "Relier cette réunion",
|
||||
"linkToSalesforceProgress": "Liaison de la réunion à Salesforce…",
|
||||
"linkToSalesforceSuccess": "La réunion a été reliée à Salesforce",
|
||||
"localRecordingStarted": "{{name}} a commencé un enregistrement local.",
|
||||
"localRecordingStopped": "{{name}} a arrêté un enregistrement local.",
|
||||
"me": "Moi",
|
||||
@@ -794,18 +858,21 @@
|
||||
"newDeviceAction": "Utiliser",
|
||||
"newDeviceAudioTitle": "Nouveau dispositif audio détecté",
|
||||
"newDeviceCameraTitle": "Nouvelle caméra détectée",
|
||||
"nextToSpeak": "Vous êtes le prochain à prendre la parole",
|
||||
"noiseSuppressionDesktopAudioDescription": "La suppression de bruit ne peut pas être activée en même temps que la partage audio du système, veuillez le désactiver et réessayer.",
|
||||
"noiseSuppressionFailedTitle": "Échec du démarrage de la suppression de bruit",
|
||||
"noiseSuppressionStereoDescription": "La suppression de bruit d'une source stéréo n'est pas encore supportée.",
|
||||
"oldElectronClientDescription1": "Vous semblez utiliser une ancienne version du client Jitsi Meet qui présente des failles de sécurité connues. Veuillez vous assurer de mettre à jour vers notre ",
|
||||
"oldElectronClientDescription2": "dernière build",
|
||||
"oldElectronClientDescription3": " rapidement !",
|
||||
"openChat": "Ouvrir le chat",
|
||||
"participantWantsToJoin": "souhaite rejoindre la réunion",
|
||||
"participantsWantToJoin": "souhaitent rejoindre la réunion",
|
||||
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) supprimé par un autre participant",
|
||||
"passwordSetRemotely": "$t(lockRoomPasswordUppercase) défini par un autre participant",
|
||||
"raiseHandAction": "Lever la main",
|
||||
"raisedHand": "{{name}} voudrait parler.",
|
||||
"raisedHands": "{{participantName}} et {{raisedHands}} autres personnes",
|
||||
"reactionSounds": "Bloquer les réactions sonores",
|
||||
"reactionSoundsForAll": "Bloquer les réactions sonores pour tous",
|
||||
"screenShareNoAudio": " La case Partager l'audio n'a pas été cochée dans l'écran de sélection de la fenêtre.",
|
||||
@@ -818,13 +885,22 @@
|
||||
"startSilentTitle": "Vous avez rejoint sans sortie audio!",
|
||||
"suboptimalBrowserWarning": "Nous craignons que votre expérience de réunion en ligne ne soit bonne ici. Nous cherchons des moyens d'améliorer cela, mais d'ici-là, essayez d'utiliser l'un des <a href='{{recommendedBrowserPageLink}}' target='_blank'>navigateurs supportés</a>.",
|
||||
"suboptimalExperienceTitle": "Avertissement de navigateur",
|
||||
"suggestRecordingAction": "Démarrer",
|
||||
"suggestRecordingDescription": "Souhaitez-vous démarrer un enregistrement ?",
|
||||
"suggestRecordingTitle": "Enregistrer cette réunion",
|
||||
"unmute": "Rétablir le son",
|
||||
"unmuteScreen": "Démarrer le partage d'écran",
|
||||
"unmuteVideo": "Réactiver la vidéo",
|
||||
"videoMutedRemotelyDescription": "Vous pouvez toujours la réactiver.",
|
||||
"videoMutedRemotelyTitle": "Votre caméra a été coupée par {{participantDisplayName}}!",
|
||||
"videoUnmuteBlockedDescription": "Le rétablissement de la vidéo a été bloqué temporairement en raison de limites système.",
|
||||
"videoUnmuteBlockedTitle": "Rétablissement de la caméra bloqué !",
|
||||
"viewLobby": "Voir la salle d'attente",
|
||||
"viewParticipants": "Voir les participants",
|
||||
"viewVisitors": "Voir les visiteurs",
|
||||
"waitingParticipants": "{{waitingParticipants}} personnes",
|
||||
"waitingVisitors": "Visiteurs en attente dans la file : {{waitingVisitors}}",
|
||||
"waitingVisitorsTitle": "La réunion n'est pas encore en direct !",
|
||||
"whiteboardLimitDescription": "Veuillez sauvegarder votre progression, car la limite d'utilisation du tableau blanc sera bientôt atteinte et celui-ci sera fermé.",
|
||||
"whiteboardLimitTitle": "Utiilisation du tableau blanc"
|
||||
},
|
||||
@@ -833,12 +909,18 @@
|
||||
"admit": "Accepter",
|
||||
"admitAll": "Tout accepter",
|
||||
"allow": "Autoriser les participants à:",
|
||||
"allowDesktop": "Autoriser le partage d'écran",
|
||||
"allowVideo": "permettre la vidéo",
|
||||
"askDesktop": "Demander de partager l'écran",
|
||||
"askUnmute": "Demander de réactiver le micro",
|
||||
"audioModeration": "Rouvrir leur micro",
|
||||
"blockEveryoneMicCamera": "Bloquer tous les micros et caméras",
|
||||
"breakoutRooms": "Salles annexes",
|
||||
"desktopModeration": "Démarrer le partage d'écran",
|
||||
"goLive": "Passer en direct",
|
||||
"invite": "Inviter quelqu'un",
|
||||
"lowerAllHands": "Abaisser toutes les mains",
|
||||
"lowerHand": "Abaisser la main",
|
||||
"moreModerationActions": "Options de modération supplémentaires",
|
||||
"moreModerationControls": "Options de modération supplémentaires",
|
||||
"moreParticipantOptions": "Options supplémentaires pour les participants",
|
||||
@@ -846,6 +928,8 @@
|
||||
"muteAll": "Couper le micro de tout le monde",
|
||||
"muteEveryoneElse": "Couper le micro de tous les autres",
|
||||
"reject": "Refuser",
|
||||
"stopDesktop": "Arrêter le partage d'écran",
|
||||
"stopEveryonesDesktop": "Arrêter le partage d'écran de tout le monde",
|
||||
"stopEveryonesVideo": "Couper toutes les caméras",
|
||||
"stopVideo": "Couper la vidéo",
|
||||
"unblockEveryoneMicCamera": "Débloquer tous les micros et caméras",
|
||||
@@ -855,11 +939,15 @@
|
||||
"headings": {
|
||||
"lobby": "Salle d'attente ({{count}})",
|
||||
"participantsList": "Participants de la réunion ({{count}})",
|
||||
"viewerRequests": "Demandes des spectateurs {{count}}",
|
||||
"visitorInQueue": " (en attente {{count}})",
|
||||
"visitorRequests": "(Demande {{count}} )",
|
||||
"visitors": "Visiteurs {{count}}",
|
||||
"visitorsList": "Spectateurs ({{count}})",
|
||||
"waitingLobby": "Dans la salle d'attente ({{count}})"
|
||||
},
|
||||
"search": "Rechercher des participants",
|
||||
"searchDescription": "Commencez à taper pour filtrer les participants",
|
||||
"title": "Participants"
|
||||
},
|
||||
"passwordDigitsOnly": "Jusqu'à {{number}} chiffres",
|
||||
@@ -868,10 +956,13 @@
|
||||
"pinnedParticipant": "Participant toujours affiché",
|
||||
"polls": {
|
||||
"answer": {
|
||||
"edit": "Modifier",
|
||||
"send": "Envoyer",
|
||||
"skip": "Passer",
|
||||
"submit": "Envoyer"
|
||||
},
|
||||
"by": "Par {{ name }}",
|
||||
"closeButton": "Fermer le sondage",
|
||||
"create": {
|
||||
"addOption": "Ajouter une option",
|
||||
"answerPlaceholder": "Option {{index}}",
|
||||
@@ -881,6 +972,7 @@
|
||||
"pollQuestion": "Question du sondage",
|
||||
"questionPlaceholder": "Poser une question",
|
||||
"removeOption": "Supprimer l'option",
|
||||
"save": "Enregistrer",
|
||||
"send": "Envoyer"
|
||||
},
|
||||
"errors": {
|
||||
@@ -910,9 +1002,11 @@
|
||||
"configuringDevices": "Configuration des appareils…",
|
||||
"connectedWithAudioQ": "Êtes-vous connecté avec le microphone ?",
|
||||
"connection": {
|
||||
"failed": "Le test de connexion a échoué !",
|
||||
"good": "Votre connexion Internet est bonne !",
|
||||
"nonOptimal": "Votre connexion n'est pas optimale",
|
||||
"poor": "Vous avez une mauvaise connexion"
|
||||
"poor": "Vous avez une mauvaise connexion",
|
||||
"running": "Exécution du test de connexion…"
|
||||
},
|
||||
"connectionDetails": {
|
||||
"audioClipping": "Attendez vous à ce que votre son soit coupé.",
|
||||
@@ -921,6 +1015,7 @@
|
||||
"goodQuality": "Impressionnant ! La qualité de vos médias sera excellente",
|
||||
"noMediaConnectivity": "Nous n'avons pas pu trouver un moyen d'établir une connectivité multimédia pour ce test. Cela est généralement causé par un pare-feu ou un NAT.",
|
||||
"noVideo": "Attendez vous à ce que votre qualité vidéo soit très mauvaise.",
|
||||
"testFailed": "Le test de connexion a rencontré des problèmes inattendus, mais cela pourrait ne pas affecter votre expérience.",
|
||||
"undetectable": "Si vous ne parvenez toujours pas à passer des appels dans le navigateur, nous vous recommandons de vous assurer que vos haut-parleurs, microphone et caméra sont correctement configurés, que vous avez accordé à votre navigateur les droits d'utiliser votre microphone et votre caméra et que la version de votre navigateur est à jour. Si vous rencontrez toujours des difficultés pour appeler, vous devez contacter le développeur de l'application Web.",
|
||||
"veryPoorConnection": "Attendez vous à ce que la qualité de votre appel soit très mauvaise",
|
||||
"videoFreezing": "Attendez vous à ce que votre vidéo saute, soit noire, et pixelisée.",
|
||||
@@ -937,7 +1032,7 @@
|
||||
"errorDialOutDisconnected": "Impossible de composer le numéro. Déconnecté",
|
||||
"errorDialOutFailed": "Impossible de composer le numéro. L'appel a échoué",
|
||||
"errorDialOutStatus": "Erreur lors de l'obtention de l'état d'appel sortant",
|
||||
"errorMissingName": "Veuillez entrer votre nom pour entrer en conférence",
|
||||
"errorMissingName": "Veuillez entrer votre nom pour entrer en réunion",
|
||||
"errorNoPermissions": "Vous devez permettre l'accès microphone et caméra",
|
||||
"errorStatusCode": "Erreur de numérotation, code d'état: {{status}}",
|
||||
"errorValidation": "La validation du numéro a échoué",
|
||||
@@ -953,6 +1048,7 @@
|
||||
"or": "ou",
|
||||
"premeeting": "Pré-séance",
|
||||
"proceedAnyway": "Continuer quand même",
|
||||
"recordingWarning": "D'autres participants peuvent enregistrer cet appel",
|
||||
"screenSharingError": "Erreur de partage d'écran:",
|
||||
"startWithPhone": "Commencez avec l'audio du téléphone",
|
||||
"unsafeRoomConsent": "Je comprends les risques et je veux quand même rejoindre cette réunion",
|
||||
@@ -1018,7 +1114,6 @@
|
||||
"limitNotificationDescriptionNative": "En raison d'une forte demande, votre enregistrement sera limité à {{limit}} min. Pour des enregistrements illimités, essayez <3> {{app}} </3>.",
|
||||
"limitNotificationDescriptionWeb": "En raison d'une forte demande, votre enregistrement sera limité à {{limit}} min. Pour des enregistrements illimités, essayez <a href={{url}} rel='noopener noreferrer' target='_blank'> {{app}} </a>.",
|
||||
"linkGenerated": "Nous avons généré un lien à votre enregistrement.",
|
||||
"live": "EN DIRECT",
|
||||
"localRecordingNoNotificationWarning": "Le démarrage de l’enregistrement ne sera pas annoncé aux autres participants. Vous devrez les informer par vous-même que la réunion sera enregistrée.",
|
||||
"localRecordingNoVideo": "La vidéo n'est pas en cours d’enregistrement",
|
||||
"localRecordingStartWarning": "Assurez-vous d’arrêter l’enregistrement vidéo avant de quitter la réunion afin de pouvoir le sauvegarder.",
|
||||
@@ -1035,13 +1130,13 @@
|
||||
"onBy": "{{name}} a démarré l'enregistrement",
|
||||
"onlyRecordSelf": "Enregistrer seulement mon audio et ma vidéo.",
|
||||
"pending": "Enregistrement de la réunion en préparation…",
|
||||
"rec": "REC",
|
||||
"policyError": "Vous avez essayé de démarrer un enregistrement trop rapidement. Veuillez réessayer plus tard !",
|
||||
"recordAudioAndVideo": "Enregistrer l'audio et la vidéo",
|
||||
"recordTranscription": "Enregistrer la transcription",
|
||||
"saveLocalRecording": "Sauvegarder l’enregistrement local (Beta)",
|
||||
"serviceDescription": "Votre enregistrement sera sauvegardé par le service d'enregistrement",
|
||||
"serviceDescriptionCloud": "Enregistrement Cloud",
|
||||
"serviceDescriptionCloudInfo": "Les conférences enregistrées sont automatiquement supprimées 24h après leur heure d'enregistrement.",
|
||||
"serviceDescriptionCloudInfo": "Les réunions enregistrées sont automatiquement supprimées 24h après leur heure d'enregistrement.",
|
||||
"serviceName": "Service d'enregistrement",
|
||||
"sessionAlreadyActive": "Cette session est déjà en cours d'enregistrement ou de diffusion.",
|
||||
"showAdvancedOptions": "Afficher les options avancées",
|
||||
@@ -1057,6 +1152,18 @@
|
||||
"sectionList": {
|
||||
"pullToRefresh": "Tirer pour rafraîchir"
|
||||
},
|
||||
"security": {
|
||||
"about": "Vous pouvez ajouter un mot de passe à votre réunion. Les participants devront fournir le mot de passe avant de pouvoir rejoindre la réunion.",
|
||||
"aboutReadOnly": "Les modérateurs peuvent ajouter un mot de passe à la réunion. Les participants devront fournir le mot de passe avant de pouvoir rejoindre la réunion.",
|
||||
"insecureRoomNameWarningNative": "Le nom de la réunion n’est pas sûr. Des participants non voulus pourraient rejoindre cette réunion. {{recommendAction}} En apprendre plus sur la sécurisation des réunions.",
|
||||
"insecureRoomNameWarningWeb": "Le nom de la réunion n’est pas sûr. Des participants non voulus pourraient rejoindre cette réunion. {{recommendAction}} En apprendre plus sur la sécurisation des réunions <a href=\"{{securityUrl}}\" rel=\"security\" target=\"_blank\">here</a>.",
|
||||
"title": "Options de sécurité",
|
||||
"unsafeRoomActions": {
|
||||
"meeting": "Envisagez de sécuriser votre réunion en utilisant le bouton options de sécurité.",
|
||||
"prejoin": "Envisagez d'utiliser un nom plus unique",
|
||||
"welcome": "Envisagez d'utiliser un nom plus unique ou choisissez en un parmi ceux suggérés"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audio",
|
||||
"buttonLabel": "Paramètres",
|
||||
@@ -1067,11 +1174,13 @@
|
||||
"signedIn": "Accès aux événements de votre agenda en cours pour {{email}}. Cliquez sur le bouton de déconnexion ci-dessous pour terminer l'accès aux événements d'agenda.",
|
||||
"title": "Calendrier"
|
||||
},
|
||||
"chatWithPermissions": "Le chat nécessite une autorisation",
|
||||
"desktopShareFramerate": "Images par seconde pour le Partage d'écran",
|
||||
"desktopShareHighFpsWarning": "Augmenter le nombre d'images par seconde pour le partage d'écran peut impacter votre bande passante. Vous devez repartager l'écran pour que ces paramètres soient utilisés.",
|
||||
"desktopShareWarning": "Vous devez repartager l'écran pour que ces paramètres soient utilisés.",
|
||||
"devices": "Dispositifs",
|
||||
"followMe": "Tous les participants me suivent",
|
||||
"followMeRecorder": "L'enregistreur me suit",
|
||||
"framesPerSecond": "images par seconde",
|
||||
"incomingMessage": "un message arrive",
|
||||
"language": "Langue",
|
||||
@@ -1095,6 +1204,7 @@
|
||||
"selectMic": "Micro",
|
||||
"selfView": "Affichage de votre propre vidéo",
|
||||
"shortcuts": "Raccourcis",
|
||||
"showSubtitlesOnStage": "Afficher les sous-titres sur scène",
|
||||
"speakers": "Haut-parleurs",
|
||||
"startAudioMuted": "Tous les participants débutent en sourdine",
|
||||
"startReactionsMuted": "Tout le monde commence avec les réactions sonores bloquées",
|
||||
@@ -1111,7 +1221,7 @@
|
||||
"alertURLText": "L'URL de serveur saisi n'est pas valide",
|
||||
"apply": "Appliquer",
|
||||
"buildInfoSection": "Information de version",
|
||||
"conferenceSection": "Conférence",
|
||||
"conferenceSection": "Réunion",
|
||||
"disableCallIntegration": "Désactiver l'intégration d'appels native",
|
||||
"disableCrashReporting": "Désactiver les rapports de plantage",
|
||||
"disableCrashReportingWarning": "Etes-vous certain de vouloir désactiver les rapports de plantage ? Le paramètre sera effectif après le redémarrage de l'application.",
|
||||
@@ -1148,11 +1258,13 @@
|
||||
"fearful": "Effrayé",
|
||||
"happy": "Content",
|
||||
"hours": "{{count}} h",
|
||||
"labelTooltip": "Nombre de participants : {{count}}",
|
||||
"minutes": "{{count}} min",
|
||||
"name": "Nom",
|
||||
"neutral": "Neutre",
|
||||
"sad": "Triste",
|
||||
"search": "Recherche",
|
||||
"searchDescription": "Commencez à taper pour filtrer les participants",
|
||||
"searchHint": "Recherche des participants",
|
||||
"seconds": "{{count}} s",
|
||||
"speakerStats": "Statistiques d'intervenant",
|
||||
@@ -1160,7 +1272,7 @@
|
||||
"surprised": "Surpris"
|
||||
},
|
||||
"startupoverlay": {
|
||||
"genericTitle": "La conférence a besoin d'utiliser votre microphone et votre caméra.",
|
||||
"genericTitle": "La réunion a besoin d'utiliser votre microphone et votre caméra.",
|
||||
"policyText": " ",
|
||||
"title": "{{app}} doit utiliser votre micro et votre caméra."
|
||||
},
|
||||
@@ -1189,6 +1301,7 @@
|
||||
"closeChat": "Fermer la discussion instantanée",
|
||||
"closeMoreActions": "Fermer le menu plus d'actions",
|
||||
"closeParticipantsPane": "Fermer le panneau des participants",
|
||||
"closedCaptions": "Sous-titres",
|
||||
"collapse": "Plier",
|
||||
"document": "Basculement du document partagé",
|
||||
"documentClose": "Fermer le document partagé",
|
||||
@@ -1218,6 +1331,7 @@
|
||||
"lobbyButton": "Activer / Désactiver le mode salle d'attente",
|
||||
"localRecording": "Basculement des commandes d'enregistrement local",
|
||||
"lockRoom": "Basculement du mot de passe de la réunion",
|
||||
"love": "Cœur",
|
||||
"lowerHand": "Baisser la main",
|
||||
"moreActions": "Basculement du menu d'actions supplémentaires",
|
||||
"moreActionsMenu": "Menu d'actions supplémentaires",
|
||||
@@ -1235,6 +1349,7 @@
|
||||
"privateMessage": "",
|
||||
"profile": "Modifier votre profil",
|
||||
"raiseHand": "Basculement de la main levée",
|
||||
"react": "Réactions aux messages",
|
||||
"reactions": "Réactions",
|
||||
"reactionsMenu": "Ouvrir / fermer le menu réactions",
|
||||
"recording": "Basculement de l'enregistrement",
|
||||
@@ -1265,6 +1380,20 @@
|
||||
"videounmute": "Démarrer la vidéo"
|
||||
},
|
||||
"addPeople": "Ajouter des personnes à votre appel",
|
||||
"advancedAudioSettings": {
|
||||
"aec": {
|
||||
"label": "Suppression d'écho acoustique"
|
||||
},
|
||||
"agc": {
|
||||
"label": "Contrôle automatique du gain"
|
||||
},
|
||||
"ns": {
|
||||
"label": "Suppression de bruit"
|
||||
},
|
||||
"stereo": {
|
||||
"label": "Stéréo"
|
||||
}
|
||||
},
|
||||
"audioOnlyOff": "Désactiver le mode bande passante faible",
|
||||
"audioOnlyOn": "Activer le mode bande passante faible",
|
||||
"audioRoute": "Sélectionner le dispositif audio",
|
||||
@@ -1277,6 +1406,7 @@
|
||||
"closeChat": "Fermer le clavardage",
|
||||
"closeParticipantsPane": "Fermer le panneau des participants",
|
||||
"closeReactionsMenu": "Fermer le menu réactions",
|
||||
"closedCaptions": "Sous-titres",
|
||||
"disableNoiseSuppression": "Arrêter la suppression du bruit",
|
||||
"disableReactionSounds": "Vous pouvez interdire les réactions sonores à cette réunion",
|
||||
"documentClose": "Fermer le document partagé",
|
||||
@@ -1294,6 +1424,7 @@
|
||||
"giphy": "Activer/désactiver le menu GIPHY",
|
||||
"hangup": "Quitter",
|
||||
"help": "Aide",
|
||||
"hideWhiteboard": "Masquer le tableau blanc",
|
||||
"invite": "Inviter des personnes",
|
||||
"joinBreakoutRoom": "Rejoindre salle annexe",
|
||||
"laugh": "Rire",
|
||||
@@ -1305,6 +1436,7 @@
|
||||
"lobbyButtonEnable": "Activer le mode salle d'attente / contrôle des participant(e)s",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"love": "Cœur",
|
||||
"lowerYourHand": "Abaisser votre main",
|
||||
"moreActions": "Plus d'actions",
|
||||
"moreOptions": "Plus d'options",
|
||||
@@ -1330,14 +1462,17 @@
|
||||
"raiseYourHand": "Lever votre main",
|
||||
"reactionBoo": "Envoyer réaction huer",
|
||||
"reactionClap": "Envoyer réaction applaudir",
|
||||
"reactionHeart": "Envoyer une réaction en forme de cœur",
|
||||
"reactionLaugh": "Envoyer réaction rire",
|
||||
"reactionLike": "Envoyer réaction approuver",
|
||||
"reactionLove": "Envoyer une réaction d'amour",
|
||||
"reactionSilence": "Envoyer réaction silence",
|
||||
"reactionSurprised": "Envoyer réaction surprise",
|
||||
"reactions": "Reactions",
|
||||
"security": "Options de sécurité",
|
||||
"selectBackground": "Sélectionner un arrière-plan",
|
||||
"shareRoom": "Inviter quelqu'un",
|
||||
"shareaudio": "Partager l'audio",
|
||||
"sharedvideo": "Partager une vidéo",
|
||||
"shortcuts": "Voir les raccourcis",
|
||||
"showWhiteboard": "Afficher le tableau blanc",
|
||||
@@ -1345,12 +1480,10 @@
|
||||
"speakerStats": "Statistiques d'intervenant",
|
||||
"startScreenSharing": "Démarrer le partage d'écran",
|
||||
"startSubtitles": "Activer les sous-titres",
|
||||
"startvideoblur": "Brouiller mon arrière plan",
|
||||
"stopAudioSharing": "Arrêter le partage son",
|
||||
"stopScreenSharing": "Arrêter le partage d'écran",
|
||||
"stopSharedVideo": "Arrêter la vidéo",
|
||||
"stopSubtitles": "Désactiver les sous-titres",
|
||||
"stopvideoblur": "Désactiver le brouillage d'arrière-plan",
|
||||
"surprised": "Surpris",
|
||||
"talkWhileMutedPopup": "Vous essayez de parler? Vous êtes en sourdine.",
|
||||
"tileViewToggle": "Basculement de l'affichage mosaïque",
|
||||
@@ -1363,20 +1496,20 @@
|
||||
},
|
||||
"transcribing": {
|
||||
"ccButtonTooltip": "Activer / Désactiver les sous-titres",
|
||||
"error": "Échec de la transcription. Veuillez réessayer.",
|
||||
"expandedLabel": "La transcription est actuellement activée",
|
||||
"failedToStart": "Échec du démarrage de la transcription",
|
||||
"labelToolTip": "La réunion est transcrite",
|
||||
"off": "La transcription est arrêtée",
|
||||
"on": "La transcription est activée",
|
||||
"pending": "Préparation de la transcription de la réunion en cours…",
|
||||
"failed": "La transcription a échoué",
|
||||
"labelTooltip": "La transcription de la réunion est en cours",
|
||||
"labelTooltipExtra": "De plus, une transcription sera disponible plus tard.",
|
||||
"openClosedCaptions": "Ouvrir les sous-titres",
|
||||
"original": "Original",
|
||||
"sourceLanguageDesc": "Actuellement, la langue de la réunion est sélectionnée à <b>{{sourceLanguage}}</b>. <br/> Vous pouvez la changer à partir de ",
|
||||
"sourceLanguageHere": "ici",
|
||||
"start": "Activer l'affichage des sous-titres",
|
||||
"stop": "Désactiver l'affichage des sous-titres",
|
||||
"subtitles": "sous-titres",
|
||||
"subtitlesOff": "off",
|
||||
"tr": "PI"
|
||||
"tr": "PI",
|
||||
"translateTo": "Traduire vers"
|
||||
},
|
||||
"unpinParticipant": "Désépingler - {{participantName}}",
|
||||
"userMedia": {
|
||||
@@ -1386,7 +1519,7 @@
|
||||
"busy": "Libération des ressources en cours. Veuillez réessayer dans quelques minutes.",
|
||||
"busyTitle": "Le service de Salle est actuellement occupé.",
|
||||
"errorAlreadyInvited": "{{displayName}} a déjà été invité",
|
||||
"errorInvite": "La conférence n'est pas encore configurée. Veuillez réessayer plus tard.",
|
||||
"errorInvite": "La réunion n'est pas encore configurée. Veuillez réessayer plus tard.",
|
||||
"errorInviteFailed": "Nous nous efforçons de régler ce problème. Veuillez réessayer plus tard.",
|
||||
"errorInviteFailedTitle": "L'invitation de {{displayName}} a échoué",
|
||||
"errorInviteTitle": "Erreur lors de l'invitation de la salle",
|
||||
@@ -1407,8 +1540,6 @@
|
||||
"ld": "LD",
|
||||
"ldTooltip": "Visionnement de vidéo en basse définition",
|
||||
"lowDefinition": "Basse définition",
|
||||
"onlyAudioAvailable": "Seulement l'audio est disponible",
|
||||
"onlyAudioSupported": "Ce navigateur prend seulement l'audio en charge.",
|
||||
"performanceSettings": "Paramètres de performance",
|
||||
"recording": "Enregistrement en cours",
|
||||
"sd": "SD",
|
||||
@@ -1418,7 +1549,10 @@
|
||||
},
|
||||
"videothumbnail": {
|
||||
"connectionInfo": "Informations de la connexion",
|
||||
"demote": "Déplacer en visiteur",
|
||||
"domute": "Discrétion",
|
||||
"domuteDesktop": "Arrêter le partage d'écran",
|
||||
"domuteDesktopOfOthers": "Arrêter le partage d'écran de tous les autres",
|
||||
"domuteOthers": "Couper le micro de tous les autres",
|
||||
"domuteVideo": "Couper la caméra",
|
||||
"domuteVideoOfOthers": "Couper la caméra des autres",
|
||||
@@ -1470,11 +1604,24 @@
|
||||
},
|
||||
"visitors": {
|
||||
"chatIndicator": "(visiteur)",
|
||||
"joinMeeting": {
|
||||
"description": "Vous êtes actuellement un observateur dans cette réunion.",
|
||||
"raiseHand": "Levez la main",
|
||||
"title": "Rejoindre la réunion",
|
||||
"wishToSpeak": "Si vous souhaitez prendre la parole, veuillez lever la main ci-dessous et attendre l'approbation du modérateur."
|
||||
},
|
||||
"labelTooltip": "Nombre de Visiteurs",
|
||||
"notification": {
|
||||
"description": "Pour participer lever la main.",
|
||||
"demoteDescription": "Envoyé ici par {{actor}}, levez la main pour participer",
|
||||
"noMainParticipantsDescription": "Un participant doit démarrer la réunion. Veuillez réessayer dans un moment.",
|
||||
"noMainParticipantsTitle": "Cette réunion n'a pas encore commencé.",
|
||||
"noVisitorLobby": "Vous ne pouvez pas rejoindre tant qu'une salle d'attente est activée pour la réunion.",
|
||||
"notAllowedPromotion": "Un participant doit d'abord autoriser votre demande.",
|
||||
"requestToJoin": "Main levée",
|
||||
"requestToJoinDescription": "Votre demande a été envoyée aux modérateurs. Patientez !",
|
||||
"title": "Vous êtes visiteur dans cette réunion"
|
||||
}
|
||||
},
|
||||
"waitingMessage": "Vous rejoindrez la réunion dès qu'elle sera en direct !"
|
||||
},
|
||||
"volumeSlider": "Curseur de volume",
|
||||
"welcomepage": {
|
||||
@@ -1483,7 +1630,7 @@
|
||||
"roomname": "Entrer le nom de la salle"
|
||||
},
|
||||
"addMeetingName": "Ajouter un nom de réunion",
|
||||
"appDescription": "Profitez de la conversation vidéo avec toute votre équipe. Allez-y, invitez tous ceux que vous connaissez. {{app}} est une solution 100 % libre de conférence vidéo entièrement chiffrée que vous pouvez utiliser en tout temps et gratuitement, sans avoir besoin de compte.",
|
||||
"appDescription": "Profitez de la conversation vidéo avec toute votre équipe. Allez-y, invitez tous ceux que vous connaissez. {{app}} est une solution 100 % libre de réunion vidéo entièrement chiffrée que vous pouvez utiliser en tout temps et gratuitement, sans avoir besoin de compte.",
|
||||
"audioVideoSwitch": {
|
||||
"audio": "Voix",
|
||||
"video": "Vidéo"
|
||||
@@ -1492,12 +1639,13 @@
|
||||
"connectCalendarButton": "Connecter votre agenda",
|
||||
"connectCalendarText": "Connectez-vous à votre calendrier pour afficher toutes les réunions {{app}}. Ajoutez également les réunions de {{provider}} à votre calendrier et démarrez-les d'un simple clic.",
|
||||
"enterRoomTitle": "Démarrer une nouvelle réunion",
|
||||
"getHelp": "Obtenir de l'aide",
|
||||
"go": "Aller",
|
||||
"goSmall": "Aller",
|
||||
"headerSubtitle": "Conférences sécurisées et de haute qualité",
|
||||
"headerSubtitle": "Réunions sécurisées et de haute qualité",
|
||||
"headerTitle": "Jitsi Meet",
|
||||
"info": "Ret. arr.",
|
||||
"jitsiOnMobile": "Jitsi sur mobile – télécharger notre application et démarrez des conférences de n'import où",
|
||||
"jitsiOnMobile": "Jitsi sur mobile – télécharger notre application et démarrez des réunions de n'import où",
|
||||
"join": "CRÉER / REJOINDRE",
|
||||
"logo": {
|
||||
"calendar": "Logo Calendar",
|
||||
@@ -1523,14 +1671,15 @@
|
||||
"roomnameHint": "Entrez le nom ou l'URL de la salle que vous voulez rejoindre. Vous pouvez inventer un nom, mais assurez-vous de le partager avec les participants de la réunion pour qu'ils utilisent le même nom.",
|
||||
"sendFeedback": "Envoyer un commentaire",
|
||||
"settings": "Paramètres",
|
||||
"startMeeting": "Démarrer la conférence",
|
||||
"startMeeting": "Démarrer la réunion",
|
||||
"terms": "Termes",
|
||||
"title": "Conférence vidéo sécurisée, riche en fonctionnalités et entièrement gratuite",
|
||||
"title": "Réunion vidéo sécurisée, riche en fonctionnalités et entièrement gratuite",
|
||||
"upcomingMeetings": "Vos réunions à venir"
|
||||
},
|
||||
"whiteboard": {
|
||||
"accessibilityLabel": {
|
||||
"heading": "Tableau blanc"
|
||||
}
|
||||
},
|
||||
"screenTitle": "Tableau blanc"
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,12 @@
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"disabled": "L'envoi de messages de chat est désactivé.",
|
||||
"enter": "Entrez dans le salon",
|
||||
"error": "Erreur : votre message n'a pas été envoyé. Raison : {{error}}",
|
||||
"everyone": "Tout le monde",
|
||||
"fieldPlaceHolder": "Tapez votre message ici",
|
||||
"guestsChatIndicator": "(invité)",
|
||||
"lobbyChatMessageTo": "Message de salle d'attente à {{recipient}}",
|
||||
"message": "Message",
|
||||
"messageAccessibleTitle": "{{user}} dit: ",
|
||||
@@ -122,7 +125,10 @@
|
||||
"nickname": {
|
||||
"popover": "Choisissez un pseudonyme",
|
||||
"title": "Entrez un pseudonyme pour utiliser le chat",
|
||||
"titleWithPolls": "Entrez un pseudonyme pour utiliser le chat et les sondages"
|
||||
"titleWithCC": "Entrez un pseudonyme pour utiliser le chat et les sous-titres",
|
||||
"titleWithPolls": "Entrez un pseudonyme pour utiliser le chat et les sondages",
|
||||
"titleWithPollsAndCC": "Entrez un pseudonyme pour utiliser le chat, les sondages et les sous-titres",
|
||||
"titleWithPollsAndCCAndFileSharing": "Entrez un pseudonyme pour utiliser le chat, les sondages, les sous-titres et les fichiers"
|
||||
},
|
||||
"noMessagesMessage": "Il n'y a pas encore de messages dans cette réunion. Démarrez une conversation ici !",
|
||||
"privateNotice": "Message privé à {{recipient}}",
|
||||
@@ -131,9 +137,14 @@
|
||||
"systemDisplayName": "Système",
|
||||
"tabs": {
|
||||
"chat": "Chat",
|
||||
"closedCaptions": "ST",
|
||||
"fileSharing": "Fichiers",
|
||||
"polls": "Sondages"
|
||||
},
|
||||
"title": "Chat",
|
||||
"titleWithCC": "ST",
|
||||
"titleWithFeatures": "Chat et",
|
||||
"titleWithFileSharing": "Fichiers",
|
||||
"titleWithPolls": "Chat et Sondages",
|
||||
"you": "vous"
|
||||
},
|
||||
@@ -144,6 +155,10 @@
|
||||
"dontShowAgain": "Ne plus m'afficher ceci",
|
||||
"installExtensionText": "Installer l'extension pour l'intégration de Google Calendar et Office 365"
|
||||
},
|
||||
"closedCaptionsTab": {
|
||||
"emptyState": "Le contenu des sous-titres sera disponible une fois qu'un modérateur l'aura démarré",
|
||||
"startClosedCaptionsButton": "Démarrer les sous-titres"
|
||||
},
|
||||
"connectingOverlay": {
|
||||
"joiningRoom": "Connexion à la réunion…"
|
||||
},
|
||||
@@ -263,7 +278,8 @@
|
||||
"Remove": "Supprimer",
|
||||
"Share": "Partager",
|
||||
"Submit": "Soumettre",
|
||||
"WaitForHostMsg": "La conférence n'a pas encore commencé. Si vous en êtes l'hôte, veuillez vous authentifier. Sinon, veuillez attendre son arrivée.",
|
||||
"Understand": "Je comprends, gardez-moi en sourdine pour l'instant",
|
||||
"UnderstandAndUnmute": "Je comprends, veuillez me réactiver s'il vous plaît",
|
||||
"WaitForHostNoAuthMsg": "La conférence n'a pas encore commencé car aucun modérateur n'est encore arrivé. Veuillez patienter.",
|
||||
"WaitingForHostButton": "Attendre l'hôte",
|
||||
"WaitingForHostTitle": "En attente de l'hôte…",
|
||||
@@ -285,6 +301,12 @@
|
||||
"alreadySharedVideoTitle": "Une seule vidéo partagée est autorisée à la fois",
|
||||
"applicationWindow": "Fenêtre d'application",
|
||||
"authenticationRequired": "Authentification requise",
|
||||
"cameraCaptureDialog": {
|
||||
"description": "Prendre et envoyer une photo en utilisant votre caméra mobile",
|
||||
"ok": "Ouvrir la caméra",
|
||||
"reject": "Pas maintenant",
|
||||
"title": "Prendre une photo"
|
||||
},
|
||||
"cameraConstraintFailedError": "Votre caméra ne satisfait pas certaines des contraintes nécessaires.",
|
||||
"cameraNotFoundError": "La caméra n'a pas été trouvée.",
|
||||
"cameraNotSendingData": "Impossible d'accéder à votre caméra. Veuillez sélectionner un autre périphérique dans les paramètres ou rafraîchir la page.",
|
||||
@@ -299,6 +321,7 @@
|
||||
"conferenceReloadMsg": "On essaie d'arranger ça. Reconnexion dans {{seconds}} secondes…",
|
||||
"conferenceReloadTitle": "Malheureusement, un problème est survenu",
|
||||
"confirm": "Confirmer",
|
||||
"confirmBack": "Retour",
|
||||
"confirmNo": "Non",
|
||||
"confirmYes": "Oui",
|
||||
"connectError": "Oups ! Un problème est survenu et la connexion à la conférence est impossible.",
|
||||
@@ -336,6 +359,7 @@
|
||||
"kickParticipantTitle": "Expulser ce participant ?",
|
||||
"kickSystemTitle": "Oups ! Vous avez été expulsé de la réunion",
|
||||
"kickTitle": "Oups ! vous avez été expulsé(e) par {{participantDisplayName}}",
|
||||
"learnMore": "en savoir plus",
|
||||
"linkMeeting": "Relier la conférence",
|
||||
"linkMeetingTitle": "Relier la conférence à Salesforce",
|
||||
"liveStreaming": "Direct",
|
||||
@@ -358,22 +382,34 @@
|
||||
"micTimeoutError": "Impossible de démarrer la source audio. Délai dépassé!",
|
||||
"micUnknownError": "Vous ne pouvez pas utiliser le microphone pour une raison inconnue.",
|
||||
"moderationAudioLabel": "Autoriser les participants à réactiver leur micro",
|
||||
"moderationDesktopLabel": "Autoriser les non-modérateurs à partager leur écran",
|
||||
"moderationVideoLabel": "Autoriser les participants à démarrer leur vidéo",
|
||||
"muteEveryoneDialog": "Êtes-vous sûr de vouloir couper les micros de tout le monde ? Vous ne pourrez plus réactiver leur micro, mais ils pourront l'activer par eux-mêmes à tout moment.",
|
||||
"muteEveryoneDialogModerationOn": "Les participants peuvent demander à parler n'importe quand",
|
||||
"muteEveryoneElseDialog": "Une fois leur micro coupé, vous ne pourrez plus le réactiver, mais ils pourront l'activer par eux-mêmes à tout moment.",
|
||||
"muteEveryoneElseTitle": "Couper le micro de tout le monde sauf de {{whom}} ?",
|
||||
"muteEveryoneElsesDesktopDialog": "Une fois le partage arrêté, vous ne pourrez pas le redémarrer, mais ils peuvent le faire à tout moment.",
|
||||
"muteEveryoneElsesDesktopTitle": "Arrêter le partage d'écran de tout le monde sauf {{whom}} ?",
|
||||
"muteEveryoneElsesVideoDialog": "Une fois la caméra coupée, vous ne pourrez plus la rallumer, mais ils peuvent la rallumer à tout moment.",
|
||||
"muteEveryoneElsesVideoTitle": "Couper la vidéo de tout le monde sauf {{whom}}?",
|
||||
"muteEveryoneSelf": "vous",
|
||||
"muteEveryoneStartMuted": "Tout le monde démarre avec le micro coupé",
|
||||
"muteEveryoneTitle": "Couper le micro de tout le monde ?",
|
||||
"muteEveryonesDesktopDialog": "Les participants peuvent partager leur écran à tout moment.",
|
||||
"muteEveryonesDesktopDialogModerationOn": "Les participants peuvent envoyer une demande pour partager leur écran à tout moment.",
|
||||
"muteEveryonesDesktopTitle": "Arrêter le partage d'écran de tout le monde ?",
|
||||
"muteEveryonesVideoDialog": "Êtes-vous sûr de vouloir couper la caméra de tout le monde? Vous ne pourrez pas la réactiver, mais ils peuvent la remettre à tout moment.",
|
||||
"muteEveryonesVideoDialogModerationOn": "Les participants peuvent demander à activer leur caméra n'importe quand.",
|
||||
"muteEveryonesVideoDialogOk": "Désactiver",
|
||||
"muteEveryonesVideoTitle": "Couper la caméra de tout le monde?",
|
||||
"muteParticipantBody": "Vous ne pourrez plus réactiver son micro, mais il pourra l'activer par lui-même à tout moment.",
|
||||
"muteParticipantButton": "Couper le micro",
|
||||
"muteParticipantsDesktopBody": "Vous ne pourrez pas démarrer leur partage d'écran, mais ils peuvent le faire à tout moment.",
|
||||
"muteParticipantsDesktopBodyModerationOn": "Vous ne pourrez pas démarrer leur partage d'écran et eux non plus.",
|
||||
"muteParticipantsDesktopButton": "Arrêter le partage d'écran",
|
||||
"muteParticipantsDesktopDialog": "Êtes-vous sûr de vouloir désactiver le partage d'écran de ce participant ? Vous ne pourrez pas le redémarrer, mais ils peuvent le faire à tout moment.",
|
||||
"muteParticipantsDesktopDialogModerationOn": "Êtes-vous sûr de vouloir désactiver le partage d'écran de ce participant ? Vous ne pourrez pas réactiver l'écran et eux non plus.",
|
||||
"muteParticipantsDesktopTitle": "Désactiver le partage d'écran de ce participant ?",
|
||||
"muteParticipantsVideoBody": "Vous ne pourrez pas rallumer la caméra, mais ils peuvent la rallumer à tout moment.",
|
||||
"muteParticipantsVideoBodyModerationOn": "Ni vous ni le participant ne pourront rallumer la caméra.",
|
||||
"muteParticipantsVideoButton": "Couper la caméra",
|
||||
@@ -393,6 +429,10 @@
|
||||
"recentlyUsedObjects": "Vos objets récemment utilisés",
|
||||
"recording": "Enregistrement",
|
||||
"recordingDisabledBecauseOfActiveLiveStreamingTooltip": "Impossible durant le direct",
|
||||
"recordingInProgressDescription": "Cette réunion est en cours d'enregistrement et d'analyse par IA{{learnMore}}. Votre audio et vidéo ont été coupés. Si vous choisissez de vous réactiver, vous consentez à être enregistré.",
|
||||
"recordingInProgressDescriptionFirstHalf": "Cette réunion est en cours d'enregistrement et d'analyse par IA",
|
||||
"recordingInProgressDescriptionSecondHalf": ". Votre audio et vidéo ont été coupés. Si vous choisissez de vous réactiver, vous consentez à être enregistré.",
|
||||
"recordingInProgressTitle": "Enregistrement en cours",
|
||||
"rejoinNow": "Rejoindre maintenant",
|
||||
"remoteControlAllowedMessage": "{{user}} a accepté votre demande de prise en main à distance !",
|
||||
"remoteControlDeniedMessage": "{{user}} a refusé votre demande de prise en main à distance !",
|
||||
@@ -522,6 +562,23 @@
|
||||
"veryBad": "Très mauvais",
|
||||
"veryGood": "Très bon"
|
||||
},
|
||||
"fileSharing": {
|
||||
"downloadFailedDescription": "Veuillez réessayer.",
|
||||
"downloadFailedTitle": "Échec du téléchargement",
|
||||
"downloadFile": "Télécharger",
|
||||
"downloadStarted": "Téléchargement de fichier démarré",
|
||||
"dragAndDrop": "Glissez et déposez des fichiers ici ou n'importe où sur l'écran",
|
||||
"fileAlreadyUploaded": "Le fichier a déjà été téléchargé vers cette réunion.",
|
||||
"fileTooLargeDescription": "Veuillez vous assurer que le fichier ne dépasse pas {{ maxFileSize }}.",
|
||||
"fileTooLargeTitle": "Le fichier sélectionné est trop volumineux",
|
||||
"fileUploadProgress": "Progression du téléchargement de fichier",
|
||||
"fileUploadedSuccessfully": "Fichier téléchargé avec succès",
|
||||
"removeFile": "Supprimer",
|
||||
"removeFileSuccess": "Fichier supprimé avec succès",
|
||||
"uploadFailedDescription": "Veuillez réessayer.",
|
||||
"uploadFailedTitle": "Échec du téléchargement",
|
||||
"uploadFile": "Partager un fichier"
|
||||
},
|
||||
"filmstrip": {
|
||||
"accessibilityLabel": {
|
||||
"heading": "Vignettes vidéos"
|
||||
@@ -690,7 +747,8 @@
|
||||
"notificationTitle": "Salle d'attente",
|
||||
"passwordJoinButton": "Rejoindre",
|
||||
"title": "Salle d'attente",
|
||||
"toggleLabel": "Activer la salle d'attente"
|
||||
"toggleLabel": "Activer la salle d'attente",
|
||||
"waitForModerator": "La conférence n'a pas encore commencé car aucun modérateur n'est encore arrivé. Si vous souhaitez devenir modérateur, veuillez vous connecter. Sinon, veuillez attendre."
|
||||
},
|
||||
"localRecording": {
|
||||
"clientState": {
|
||||
@@ -733,7 +791,10 @@
|
||||
"me": "moi",
|
||||
"notify": {
|
||||
"OldElectronAPPTitle": "Faille de sécurité !",
|
||||
"allowAction": "Permettre",
|
||||
"allowAll": "Tout autoriser",
|
||||
"allowAudio": "Autoriser l'audio",
|
||||
"allowDesktop": "Autoriser le partage d'écran",
|
||||
"allowVideo": "Autoriser la vidéo",
|
||||
"allowedUnmute": "Vous pouvez réactiver votre écran, votre caméra ou partager votre écran.",
|
||||
"audioUnmuteBlockedDescription": "Le rétablissement du son a été bloqué temporairement en raison de limites système.",
|
||||
"audioUnmuteBlockedTitle": "Rétablissement du son bloqué !",
|
||||
@@ -746,8 +807,10 @@
|
||||
"dataChannelClosedDescription": "Le canal de communication avec le Bridge a été interrompu, la qualité vidéo se trouve limitée à sa valeur la plus faible.",
|
||||
"dataChannelClosedDescriptionWithAudio": "Le canal de pont est fermé, ce qui peut entraîner des perturbations de l'audio et de la vidéo.",
|
||||
"dataChannelClosedWithAudio": "La qualité de l'audio et de la vidéo peut être altérée",
|
||||
"desktopMutedRemotelyTitle": "Votre partage d'écran a été arrêté par {{participantDisplayName}}",
|
||||
"disabledIframe": "L'intégration Iframe est uniquement destinée à des démos, cet appel se terminera dans {{timeout}} minutes.",
|
||||
"disabledIframeSecondary": "L'intégration Iframe de {{domaine}} est uniquement destinée à des démos, cet appel se terminera dans {{timeout}} minutes.",
|
||||
"disabledIframeSecondaryNative": "L'intégration de {{domain}} est uniquement destinée aux fins de démonstration, cet appel se terminera dans {{timeout}} minutes.",
|
||||
"disabledIframeSecondaryWeb": "L'intégration de {{domain}} est uniquement destinée aux fins de démonstration, cet appel se terminera dans {{timeout}} minutes. Veuillez utiliser <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi as a Service</a> pour l'intégration en production !",
|
||||
"disconnected": "déconnecté",
|
||||
"displayNotifications": "Afficher les notifications pour",
|
||||
"dontRemindMe": "Ne pas me le rappeler",
|
||||
@@ -802,6 +865,7 @@
|
||||
"oldElectronClientDescription1": "Vous semblez utiliser une ancienne version du client Jitsi Meet qui présente des failles de sécurité connues. Veuillez vous assurer de mettre à jour vers notre ",
|
||||
"oldElectronClientDescription2": "dernière build",
|
||||
"oldElectronClientDescription3": " rapidement !",
|
||||
"openChat": "Ouvrir le chat",
|
||||
"participantWantsToJoin": "souhaite rejoindre la réunion",
|
||||
"participantsWantToJoin": "souhaitent rejoindre la réunion",
|
||||
"passwordRemovedRemotely": "Le $t(lockRoomPassword) a été supprimé par un autre participant",
|
||||
@@ -825,6 +889,8 @@
|
||||
"suggestRecordingDescription": "Souhaitez-vous démarrer un enregistrement ?",
|
||||
"suggestRecordingTitle": "Enregistrer cette réunion",
|
||||
"unmute": "Rétablir le son",
|
||||
"unmuteScreen": "Démarrer le partage d'écran",
|
||||
"unmuteVideo": "Réactiver la vidéo",
|
||||
"videoMutedRemotelyDescription": "Vous pouvez toujours la réactiver.",
|
||||
"videoMutedRemotelyTitle": "Votre caméra a été coupée par {{participantDisplayName}}!",
|
||||
"videoUnmuteBlockedDescription": "Le rétablissement de la vidéo a été bloqué temporairement en raison de limites système.",
|
||||
@@ -843,11 +909,14 @@
|
||||
"admit": "Accepter",
|
||||
"admitAll": "Tout accepter",
|
||||
"allow": "Autoriser les participants à:",
|
||||
"allowDesktop": "Autoriser le partage d'écran",
|
||||
"allowVideo": "permettre la vidéo",
|
||||
"askDesktop": "Demander de partager l'écran",
|
||||
"askUnmute": "Demander de réactiver le micro",
|
||||
"audioModeration": "Rouvrir leur micro",
|
||||
"blockEveryoneMicCamera": "Bloquer tous les micros et caméras",
|
||||
"breakoutRooms": "Salles annexes",
|
||||
"desktopModeration": "Démarrer le partage d'écran",
|
||||
"goLive": "Passer en direct",
|
||||
"invite": "Inviter quelqu'un",
|
||||
"lowerAllHands": "Abaisser toutes les mains",
|
||||
@@ -859,6 +928,8 @@
|
||||
"muteAll": "Couper le micro de tout le monde",
|
||||
"muteEveryoneElse": "Couper le micro de tous les autres",
|
||||
"reject": "Refuser",
|
||||
"stopDesktop": "Arrêter le partage d'écran",
|
||||
"stopEveryonesDesktop": "Arrêter le partage d'écran de tout le monde",
|
||||
"stopEveryonesVideo": "Couper toutes les caméras",
|
||||
"stopVideo": "Couper la vidéo",
|
||||
"unblockEveryoneMicCamera": "Débloquer tous les micros et caméras",
|
||||
@@ -868,12 +939,15 @@
|
||||
"headings": {
|
||||
"lobby": "Salle d'attente ({{count}})",
|
||||
"participantsList": "Participants de la réunion ({{count}})",
|
||||
"viewerRequests": "Demandes des spectateurs {{count}}",
|
||||
"visitorInQueue": " (en attente {{count}})",
|
||||
"visitorRequests": "(Demande {{count}} )",
|
||||
"visitors": "Visiteurs {{count}}",
|
||||
"visitorsList": "Spectateurs ({{count}})",
|
||||
"waitingLobby": "Dans la salle d'attente ({{count}})"
|
||||
},
|
||||
"search": "Rechercher des participants",
|
||||
"searchDescription": "Commencez à taper pour filtrer les participants",
|
||||
"title": "Participants"
|
||||
},
|
||||
"passwordDigitsOnly": "Jusqu'à {{number}} chiffres",
|
||||
@@ -1100,6 +1174,7 @@
|
||||
"signedIn": "Accès aux événements du calendrier {{email}}. Cliquez sur le bouton se déconnecter ci-dessous pour arrêter l'accès aux événements du calendrier.",
|
||||
"title": "Calendrier"
|
||||
},
|
||||
"chatWithPermissions": "Le chat nécessite une autorisation",
|
||||
"desktopShareFramerate": "Images par seconde pour le Partage d'écran",
|
||||
"desktopShareHighFpsWarning": "Augmenter le nombre d'images par seconde pour le partage d'écran peut impacter votre bande passante. Vous devez repartager l'écran pour que ces paramètres soient utilisés.",
|
||||
"desktopShareWarning": "Vous devez repartager l'écran pour que ces paramètres soient utilisés.",
|
||||
@@ -1129,6 +1204,7 @@
|
||||
"selectMic": "Microphone",
|
||||
"selfView": "Affichage de votre propre vidéo",
|
||||
"shortcuts": "Raccourcis",
|
||||
"showSubtitlesOnStage": "Afficher les sous-titres sur l'écran",
|
||||
"speakers": "Haut-parleurs",
|
||||
"startAudioMuted": "Tout le monde commence en muet",
|
||||
"startReactionsMuted": "Tout le monde commence avec les réactions sonores bloquées",
|
||||
@@ -1182,11 +1258,13 @@
|
||||
"fearful": "Effrayé",
|
||||
"happy": "Content",
|
||||
"hours": "{{count}}h",
|
||||
"labelTooltip": "Nombre de participants : {{count}}",
|
||||
"minutes": "{{count}}m",
|
||||
"name": "Nom",
|
||||
"neutral": "Indifférent",
|
||||
"sad": "Triste",
|
||||
"search": "Recherche",
|
||||
"searchDescription": "Commencez à taper pour filtrer les participants",
|
||||
"searchHint": "Recherche des participants",
|
||||
"seconds": "{{count}}s",
|
||||
"speakerStats": "Statistiques de l'interlocuteur",
|
||||
@@ -1223,6 +1301,7 @@
|
||||
"closeChat": "Fermer la discussion instantanée",
|
||||
"closeMoreActions": "Fermer le menu plus d'actions",
|
||||
"closeParticipantsPane": "Fermer le panneau des participants",
|
||||
"closedCaptions": "Sous-titres",
|
||||
"collapse": "Plier",
|
||||
"document": "Activer / Désactiver le document partagé",
|
||||
"documentClose": "Fermer le document partagé",
|
||||
@@ -1301,6 +1380,20 @@
|
||||
"videounmute": "Activer votre vidéo"
|
||||
},
|
||||
"addPeople": "Ajouter des personnes à votre appel",
|
||||
"advancedAudioSettings": {
|
||||
"aec": {
|
||||
"label": "Suppression d'écho acoustique"
|
||||
},
|
||||
"agc": {
|
||||
"label": "Contrôle automatique du gain"
|
||||
},
|
||||
"ns": {
|
||||
"label": "Suppression de bruit"
|
||||
},
|
||||
"stereo": {
|
||||
"label": "Stéréo"
|
||||
}
|
||||
},
|
||||
"audioOnlyOff": "Désactiver le mode bande passante réduite",
|
||||
"audioOnlyOn": "Activer le mode bande passante réduite",
|
||||
"audioRoute": "Sélectionner la source audio",
|
||||
@@ -1313,6 +1406,7 @@
|
||||
"closeChat": "Fermer le chat",
|
||||
"closeParticipantsPane": "Fermer le panneau des participants",
|
||||
"closeReactionsMenu": "Fermer le menu réactions",
|
||||
"closedCaptions": "Sous-titres",
|
||||
"disableNoiseSuppression": "Arrêter la suppression du bruit",
|
||||
"disableReactionSounds": "Vous pouvez interdire les réactions sonores à cette réunion",
|
||||
"documentClose": "Fermer le document partagé",
|
||||
@@ -1371,6 +1465,7 @@
|
||||
"reactionHeart": "Envoyer une réaction en forme de cœur",
|
||||
"reactionLaugh": "Envoyer réaction rire",
|
||||
"reactionLike": "Envoyer réaction approuver",
|
||||
"reactionLove": "Envoyer une réaction d'amour",
|
||||
"reactionSilence": "Envoyer réaction silence",
|
||||
"reactionSurprised": "Envoyer réaction surprise",
|
||||
"reactions": "Reactions",
|
||||
@@ -1403,14 +1498,18 @@
|
||||
"ccButtonTooltip": "Activer / Désactiver les sous-titres",
|
||||
"expandedLabel": "La transcription est actuellement activée",
|
||||
"failed": "La transcription a échoué",
|
||||
"labelToolTip": "La transcription de la réunion est en cours",
|
||||
"labelTooltip": "La transcription de la réunion est en cours",
|
||||
"labelTooltipExtra": "Une transcription sera disponible plus tard.",
|
||||
"openClosedCaptions": "Ouvrir les sous-titres",
|
||||
"original": "Original",
|
||||
"sourceLanguageDesc": "Actuellement, la langue de la réunion est sélectionnée à <b>{{sourceLanguage}}</b>. <br/> Vous pouvez la changer à partir de ",
|
||||
"sourceLanguageHere": "ici",
|
||||
"start": "Activer les sous-titres",
|
||||
"stop": "Désactiver les sous-titres",
|
||||
"subtitles": "sous-titres",
|
||||
"subtitlesOff": "off",
|
||||
"tr": "TR"
|
||||
"tr": "TR",
|
||||
"translateTo": "Traduire vers"
|
||||
},
|
||||
"unpinParticipant": "Désépingler - {{participantName}}",
|
||||
"userMedia": {
|
||||
@@ -1452,6 +1551,8 @@
|
||||
"connectionInfo": "Informations de la connexion",
|
||||
"demote": "Déplacer en visiteur",
|
||||
"domute": "Couper le micro",
|
||||
"domuteDesktop": "Arrêter le partage d'écran",
|
||||
"domuteDesktopOfOthers": "Arrêter le partage d'écran de tous les autres",
|
||||
"domuteOthers": "Couper le micro de tous les autres",
|
||||
"domuteVideo": "Couper la caméra",
|
||||
"domuteVideoOfOthers": "Couper la caméra des autres",
|
||||
@@ -1516,6 +1617,8 @@
|
||||
"noMainParticipantsTitle": "Cette réunion n'a pas encore commencé.",
|
||||
"noVisitorLobby": "Vous ne pouvez pas rejoindre tant qu'une salle d'attente est activée pour la réunion.",
|
||||
"notAllowedPromotion": "Un participant doit d'abord autoriser votre demande.",
|
||||
"requestToJoin": "Main levée",
|
||||
"requestToJoinDescription": "Votre demande a été envoyée aux modérateurs.",
|
||||
"title": "Vous êtes visiteur dans cette réunion"
|
||||
},
|
||||
"waitingMessage": "Vous rejoindrez la réunion dès qu'elle sera en direct !"
|
||||
|
||||
@@ -280,7 +280,6 @@
|
||||
"Submit": "Submit",
|
||||
"Understand": "I understand, keep me muted for now",
|
||||
"UnderstandAndUnmute": "I understand, please unmute me",
|
||||
"WaitForHostMsg": "The conference has not yet started because no moderators have yet arrived. If you'd like to become a moderator please log-in. Otherwise, please wait.",
|
||||
"WaitForHostNoAuthMsg": "The conference has not yet started because no moderators have yet arrived. Please wait.",
|
||||
"WaitingForHostButton": "Wait for moderator",
|
||||
"WaitingForHostTitle": "Waiting for a moderator…",
|
||||
@@ -523,6 +522,7 @@
|
||||
"tokenAuthFailedWithReasons": "Sorry, you're not allowed to join this call. Possible reasons: {{reason}}",
|
||||
"tokenAuthUnsupported": "Token URL is not supported.",
|
||||
"transcribing": "Transcribing",
|
||||
"unauthenticatedAccessDisabled": "This call requires authentication. Please login in order to proceed.",
|
||||
"unlockRoom": "Remove meeting $t(lockRoomPassword)",
|
||||
"user": "User",
|
||||
"userIdentifier": "User identifier",
|
||||
@@ -748,7 +748,8 @@
|
||||
"notificationTitle": "Lobby",
|
||||
"passwordJoinButton": "Join",
|
||||
"title": "Lobby",
|
||||
"toggleLabel": "Enable lobby"
|
||||
"toggleLabel": "Enable lobby",
|
||||
"waitForModerator": "The conference has not yet started because no moderators have yet arrived. If you'd like to become a moderator please log-in. Otherwise, please wait."
|
||||
},
|
||||
"localRecording": {
|
||||
"clientState": {
|
||||
@@ -964,6 +965,9 @@
|
||||
"by": "By {{ name }}",
|
||||
"closeButton": "Close poll",
|
||||
"create": {
|
||||
"accessibilityLabel": {
|
||||
"send": "Send poll"
|
||||
},
|
||||
"addOption": "Add option",
|
||||
"answerPlaceholder": "Option {{index}}",
|
||||
"cancel": "Cancel",
|
||||
@@ -972,8 +976,7 @@
|
||||
"pollQuestion": "Poll Question",
|
||||
"questionPlaceholder": "Ask a question",
|
||||
"removeOption": "Remove option",
|
||||
"save": "Save",
|
||||
"send": "Send"
|
||||
"save": "Save"
|
||||
},
|
||||
"errors": {
|
||||
"notUniqueOption": "Options must be unique"
|
||||
@@ -1434,8 +1437,8 @@
|
||||
"linkToSalesforce": "Link to Salesforce",
|
||||
"lobbyButtonDisable": "Disable lobby mode",
|
||||
"lobbyButtonEnable": "Enable lobby mode",
|
||||
"login": "Log-in",
|
||||
"logout": "Log-out",
|
||||
"login": "Log In",
|
||||
"logout": "Log Out",
|
||||
"love": "Heart",
|
||||
"lowerYourHand": "Lower your hand",
|
||||
"moreActions": "More actions",
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
endConference,
|
||||
sendTones,
|
||||
setAssumedBandwidthBps,
|
||||
setFollowMe,
|
||||
setFollowMeRecorder,
|
||||
setLocalSubject,
|
||||
setPassword,
|
||||
setSubject
|
||||
@@ -91,6 +89,7 @@ import {
|
||||
togglePinStageParticipant
|
||||
} from '../../react/features/filmstrip/actions.web';
|
||||
import { getPinnedActiveParticipants, isStageFilmstripAvailable } from '../../react/features/filmstrip/functions.web';
|
||||
import { setFollowMe, setFollowMeRecorder } from '../../react/features/follow-me/actions';
|
||||
import { invite } from '../../react/features/invite/actions.any';
|
||||
import {
|
||||
selectParticipantInLargeVideo
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -66,7 +66,7 @@
|
||||
"js-md5": "0.6.1",
|
||||
"js-sha512": "0.8.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2091.0.0+518cec5f/lib-jitsi-meet.tgz",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2099.0.0+89536686/lib-jitsi-meet.tgz",
|
||||
"lodash-es": "4.17.21",
|
||||
"null-loader": "4.0.1",
|
||||
"optional-require": "1.0.3",
|
||||
@@ -18260,8 +18260,8 @@
|
||||
},
|
||||
"node_modules/lib-jitsi-meet": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2091.0.0+518cec5f/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-mmdvU3B7dYWV6RcfFW/BWJkaNR3LLCG3wHcBYT8WZKVbh00vu4SZJKwtjiVrw2aIP1FcXoD3T0RJ5+IaGROU3A==",
|
||||
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2099.0.0+89536686/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-0FYPvOFSdg9L4ocH8bJw8doUE0rM55JnqRijXMOLS3ZOphbpeBg8tBTH33jwb+bqgo5jjmjTrvJkmkvGNF5/Jg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jitsi/js-utils": "2.4.6",
|
||||
@@ -18273,7 +18273,6 @@
|
||||
"base64-js": "1.5.1",
|
||||
"current-executing-script": "0.1.3",
|
||||
"emoji-regex": "10.4.0",
|
||||
"jquery": "3.6.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"sdp-transform": "2.3.0",
|
||||
"strophe.js": "https://github.com/jitsi/strophejs/releases/download/v1.5-jitsi-3/strophe.js-1.5.0.tgz",
|
||||
@@ -39716,8 +39715,8 @@
|
||||
}
|
||||
},
|
||||
"lib-jitsi-meet": {
|
||||
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2091.0.0+518cec5f/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-mmdvU3B7dYWV6RcfFW/BWJkaNR3LLCG3wHcBYT8WZKVbh00vu4SZJKwtjiVrw2aIP1FcXoD3T0RJ5+IaGROU3A==",
|
||||
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2099.0.0+89536686/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-0FYPvOFSdg9L4ocH8bJw8doUE0rM55JnqRijXMOLS3ZOphbpeBg8tBTH33jwb+bqgo5jjmjTrvJkmkvGNF5/Jg==",
|
||||
"requires": {
|
||||
"@jitsi/js-utils": "2.4.6",
|
||||
"@jitsi/logger": "2.1.1",
|
||||
@@ -39728,7 +39727,6 @@
|
||||
"base64-js": "1.5.1",
|
||||
"current-executing-script": "0.1.3",
|
||||
"emoji-regex": "10.4.0",
|
||||
"jquery": "3.6.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"sdp-transform": "2.3.0",
|
||||
"strophe.js": "https://github.com/jitsi/strophejs/releases/download/v1.5-jitsi-3/strophe.js-1.5.0.tgz",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"js-md5": "0.6.1",
|
||||
"js-sha512": "0.8.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2091.0.0+518cec5f/lib-jitsi-meet.tgz",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2099.0.0+89536686/lib-jitsi-meet.tgz",
|
||||
"lodash-es": "4.17.21",
|
||||
"null-loader": "4.0.1",
|
||||
"optional-require": "1.0.3",
|
||||
@@ -217,8 +217,6 @@
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx --max-warnings 0 --fix .",
|
||||
"postinstall": "patch-package --error-on-fail && jetify",
|
||||
"validate": "npm ls",
|
||||
"tsc-test:web": "tsc --project tsconfig.web.json --listFilesOnly | grep -v node_modules | grep native",
|
||||
"tsc-test:native": "tsc --project tsconfig.native.json --listFilesOnly | grep -v node_modules | grep web",
|
||||
"start": "make dev",
|
||||
"test": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.conf.ts",
|
||||
"test-single": "DOTENV_CONFIG_PATH=tests/.env wdio run tests/wdio.conf.ts --spec",
|
||||
|
||||
@@ -46,6 +46,15 @@ export const SET_TOKEN_AUTH_URL_SUCCESS = 'SET_TOKEN_AUTH_URL_SUCCESS';
|
||||
*/
|
||||
export const STOP_WAIT_FOR_OWNER = 'STOP_WAIT_FOR_OWNER';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which disables moderator login.
|
||||
*
|
||||
* {
|
||||
* type: DISABLE_MODERATOR_LOGIN
|
||||
* }
|
||||
*/
|
||||
export const DISABLE_MODERATOR_LOGIN = 'DISABLE_MODERATOR_LOGIN';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which informs that the authentication and role
|
||||
* upgrade process has finished either with success or with a specific error.
|
||||
@@ -74,6 +83,15 @@ export const UPGRADE_ROLE_FINISHED = 'UPGRADE_ROLE_FINISHED';
|
||||
*/
|
||||
export const UPGRADE_ROLE_STARTED = 'UPGRADE_ROLE_STARTED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which enables moderator login.
|
||||
*
|
||||
* {
|
||||
* type: ENABLE_MODERATOR_LOGIN
|
||||
* }
|
||||
*/
|
||||
export const ENABLE_MODERATOR_LOGIN = 'ENABLE_MODERATOR_LOGIN';
|
||||
|
||||
/**
|
||||
* The type of (redux) action that sets delayed handler which will check if
|
||||
* the conference has been created and it's now possible to join from anonymous
|
||||
|
||||
@@ -4,12 +4,15 @@ import { IJitsiConference } from '../base/conference/reducer';
|
||||
import { hideDialog, openDialog } from '../base/dialog/actions';
|
||||
|
||||
import {
|
||||
DISABLE_MODERATOR_LOGIN,
|
||||
ENABLE_MODERATOR_LOGIN,
|
||||
LOGIN,
|
||||
LOGOUT,
|
||||
SET_TOKEN_AUTH_URL_SUCCESS,
|
||||
STOP_WAIT_FOR_OWNER,
|
||||
UPGRADE_ROLE_FINISHED,
|
||||
UPGRADE_ROLE_STARTED, WAIT_FOR_OWNER
|
||||
UPGRADE_ROLE_STARTED,
|
||||
WAIT_FOR_OWNER
|
||||
} from './actionTypes';
|
||||
import { LoginDialog, WaitForOwnerDialog } from './components';
|
||||
import logger from './logger';
|
||||
@@ -165,6 +168,30 @@ export function logout() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables moderator login.
|
||||
*
|
||||
* @returns {{
|
||||
* type: DISABLE_MODERATOR_LOGIN
|
||||
* }}
|
||||
*/
|
||||
export function disableModeratorLogin() {
|
||||
return {
|
||||
type: DISABLE_MODERATOR_LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables moderator login.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function enableModeratorLogin() {
|
||||
return {
|
||||
type: ENABLE_MODERATOR_LOGIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens {@link WaitForOnwerDialog}.
|
||||
*
|
||||
@@ -175,6 +202,7 @@ export function openWaitForOwnerDialog() {
|
||||
return openDialog(WaitForOwnerDialog);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stops waiting for the conference owner.
|
||||
*
|
||||
|
||||
@@ -67,7 +67,7 @@ class WaitForOwnerDialog extends Component<IProps> {
|
||||
<ConfirmDialog
|
||||
cancelLabel = { this.props._alternativeCancelText ? 'dialog.WaitingForHostButton' : 'dialog.Cancel' }
|
||||
confirmLabel = 'dialog.IamHost'
|
||||
descriptionKey = 'dialog.WaitForHostMsg'
|
||||
descriptionKey = 'lobby.waitForModerator'
|
||||
isConfirmHidden = { _isConfirmHidden }
|
||||
onCancel = { this._onCancel }
|
||||
onSubmit = { this._onLogin } />
|
||||
|
||||
@@ -91,7 +91,7 @@ class WaitForOwnerDialog extends PureComponent<IProps> {
|
||||
onSubmit = { this._onIAmHost }
|
||||
titleKey = { t('dialog.WaitingForHostTitle') }>
|
||||
<span>
|
||||
{ this.props._hideLoginButton ? t('dialog.WaitForHostNoAuthMsg') : t('dialog.WaitForHostMsg') }
|
||||
{ this.props._hideLoginButton ? t('dialog.WaitForHostNoAuthMsg') : t('lobby.waitForModerator') }
|
||||
</span>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
WAIT_FOR_OWNER
|
||||
} from './actionTypes';
|
||||
import {
|
||||
disableModeratorLogin,
|
||||
enableModeratorLogin,
|
||||
hideLoginDialog,
|
||||
openLoginDialog,
|
||||
openTokenAuthUrl,
|
||||
@@ -44,7 +46,7 @@ import logger from './logger';
|
||||
|
||||
/**
|
||||
* Middleware that captures connection or conference failed errors and controls
|
||||
* {@link WaitForOwnerDialog} and {@link LoginDialog}.
|
||||
* moderator login availability and {@link LoginDialog}.
|
||||
*
|
||||
* FIXME Some of the complexity was introduced by the lack of dialog stacking.
|
||||
*
|
||||
@@ -105,11 +107,21 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
}
|
||||
recoverable = error.recoverable;
|
||||
}
|
||||
if (recoverable) {
|
||||
store.dispatch(waitForOwner());
|
||||
} else {
|
||||
store.dispatch(stopWaitForOwner());
|
||||
|
||||
if (error.name === JitsiConferenceErrors.MEMBERS_ONLY_ERROR && lobbyWaitingForHost) {
|
||||
if (recoverable) {
|
||||
store.dispatch(enableModeratorLogin());
|
||||
} else {
|
||||
store.dispatch(disableModeratorLogin());
|
||||
}
|
||||
} else if (error.name === JitsiConferenceErrors.AUTHENTICATION_REQUIRED) {
|
||||
if (recoverable) {
|
||||
store.dispatch(waitForOwner());
|
||||
} else {
|
||||
store.dispatch(stopWaitForOwner());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -126,6 +138,9 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
dispatch(setTokenAuthUrlSuccess(true));
|
||||
}
|
||||
|
||||
if (_isWaitingForModerator(store)) {
|
||||
store.dispatch(disableModeratorLogin());
|
||||
}
|
||||
if (_isWaitingForOwner(store)) {
|
||||
store.dispatch(stopWaitForOwner());
|
||||
}
|
||||
@@ -134,6 +149,7 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
}
|
||||
|
||||
case CONFERENCE_LEFT:
|
||||
store.dispatch(disableModeratorLogin());
|
||||
store.dispatch(stopWaitForOwner());
|
||||
break;
|
||||
|
||||
@@ -236,7 +252,6 @@ function _clearExistingWaitForOwnerTimeout({ getState }: IStore) {
|
||||
waitForOwnerTimeoutID && clearTimeout(waitForOwnerTimeoutID);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the cyclic "wait for conference owner" task is currently scheduled.
|
||||
*
|
||||
@@ -247,6 +262,16 @@ function _isWaitingForOwner({ getState }: IStore) {
|
||||
return Boolean(getState()['features/authentication'].waitForOwnerTimeoutID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cyclic "wait for moderator" task is currently scheduled.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isWaitingForModerator({ getState }: IStore) {
|
||||
return getState()['features/authentication'].showModeratorLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login challenge. Opens login dialog or redirects to token auth URL.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,8 @@ import { assign } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
CANCEL_LOGIN,
|
||||
DISABLE_MODERATOR_LOGIN,
|
||||
ENABLE_MODERATOR_LOGIN,
|
||||
SET_TOKEN_AUTH_URL_SUCCESS,
|
||||
STOP_WAIT_FOR_OWNER,
|
||||
UPGRADE_ROLE_FINISHED,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
export interface IAuthenticationState {
|
||||
error?: Object | undefined;
|
||||
progress?: number | undefined;
|
||||
showModeratorLogin?: boolean;
|
||||
thenableWithCancel?: {
|
||||
cancel: Function;
|
||||
};
|
||||
@@ -45,6 +48,11 @@ ReducerRegistry.register<IAuthenticationState>('features/authentication',
|
||||
progress: undefined,
|
||||
thenableWithCancel: undefined
|
||||
});
|
||||
case ENABLE_MODERATOR_LOGIN:
|
||||
return assign(state, {
|
||||
showModeratorLogin: true
|
||||
});
|
||||
|
||||
case SET_TOKEN_AUTH_URL_SUCCESS:
|
||||
return assign(state, {
|
||||
tokenAuthUrlSuccessful: action.value
|
||||
@@ -56,6 +64,12 @@ ReducerRegistry.register<IAuthenticationState>('features/authentication',
|
||||
waitForOwnerTimeoutID: undefined
|
||||
});
|
||||
|
||||
case DISABLE_MODERATOR_LOGIN:
|
||||
return assign(state, {
|
||||
error: undefined,
|
||||
showModeratorLogin: false
|
||||
});
|
||||
|
||||
case UPGRADE_ROLE_FINISHED: {
|
||||
let { thenableWithCancel } = action;
|
||||
|
||||
|
||||
@@ -260,28 +260,6 @@ export const P2P_STATUS_CHANGED = 'P2P_STATUS_CHANGED';
|
||||
*/
|
||||
export const SEND_TONES = 'SEND_TONES';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which updates the current known status of the
|
||||
* Follow Me feature.
|
||||
*
|
||||
* {
|
||||
* type: SET_FOLLOW_ME,
|
||||
* enabled: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME = 'SET_FOLLOW_ME';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which updates the current known status of the
|
||||
* Follow Me feature that is used only by the recorder.
|
||||
*
|
||||
* {
|
||||
* type: SET_FOLLOW_ME_RECORDER,
|
||||
* enabled: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME_RECORDER = 'SET_FOLLOW_ME_RECORDER';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the obfuscated room name.
|
||||
*
|
||||
|
||||
@@ -58,8 +58,6 @@ import {
|
||||
P2P_STATUS_CHANGED,
|
||||
SEND_TONES,
|
||||
SET_ASSUMED_BANDWIDTH_BPS,
|
||||
SET_FOLLOW_ME,
|
||||
SET_FOLLOW_ME_RECORDER,
|
||||
SET_OBFUSCATED_ROOM,
|
||||
SET_PASSWORD,
|
||||
SET_PASSWORD_FAILED,
|
||||
@@ -853,38 +851,6 @@ export function sendTones(tones: string, duration: number, pause: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the Follow Me feature.
|
||||
*
|
||||
* @param {boolean} enabled - Whether or not Follow Me should be enabled.
|
||||
* @returns {{
|
||||
* type: SET_FOLLOW_ME,
|
||||
* enabled: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setFollowMe(enabled: boolean) {
|
||||
return {
|
||||
type: SET_FOLLOW_ME,
|
||||
enabled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the Follow Me feature used only for the recorder.
|
||||
*
|
||||
* @param {boolean} enabled - Whether Follow Me should be enabled and used only by the recorder.
|
||||
* @returns {{
|
||||
* type: SET_FOLLOW_ME_RECORDER,
|
||||
* enabled: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setFollowMeRecorder(enabled: boolean) {
|
||||
return {
|
||||
type: SET_FOLLOW_ME_RECORDER,
|
||||
enabled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the Mute reaction sounds feature.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { reloadNow } from '../../app/actions';
|
||||
import { IStore } from '../../app/types';
|
||||
import { login } from '../../authentication/actions.any';
|
||||
import { removeLobbyChatParticipant } from '../../chat/actions.any';
|
||||
import { openDisplayNamePrompt } from '../../display-name/actions';
|
||||
import { isVpaasMeeting } from '../../jaas/functions';
|
||||
@@ -209,7 +210,7 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
|
||||
case JitsiConferenceErrors.CONFERENCE_MAX_USERS: {
|
||||
let retryAsVisitor = false;
|
||||
|
||||
if (error.params?.length && error.params[0]?.visitorsSupported) {
|
||||
if (error.params?.length && error.params[0]?.visitorsSupported === 'true') {
|
||||
// visitors are supported, so let's try joining that way
|
||||
retryAsVisitor = true;
|
||||
}
|
||||
@@ -251,7 +252,7 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
|
||||
case JitsiConferenceErrors.NOT_ALLOWED_ERROR: {
|
||||
const [ type, msg ] = error.params;
|
||||
|
||||
let descriptionKey;
|
||||
let descriptionKey, customActionNameKey, customActionHandler;
|
||||
let titleKey = 'dialog.tokenAuthFailed';
|
||||
|
||||
if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.NO_MAIN_PARTICIPANTS) {
|
||||
@@ -263,9 +264,15 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
|
||||
descriptionKey = 'visitors.notification.notAllowedPromotion';
|
||||
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.ROOM_CREATION_RESTRICTION) {
|
||||
descriptionKey = 'dialog.errorRoomCreationRestriction';
|
||||
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.ROOM_UNAUTHENTICATED_ACCESS_DISABLED) {
|
||||
titleKey = 'dialog.unauthenticatedAccessDisabled';
|
||||
customActionNameKey = [ 'toolbar.login' ];
|
||||
customActionHandler = [ () => dispatch(login()) ];
|
||||
}
|
||||
|
||||
dispatch(showErrorNotification({
|
||||
customActionNameKey,
|
||||
customActionHandler,
|
||||
descriptionKey,
|
||||
hideErrorSupportLink: true,
|
||||
titleKey
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
LOCK_STATE_CHANGED,
|
||||
P2P_STATUS_CHANGED,
|
||||
SET_ASSUMED_BANDWIDTH_BPS,
|
||||
SET_FOLLOW_ME,
|
||||
SET_FOLLOW_ME_RECORDER,
|
||||
SET_OBFUSCATED_ROOM,
|
||||
SET_PASSWORD,
|
||||
SET_PENDING_SUBJECT_CHANGE,
|
||||
@@ -107,6 +105,7 @@ export interface IJitsiConference {
|
||||
getParticipantById: Function;
|
||||
getParticipantCount: Function;
|
||||
getParticipants: Function;
|
||||
getPolls: Function;
|
||||
getRole: Function;
|
||||
getShortTermCredentials: Function;
|
||||
getSpeakerStats: () => ISpeakerStats;
|
||||
@@ -177,8 +176,6 @@ export interface IConferenceState {
|
||||
dataChannelOpen?: boolean;
|
||||
e2eeSupported?: boolean;
|
||||
error?: Error;
|
||||
followMeEnabled?: boolean;
|
||||
followMeRecorderEnabled?: boolean;
|
||||
joining?: IJitsiConference;
|
||||
leaving?: IJitsiConference;
|
||||
lobbyError?: boolean;
|
||||
@@ -273,14 +270,6 @@ ReducerRegistry.register<IConferenceState>('features/base/conference',
|
||||
|
||||
return set(state, 'assumedBandwidthBps', assumedBandwidthBps);
|
||||
}
|
||||
case SET_FOLLOW_ME:
|
||||
return set(state, 'followMeEnabled', action.enabled);
|
||||
|
||||
case SET_FOLLOW_ME_RECORDER:
|
||||
return { ...state,
|
||||
followMeRecorderEnabled: action.enabled,
|
||||
followMeEnabled: action.enabled
|
||||
};
|
||||
|
||||
case SET_START_REACTIONS_MUTED:
|
||||
return set(state, 'startReactionsMuted', action.muted);
|
||||
|
||||
@@ -464,6 +464,7 @@ export interface IConfig {
|
||||
lobby?: {
|
||||
autoKnock?: boolean;
|
||||
enableChat?: boolean;
|
||||
showHangUp?: boolean;
|
||||
};
|
||||
localRecording?: {
|
||||
disable?: boolean;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import logger from '../app/logger';
|
||||
|
||||
import { UPDATE_FLAGS } from './actionTypes';
|
||||
import * as featureFlags from './constants';
|
||||
|
||||
/**
|
||||
* Updates the current features flags with the given ones. They will be merged.
|
||||
@@ -10,6 +13,13 @@ import { UPDATE_FLAGS } from './actionTypes';
|
||||
* }}
|
||||
*/
|
||||
export function updateFlags(flags: Object) {
|
||||
const supportedFlags = Object.values(featureFlags);
|
||||
const unsupportedFlags = Object.keys(flags).filter(flag => !supportedFlags.includes(flag as any));
|
||||
|
||||
if (unsupportedFlags.length > 0) {
|
||||
logger.warn(`The following feature flags are not supported: ${unsupportedFlags.join(', ')}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: UPDATE_FLAGS,
|
||||
flags
|
||||
|
||||
@@ -41,8 +41,8 @@ const _LANGUAGES = {
|
||||
},
|
||||
|
||||
// Spanish (Latin America)
|
||||
'esUS': {
|
||||
main: require('../../../../lang/main-esUS')
|
||||
'es-US': {
|
||||
main: require('../../../../lang/main-es-US')
|
||||
},
|
||||
|
||||
// Estonian
|
||||
@@ -66,8 +66,8 @@ const _LANGUAGES = {
|
||||
},
|
||||
|
||||
// French (Canadian)
|
||||
'frCA': {
|
||||
main: require('../../../../lang/main-frCA')
|
||||
'fr-CA': {
|
||||
main: require('../../../../lang/main-fr-CA')
|
||||
},
|
||||
|
||||
// Croatian
|
||||
@@ -116,8 +116,8 @@ const _LANGUAGES = {
|
||||
},
|
||||
|
||||
// Portuguese (Brazil)
|
||||
'ptBR': {
|
||||
main: require('../../../../lang/main-ptBR')
|
||||
'pt-BR': {
|
||||
main: require('../../../../lang/main-pt-BR')
|
||||
},
|
||||
|
||||
// Romanian
|
||||
@@ -166,13 +166,13 @@ const _LANGUAGES = {
|
||||
},
|
||||
|
||||
// Chinese (Simplified)
|
||||
'zhCN': {
|
||||
main: require('../../../../lang/main-zhCN')
|
||||
'zh-CN': {
|
||||
main: require('../../../../lang/main-zh-CN')
|
||||
},
|
||||
|
||||
// Chinese (Traditional)
|
||||
'zhTW': {
|
||||
main: require('../../../../lang/main-zhTW')
|
||||
'zh-TW': {
|
||||
main: require('../../../../lang/main-zh-TW')
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
|
||||
declare let navigator: any;
|
||||
|
||||
/**
|
||||
* Custom language detection, just returns the config property if any.
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* Does not support caching.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
cacheUserLanguage: Function.prototype,
|
||||
|
||||
/**
|
||||
* Looks the language up in the config.
|
||||
*
|
||||
* @returns {string} The default language if any.
|
||||
*/
|
||||
lookup() {
|
||||
let found = [];
|
||||
|
||||
if (typeof navigator !== 'undefined') {
|
||||
if (navigator.languages) {
|
||||
// chrome only; not an array, so can't use .push.apply instead of iterating
|
||||
for (let i = 0; i < navigator.languages.length; i++) {
|
||||
found.push(navigator.languages[i]);
|
||||
}
|
||||
}
|
||||
if (navigator.userLanguage) {
|
||||
found.push(navigator.userLanguage);
|
||||
}
|
||||
if (navigator.language) {
|
||||
found.push(navigator.language);
|
||||
}
|
||||
}
|
||||
|
||||
found = found.map<string>(normalizeLanguage);
|
||||
|
||||
return found.length > 0 ? found : undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Name of the language detector.
|
||||
*/
|
||||
name: 'customNavigatorDetector'
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize language format.
|
||||
*
|
||||
* (en-US => enUS)
|
||||
* (en-gb => enGB)
|
||||
* (es-es => es).
|
||||
*
|
||||
* @param {string} language - Language.
|
||||
* @returns {string} The normalized language.
|
||||
*/
|
||||
function normalizeLanguage(language: string) {
|
||||
const [ lang, variant ] = language.replace('_', '-').split('-');
|
||||
|
||||
if (!variant || lang.toUpperCase() === variant.toUpperCase()) {
|
||||
return lang;
|
||||
}
|
||||
|
||||
return lang + variant.toUpperCase();
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import BrowserLanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import configLanguageDetector from './configLanguageDetector';
|
||||
import customNavigatorDetector from './customNavigatorDetector';
|
||||
|
||||
/**
|
||||
* The ordered list (by name) of language detectors to be utilized as backends
|
||||
@@ -16,7 +15,7 @@ const order = [
|
||||
|
||||
// Allow i18next to detect the system language reported by the Web browser
|
||||
// itself.
|
||||
interfaceConfig.LANG_DETECTION && order.push(customNavigatorDetector.name);
|
||||
interfaceConfig.LANG_DETECTION && order.push('navigator');
|
||||
|
||||
// Default use configured language
|
||||
order.push(configLanguageDetector.name);
|
||||
@@ -34,11 +33,6 @@ const languageDetector
|
||||
order
|
||||
});
|
||||
|
||||
// Add the language detector which looks the language up in the config. Its
|
||||
// order has already been established above.
|
||||
// @ts-ignore
|
||||
languageDetector.addDetector(customNavigatorDetector);
|
||||
|
||||
// @ts-ignore
|
||||
languageDetector.addDetector(configLanguageDetector);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { getLobbyConfig } from '../../../../lobby/functions';
|
||||
import DeviceStatus from '../../../../prejoin/components/web/preview/DeviceStatus';
|
||||
import { isRoomNameEnabled } from '../../../../prejoin/functions.web';
|
||||
import Toolbox from '../../../../toolbox/components/web/Toolbox';
|
||||
@@ -121,10 +122,9 @@ const useStyles = makeStyles()(theme => {
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
margin: '0 48px',
|
||||
padding: '24px 0 16px',
|
||||
position: 'relative',
|
||||
width: '300px',
|
||||
width: '400px',
|
||||
height: '100%',
|
||||
zIndex: 252,
|
||||
|
||||
@@ -146,10 +146,21 @@ const useStyles = makeStyles()(theme => {
|
||||
contentControls: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
alignItems: 'stretch',
|
||||
margin: 'auto',
|
||||
width: '100%'
|
||||
},
|
||||
paddedContent: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '0 50px',
|
||||
|
||||
'& > *': {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
...theme.typography.heading4,
|
||||
color: `${theme.palette.text01}!important`,
|
||||
@@ -220,34 +231,38 @@ const PreMeetingScreen = ({
|
||||
{_isPreCallTestEnabled && <ConnectionStatus />}
|
||||
|
||||
<div className = { classes.contentControls }>
|
||||
<h1 className = { classes.title }>
|
||||
{title}
|
||||
</h1>
|
||||
{_roomName && (
|
||||
<span className = { classes.roomNameContainer }>
|
||||
{isOverflowing ? (
|
||||
<Tooltip content = { _roomName }>
|
||||
<div className = { classes.paddedContent }>
|
||||
<h1 className = { classes.title }>
|
||||
{title}
|
||||
</h1>
|
||||
{_roomName && (
|
||||
<span className = { classes.roomNameContainer }>
|
||||
{isOverflowing ? (
|
||||
<Tooltip content = { _roomName }>
|
||||
<span
|
||||
className = { classes.roomName }
|
||||
ref = { roomNameRef }>
|
||||
{_roomName}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className = { classes.roomName }
|
||||
ref = { roomNameRef }>
|
||||
{_roomName}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className = { classes.roomName }
|
||||
ref = { roomNameRef }>
|
||||
{_roomName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{_buttons.length && <Toolbox toolbarButtons = { _buttons } />}
|
||||
{skipPrejoinButton}
|
||||
{showUnsafeRoomWarning && <UnsafeRoomWarning />}
|
||||
{showDeviceStatus && <DeviceStatus />}
|
||||
{showRecordingWarning && <RecordingWarning />}
|
||||
<div className = { classes.paddedContent }>
|
||||
{skipPrejoinButton}
|
||||
{showUnsafeRoomWarning && <UnsafeRoomWarning />}
|
||||
{showDeviceStatus && <DeviceStatus />}
|
||||
{showRecordingWarning && <RecordingWarning />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,10 +284,16 @@ const PreMeetingScreen = ({
|
||||
function mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
|
||||
const { hiddenPremeetingButtons } = state['features/base/config'];
|
||||
const { toolbarButtons } = state['features/toolbox'];
|
||||
const { showHangUp = true } = getLobbyConfig(state);
|
||||
const { knocking } = state['features/lobby'];
|
||||
const premeetingButtons = (ownProps.thirdParty
|
||||
? THIRD_PARTY_PREJOIN_BUTTONS
|
||||
: PREMEETING_BUTTONS).filter((b: any) => !(hiddenPremeetingButtons || []).includes(b));
|
||||
|
||||
if (showHangUp && knocking && !premeetingButtons.includes('hangup')) {
|
||||
premeetingButtons.push('hangup');
|
||||
}
|
||||
|
||||
const { premeetingBackground } = state['features/dynamic-branding'];
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,6 +22,11 @@ interface ICheckboxProps {
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The id of the input.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* The label of the input.
|
||||
*/
|
||||
@@ -147,6 +152,7 @@ const Checkbox = ({
|
||||
checked,
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
onChange
|
||||
@@ -160,6 +166,7 @@ const Checkbox = ({
|
||||
<input
|
||||
checked = { checked }
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
name = { name }
|
||||
onChange = { onChange }
|
||||
type = 'checkbox' />
|
||||
|
||||
@@ -744,6 +744,11 @@ function _shouldSendPrivateMessageTo(state: IReduxState, action: AnyAction) {
|
||||
}
|
||||
|
||||
if (lastMessage.privateMessage) {
|
||||
if (!lastMessage.participantId) {
|
||||
// this is a system message we can ignore
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We show the notice if the last received message was private.
|
||||
return {
|
||||
id: lastMessage.participantId,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
|
||||
/**
|
||||
* Tells whether or not the notifications should be displayed within
|
||||
@@ -30,5 +28,11 @@ export function shouldDisplayNotifications(stateful: IStateful) {
|
||||
export function arePollsDisabled(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return state['features/base/config']?.disablePolls || iAmVisitor(state);
|
||||
const { conference } = state['features/base/conference'];
|
||||
|
||||
if (!conference?.getPolls()?.isSupported()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state['features/base/config']?.disablePolls;
|
||||
}
|
||||
|
||||
@@ -19,3 +19,25 @@ export const SET_FOLLOW_ME_MODERATOR = 'SET_FOLLOW_ME_MODERATOR';
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME_STATE = 'SET_FOLLOW_ME_STATE';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which updates the current known status of the
|
||||
* Follow Me feature.
|
||||
*
|
||||
* {
|
||||
* type: SET_FOLLOW_ME,
|
||||
* enabled: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME = 'SET_FOLLOW_ME';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which updates the current known status of the
|
||||
* Follow Me feature that is used only by the recorder.
|
||||
*
|
||||
* {
|
||||
* type: SET_FOLLOW_ME_RECORDER,
|
||||
* enabled: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_FOLLOW_ME_RECORDER = 'SET_FOLLOW_ME_RECORDER';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
SET_FOLLOW_ME,
|
||||
SET_FOLLOW_ME_MODERATOR,
|
||||
SET_FOLLOW_ME_RECORDER,
|
||||
SET_FOLLOW_ME_STATE
|
||||
} from './actionTypes';
|
||||
|
||||
@@ -37,3 +39,35 @@ export function setFollowMeState(state?: Object) {
|
||||
state
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the Follow Me feature.
|
||||
*
|
||||
* @param {boolean} enabled - Whether or not Follow Me should be enabled.
|
||||
* @returns {{
|
||||
* type: SET_FOLLOW_ME,
|
||||
* enabled: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setFollowMe(enabled: boolean) {
|
||||
return {
|
||||
type: SET_FOLLOW_ME,
|
||||
enabled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the Follow Me feature used only for the recorder.
|
||||
*
|
||||
* @param {boolean} enabled - Whether Follow Me should be enabled and used only by the recorder.
|
||||
* @returns {{
|
||||
* type: SET_FOLLOW_ME_RECORDER,
|
||||
* enabled: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setFollowMeRecorder(enabled: boolean) {
|
||||
return {
|
||||
type: SET_FOLLOW_ME_RECORDER,
|
||||
enabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { set } from '../base/redux/functions';
|
||||
|
||||
import {
|
||||
SET_FOLLOW_ME,
|
||||
SET_FOLLOW_ME_MODERATOR,
|
||||
SET_FOLLOW_ME_RECORDER,
|
||||
SET_FOLLOW_ME_STATE
|
||||
} from './actionTypes';
|
||||
|
||||
export interface IFollowMeState {
|
||||
followMeEnabled?: boolean;
|
||||
followMeRecorderEnabled?: boolean;
|
||||
moderator?: string;
|
||||
recorder?: boolean;
|
||||
state?: {
|
||||
@@ -21,7 +25,8 @@ ReducerRegistry.register<IFollowMeState>(
|
||||
'features/follow-me',
|
||||
(state = {}, action): IFollowMeState => {
|
||||
switch (action.type) {
|
||||
|
||||
case SET_FOLLOW_ME:
|
||||
return set(state, 'followMeEnabled', action.enabled);
|
||||
case SET_FOLLOW_ME_MODERATOR: {
|
||||
let newState = set(state, 'moderator', action.id);
|
||||
|
||||
@@ -35,6 +40,11 @@ ReducerRegistry.register<IFollowMeState>(
|
||||
|
||||
return newState;
|
||||
}
|
||||
case SET_FOLLOW_ME_RECORDER:
|
||||
return { ...state,
|
||||
followMeRecorderEnabled: action.enabled,
|
||||
followMeEnabled: action.enabled
|
||||
};
|
||||
case SET_FOLLOW_ME_STATE: {
|
||||
return set(state, 'state', action.state);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { FOLLOW_ME_COMMAND } from './constants';
|
||||
* notify all listeners.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/conference'].followMeEnabled,
|
||||
/* selector */ state => state['features/follow-me'].followMeEnabled,
|
||||
/* listener */ (newSelectedValue, store) => _sendFollowMeCommand(newSelectedValue || 'off', store));
|
||||
|
||||
/**
|
||||
@@ -88,7 +88,7 @@ function _getFollowMeState(state: IReduxState) {
|
||||
const stageFilmstrip = isStageFilmstripEnabled(state);
|
||||
|
||||
return {
|
||||
recorder: state['features/base/conference'].followMeRecorderEnabled,
|
||||
recorder: state['features/follow-me'].followMeRecorderEnabled,
|
||||
filmstripVisible: state['features/filmstrip'].visible,
|
||||
maxStageParticipants: stageFilmstrip ? state['features/base/settings'].maxStageParticipants : undefined,
|
||||
nextOnStage: pinnedParticipant?.id,
|
||||
@@ -130,7 +130,7 @@ function _sendFollowMeCommand(
|
||||
);
|
||||
|
||||
return;
|
||||
} else if (!state['features/base/conference'].followMeEnabled) {
|
||||
} else if (!state['features/follow-me'].followMeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { updateSettings } from '../../base/settings/actions';
|
||||
import { IMessage } from '../../chat/types';
|
||||
import { isDeviceStatusVisible } from '../../prejoin/functions';
|
||||
import { cancelKnocking, joinWithPassword, onSendMessage, setPasswordJoinFailed, startKnocking } from '../actions';
|
||||
import { getLobbyConfig } from '../functions';
|
||||
|
||||
export const SCREEN_STATES = {
|
||||
EDIT: 1,
|
||||
@@ -27,6 +28,11 @@ export interface IProps {
|
||||
*/
|
||||
_deviceStatusVisible: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the hangup button.
|
||||
*/
|
||||
_hangUp?: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether the message that display name is required is shown.
|
||||
*/
|
||||
@@ -52,6 +58,11 @@ export interface IProps {
|
||||
*/
|
||||
_lobbyMessageRecipient?: string;
|
||||
|
||||
/**
|
||||
* Whether to hide the login button.
|
||||
*/
|
||||
_login?: boolean;
|
||||
|
||||
/**
|
||||
* The name of the meeting we're about to join.
|
||||
*/
|
||||
@@ -445,8 +456,10 @@ export function _mapStateToProps(state: IReduxState) {
|
||||
const { disableLobbyPassword } = getSecurityUiConfig(state);
|
||||
const showCopyUrlButton = inviteEnabledFlag || !disableInviteFunctions;
|
||||
const deviceStatusVisible = isDeviceStatusVisible(state);
|
||||
const { showHangUp = true } = getLobbyConfig(state);
|
||||
const { membersOnly, lobbyWaitingForHost } = state['features/base/conference'];
|
||||
const { isLobbyChatActive, lobbyMessageRecipient, messages } = state['features/chat'];
|
||||
const { showModeratorLogin } = state['features/authentication'];
|
||||
|
||||
return {
|
||||
_deviceStatusVisible: deviceStatusVisible,
|
||||
@@ -454,6 +467,8 @@ export function _mapStateToProps(state: IReduxState) {
|
||||
_knocking: knocking,
|
||||
_lobbyChatMessages: messages,
|
||||
_lobbyMessageRecipient: lobbyMessageRecipient?.name,
|
||||
_login: showModeratorLogin,
|
||||
_hangUp: showHangUp,
|
||||
_isLobbyChatActive: isLobbyChatActive,
|
||||
_meetingName: getConferenceName(state),
|
||||
_membersOnlyConference: membersOnly,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { login } from '../../../authentication/actions.any';
|
||||
import { getConferenceName } from '../../../base/conference/functions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
@@ -18,6 +19,7 @@ import { navigate }
|
||||
from '../../../mobile/navigation/components/lobby/LobbyNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import { preJoinStyles } from '../../../prejoin/components/native/styles';
|
||||
import HangupButton from '../../../toolbox/components/HangupButton';
|
||||
import AudioMuteButton from '../../../toolbox/components/native/AudioMuteButton';
|
||||
import VideoMuteButton from '../../../toolbox/components/native/VideoMuteButton';
|
||||
import AbstractLobbyScreen, {
|
||||
@@ -43,6 +45,18 @@ interface IProps extends AbstractProps {
|
||||
* Implements a waiting screen that represents the participant being in the lobby.
|
||||
*/
|
||||
class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
/**
|
||||
* Initializes a new LobbyScreen instance.
|
||||
*
|
||||
* @param {IProps} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onLogin = this._onLogin.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code PureComponent#render}.
|
||||
*
|
||||
@@ -197,12 +211,19 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
* @inheritdoc
|
||||
*/
|
||||
_renderToolbarButtons() {
|
||||
const { _hangUp } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { preJoinStyles.toolboxContainer as ViewStyle }>
|
||||
<AudioMuteButton
|
||||
styles = { preJoinStyles.buttonStylesBorderless } />
|
||||
<VideoMuteButton
|
||||
styles = { preJoinStyles.buttonStylesBorderless } />
|
||||
{
|
||||
_hangUp
|
||||
&& <HangupButton
|
||||
styles = { preJoinStyles.buttonStylesBorderless } />
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +234,7 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
* @inheritdoc
|
||||
*/
|
||||
_renderStandardButtons() {
|
||||
const { _knocking, _renderPassword, _isLobbyChatActive } = this.props;
|
||||
const { _knocking, _renderPassword, _isLobbyChatActive, _login } = this.props;
|
||||
const { displayName } = this.state;
|
||||
|
||||
return (
|
||||
@@ -246,9 +267,28 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
style = { preJoinStyles.joinButton }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
}
|
||||
{
|
||||
_login
|
||||
&& <Button
|
||||
accessibilityLabel = 'dialog.IamHost'
|
||||
labelKey = 'dialog.IamHost'
|
||||
onClick = { this._onLogin }
|
||||
style = { preJoinStyles.joinButton }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login button click.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLogin() {
|
||||
this.props.dispatch(login());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { login } from '../../../authentication/actions.any';
|
||||
import { leaveConference } from '../../../base/conference/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
@@ -8,6 +10,7 @@ import PreMeetingScreen from '../../../base/premeeting/components/web/PreMeeting
|
||||
import LoadingIndicator from '../../../base/react/components/web/LoadingIndicator';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Input from '../../../base/ui/components/web/Input';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import ChatInput from '../../../chat/components/web/ChatInput';
|
||||
import MessageContainer from '../../../chat/components/web/MessageContainer';
|
||||
import AbstractLobbyScreen, {
|
||||
@@ -35,6 +38,10 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
super(props);
|
||||
|
||||
this._messageContainerRef = React.createRef<MessageContainer>();
|
||||
|
||||
// Bind authentication methods
|
||||
this._onLogin = this._onLogin.bind(this);
|
||||
this._onHangup = this._onHangup.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +91,7 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
* @inheritdoc
|
||||
*/
|
||||
override _renderJoining() {
|
||||
const { _isLobbyChatActive } = this.props;
|
||||
const { _login, _isLobbyChatActive } = this.props;
|
||||
|
||||
return (
|
||||
<div className = 'lobby-screen-content'>
|
||||
@@ -96,7 +103,7 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
<LoadingIndicator size = 'large' />
|
||||
</div>
|
||||
<span className = 'joining-message'>
|
||||
{ this.props.t('lobby.joiningMessage') }
|
||||
{ this.props.t(_login ? 'lobby.waitForModerator' : 'lobby.joiningMessage') }
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -215,14 +222,14 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
labelKey = 'prejoin.joinMeeting'
|
||||
onClick = { this._onJoinWithPassword }
|
||||
testId = 'lobby.passwordJoinButton'
|
||||
type = 'primary' />
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
<Button
|
||||
className = 'lobby-button-margin'
|
||||
fullWidth = { true }
|
||||
labelKey = 'lobby.backToKnockModeButton'
|
||||
onClick = { this._onSwitchToKnockMode }
|
||||
testId = 'lobby.backToKnockModeButton'
|
||||
type = 'secondary' />
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -233,7 +240,7 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
* @inheritdoc
|
||||
*/
|
||||
override _renderStandardButtons() {
|
||||
const { _knocking, _isLobbyChatActive, _renderPassword } = this.props;
|
||||
const { _knocking, _login, _isLobbyChatActive, _renderPassword } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -260,7 +267,15 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
labelKey = 'lobby.enterPasswordButton'
|
||||
onClick = { this._onSwitchToPasswordMode }
|
||||
testId = 'lobby.enterPasswordButton'
|
||||
type = 'secondary' />
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
}
|
||||
{_login && <Button
|
||||
className = 'lobby-button-margin'
|
||||
fullWidth = { true }
|
||||
labelKey = 'dialog.IamHost'
|
||||
onClick = { this._onLogin }
|
||||
testId = 'lobby.loginButton'
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
}
|
||||
</>
|
||||
);
|
||||
@@ -279,6 +294,26 @@ class LobbyScreen extends AbstractLobbyScreen<IProps> {
|
||||
this._messageContainerRef.current.scrollToElement(withAnimation, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login button click.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLogin() {
|
||||
this.props.dispatch(login());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles hangup button click.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHangup() {
|
||||
this.props.dispatch(leaveConference());
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(LobbyScreen));
|
||||
|
||||
@@ -8,67 +8,10 @@ import 'promise.withresolvers/auto'; // Promise.withResolvers.
|
||||
import 'react-native-url-polyfill/auto'; // Complete URL polyfill.
|
||||
|
||||
import Storage from './Storage';
|
||||
import { querySelector, querySelectorAll } from './querySelectorPolyfill';
|
||||
|
||||
const { AppInfo } = NativeModules;
|
||||
|
||||
/**
|
||||
* Implements an absolute minimum of the common logic of
|
||||
* {@code Document.querySelector} and {@code Element.querySelector}. Implements
|
||||
* the most simple of selectors necessary to satisfy the call sites at the time
|
||||
* of this writing (i.e. Select by tagName).
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selectors - The group of CSS selectors to match on.
|
||||
* @returns {Element} - The first Element which is a descendant of the specified
|
||||
* node and matches the specified group of selectors.
|
||||
*/
|
||||
function _querySelector(node, selectors) {
|
||||
let element = null;
|
||||
|
||||
node && _visitNode(node, n => {
|
||||
if (n.nodeType === 1 /* ELEMENT_NODE */
|
||||
&& n.nodeName === selectors) {
|
||||
element = n;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits each Node in the tree of a specific root Node (using depth-first
|
||||
* traversal) and invokes a specific callback until the callback returns true.
|
||||
*
|
||||
* @param {Node} node - The root Node which represents the tree of Nodes to
|
||||
* visit.
|
||||
* @param {Function} callback - The callback to invoke with each visited Node.
|
||||
* @returns {boolean} - True if the specified callback returned true for a Node
|
||||
* (at which point the visiting stopped); otherwise, false.
|
||||
*/
|
||||
function _visitNode(node, callback) {
|
||||
if (callback(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* eslint-disable no-param-reassign, no-extra-parens */
|
||||
|
||||
if ((node = node.firstChild)) {
|
||||
do {
|
||||
if (_visitNode(node, callback)) {
|
||||
return true;
|
||||
}
|
||||
} while ((node = node.nextSibling));
|
||||
}
|
||||
|
||||
/* eslint-enable no-param-reassign, no-extra-parens */
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
(global => {
|
||||
// DOMParser
|
||||
//
|
||||
@@ -97,7 +40,6 @@ function _visitNode(node, callback) {
|
||||
// document
|
||||
//
|
||||
// Required by:
|
||||
// - jQuery
|
||||
// - Strophe
|
||||
if (typeof global.document === 'undefined') {
|
||||
const document
|
||||
@@ -156,7 +98,17 @@ function _visitNode(node, callback) {
|
||||
if (elementPrototype) {
|
||||
if (typeof elementPrototype.querySelector === 'undefined') {
|
||||
elementPrototype.querySelector = function(selectors) {
|
||||
return _querySelector(this, selectors);
|
||||
return querySelector(this, selectors);
|
||||
};
|
||||
}
|
||||
|
||||
// Element.querySelectorAll
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet XMLUtils
|
||||
if (typeof elementPrototype.querySelectorAll === 'undefined') {
|
||||
elementPrototype.querySelectorAll = function(selectors) {
|
||||
return querySelectorAll(this, selectors);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,6 +187,51 @@ function _visitNode(node, callback) {
|
||||
}
|
||||
}
|
||||
|
||||
// Document.querySelector
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet -> XMLUtils.ts -> parseXML
|
||||
if (typeof document.querySelector === 'undefined') {
|
||||
document.querySelector = function(selectors) {
|
||||
return this.documentElement ? querySelector(this.documentElement, selectors) : null;
|
||||
};
|
||||
}
|
||||
|
||||
// Document.querySelectorAll
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet -> XMLUtils.ts -> parseXML
|
||||
if (typeof document.querySelectorAll === 'undefined') {
|
||||
document.querySelectorAll = function(selectors) {
|
||||
return this.documentElement ? querySelectorAll(this.documentElement, selectors) : [];
|
||||
};
|
||||
}
|
||||
|
||||
// Also add querySelector methods to Document.prototype for DOMParser-created documents
|
||||
const documentPrototype = Object.getPrototypeOf(document);
|
||||
|
||||
if (documentPrototype) {
|
||||
// Document.querySelector
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet -> XMLUtils.ts -> parseXML
|
||||
if (typeof documentPrototype.querySelector === 'undefined') {
|
||||
documentPrototype.querySelector = function(selectors) {
|
||||
return this.documentElement ? querySelector(this.documentElement, selectors) : null;
|
||||
};
|
||||
}
|
||||
|
||||
// Document.querySelectorAll
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet -> XMLUtils.ts -> parseXML
|
||||
if (typeof documentPrototype.querySelectorAll === 'undefined') {
|
||||
documentPrototype.querySelectorAll = function(selectors) {
|
||||
return this.documentElement ? querySelectorAll(this.documentElement, selectors) : [];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
global.document = document;
|
||||
}
|
||||
|
||||
|
||||
282
react/features/mobile/polyfills/querySelectorPolyfill.js
Normal file
282
react/features/mobile/polyfills/querySelectorPolyfill.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// Regex constants for efficient reuse across selector parsing
|
||||
const SIMPLE_TAG_NAME_REGEX = /^[a-zA-Z][\w-]*$/;
|
||||
const MULTI_ATTRIBUTE_SELECTOR_REGEX = /^([a-zA-Z][\w-]*)?(\[(?:\*\|)?([^=\]]+)=["']?([^"'\]]+)["']?\])+$/;
|
||||
const SINGLE_ATTRIBUTE_REGEX = /\[(?:\*\|)?([^=\]]+)=["']?([^"'\]]+)["']?\]/g;
|
||||
const WHITESPACE_AROUND_COMBINATOR_REGEX = /\s*>\s*/g;
|
||||
|
||||
/**
|
||||
* Parses a CSS selector into reusable components.
|
||||
*
|
||||
* @param {string} selector - The CSS selector to parse.
|
||||
* @returns {Object} - Object with tagName and attrConditions properties.
|
||||
*/
|
||||
function _parseSelector(selector) {
|
||||
// Wildcard selector
|
||||
if (selector === '*') {
|
||||
return {
|
||||
tagName: null, // null means match all tag names
|
||||
attrConditions: []
|
||||
};
|
||||
}
|
||||
|
||||
// Simple tag name
|
||||
if (SIMPLE_TAG_NAME_REGEX.test(selector)) {
|
||||
return {
|
||||
tagName: selector,
|
||||
attrConditions: []
|
||||
};
|
||||
}
|
||||
|
||||
// Attribute selector: tagname[attr="value"] or
|
||||
// tagname[attr1="value1"][attr2="value2"] (with optional wildcard namespace)
|
||||
const multiAttrMatch = selector.match(MULTI_ATTRIBUTE_SELECTOR_REGEX);
|
||||
|
||||
if (multiAttrMatch) {
|
||||
const tagName = multiAttrMatch[1];
|
||||
const attrConditions = [];
|
||||
let attrMatch;
|
||||
|
||||
while ((attrMatch = SINGLE_ATTRIBUTE_REGEX.exec(selector)) !== null) {
|
||||
attrConditions.push({
|
||||
name: attrMatch[1], // This properly strips the *| prefix
|
||||
value: attrMatch[2]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tagName,
|
||||
attrConditions
|
||||
};
|
||||
}
|
||||
|
||||
// Unsupported selector
|
||||
throw new SyntaxError(`Unsupported selector pattern: '${selector}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters elements by selector pattern and handles findFirst logic.
|
||||
*
|
||||
* @param {Element[]} elements - Array of elements to filter.
|
||||
* @param {string} selector - CSS selector to match against.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Filtered results with proper return type.
|
||||
*/
|
||||
function _filterAndMatchElements(elements, selector, findFirst) {
|
||||
const { tagName, attrConditions } = _parseSelector(selector);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const element of elements) {
|
||||
// Check tag name if specified
|
||||
if (tagName && !(element.localName === tagName || element.tagName === tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if all attribute conditions match
|
||||
const allMatch = attrConditions.every(condition =>
|
||||
element.getAttribute(condition.name) === condition.value
|
||||
);
|
||||
|
||||
if (allMatch) {
|
||||
results.push(element);
|
||||
if (findFirst) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findFirst ? null : results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles direct child traversal for selectors with > combinators.
|
||||
* This is the shared logic used by both scope selectors and regular direct child selectors.
|
||||
*
|
||||
* @param {Element[]} startElements - Array of starting elements to traverse from.
|
||||
* @param {string[]} selectorParts - Array of selector parts split by '>'.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Array of Elements for querySelectorAll,
|
||||
* single Element or null for querySelector.
|
||||
*/
|
||||
function _traverseDirectChildren(startElements, selectorParts, findFirst) {
|
||||
let currentElements = startElements;
|
||||
|
||||
for (const part of selectorParts) {
|
||||
const nextElements = [];
|
||||
|
||||
currentElements.forEach(el => {
|
||||
// Get direct children
|
||||
const directChildren = Array.from(el.children || []);
|
||||
|
||||
// Use same helper as handlers
|
||||
const matchingChildren = _filterAndMatchElements(directChildren, part, false);
|
||||
|
||||
nextElements.push(...matchingChildren);
|
||||
});
|
||||
|
||||
currentElements = nextElements;
|
||||
|
||||
// If we have no results, we can stop early (applies to both querySelector and querySelectorAll)
|
||||
if (currentElements.length === 0) {
|
||||
return findFirst ? null : [];
|
||||
}
|
||||
}
|
||||
|
||||
return findFirst ? currentElements[0] || null : currentElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles :scope pseudo-selector cases with direct child combinators.
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selector - The CSS selector.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Array of Elements for querySelectorAll,
|
||||
* single Element or null for querySelector.
|
||||
*/
|
||||
function _handleScopeSelector(node, selector, findFirst) {
|
||||
let searchSelector = selector.substring(6);
|
||||
|
||||
// Handle :scope > tagname (direct children)
|
||||
if (searchSelector.startsWith('>')) {
|
||||
searchSelector = searchSelector.substring(1);
|
||||
|
||||
// Split by > and use shared traversal logic
|
||||
const parts = searchSelector.split('>');
|
||||
|
||||
// Start from the node itself (scope)
|
||||
return _traverseDirectChildren([ node ], parts, findFirst);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles nested > selectors (direct child combinators).
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selector - The CSS selector.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Array of Elements for querySelectorAll,
|
||||
* single Element or null for querySelector.
|
||||
*/
|
||||
function _handleDirectChildSelectors(node, selector, findFirst) {
|
||||
const parts = selector.split('>');
|
||||
|
||||
// First find elements matching the first part (this could be descendants, not just direct children)
|
||||
const startElements = _querySelectorInternal(node, parts[0], false);
|
||||
|
||||
// If no starting elements found, return early
|
||||
if (startElements.length === 0) {
|
||||
return findFirst ? null : [];
|
||||
}
|
||||
|
||||
// Use shared traversal logic for the remaining parts
|
||||
return _traverseDirectChildren(startElements, parts.slice(1), findFirst);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles simple tag name selectors.
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selector - The CSS selector.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Array of Elements for querySelectorAll,
|
||||
* single Element or null for querySelector.
|
||||
*/
|
||||
function _handleSimpleTagSelector(node, selector, findFirst) {
|
||||
const elements = Array.from(node.getElementsByTagName(selector));
|
||||
|
||||
if (findFirst) {
|
||||
return elements[0] || null;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles attribute selectors: tagname[attr="value"] or tagname[attr1="value1"][attr2="value2"].
|
||||
* Supports single or multiple attributes with optional wildcard namespace (*|).
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selector - The CSS selector.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Array of Elements for querySelectorAll,
|
||||
* single Element or null for querySelector.
|
||||
*/
|
||||
function _handleAttributeSelector(node, selector, findFirst) {
|
||||
const { tagName } = _parseSelector(selector); // Just to get tagName for optimization
|
||||
|
||||
// Handler's job: find the right elements to search
|
||||
const elementsToCheck = tagName
|
||||
? Array.from(node.getElementsByTagName(tagName))
|
||||
: Array.from(node.getElementsByTagName('*'));
|
||||
|
||||
// Common helper does the matching
|
||||
return _filterAndMatchElements(elementsToCheck, selector, findFirst);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function that implements the core selector matching logic for both
|
||||
* querySelector and querySelectorAll. Supports :scope pseudo-selector, direct
|
||||
* child selectors, and common CSS selectors.
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selector - The CSS selector to match elements against.
|
||||
* @param {boolean} findFirst - If true, return after finding the first match.
|
||||
* @returns {Element[]|Element|null} - Array of Elements for querySelectorAll,
|
||||
* single Element or null for querySelector.
|
||||
*/
|
||||
function _querySelectorInternal(node, selector, findFirst = false) {
|
||||
// Normalize whitespace around > combinators first
|
||||
const normalizedSelector = selector.replace(WHITESPACE_AROUND_COMBINATOR_REGEX, '>');
|
||||
|
||||
// Handle :scope pseudo-selector
|
||||
if (normalizedSelector.startsWith(':scope')) {
|
||||
return _handleScopeSelector(node, normalizedSelector, findFirst);
|
||||
}
|
||||
|
||||
// Handle nested > selectors (direct child combinators)
|
||||
if (normalizedSelector.includes('>')) {
|
||||
return _handleDirectChildSelectors(node, normalizedSelector, findFirst);
|
||||
}
|
||||
|
||||
// Fast path: simple tag name
|
||||
if (normalizedSelector === '*' || SIMPLE_TAG_NAME_REGEX.test(normalizedSelector)) {
|
||||
return _handleSimpleTagSelector(node, normalizedSelector, findFirst);
|
||||
}
|
||||
|
||||
// Attribute selector: tagname[attr="value"] or
|
||||
// tagname[attr1="value1"][attr2="value2"] (with optional wildcard namespace)
|
||||
if (normalizedSelector.match(MULTI_ATTRIBUTE_SELECTOR_REGEX)) {
|
||||
return _handleAttributeSelector(node, normalizedSelector, findFirst);
|
||||
}
|
||||
|
||||
// Unsupported selector - throw SyntaxError to match browser behavior
|
||||
throw new SyntaxError(`Failed to execute 'querySelector${
|
||||
findFirst ? '' : 'All'}' on 'Element': '${selector}' is not a valid selector.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements querySelector functionality using the shared internal logic.
|
||||
* Supports the same selectors as querySelectorAll but returns only the first match.
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selectors - The CSS selector to match elements against.
|
||||
* @returns {Element|null} - The first Element which matches the selector, or null.
|
||||
*/
|
||||
export function querySelector(node, selectors) {
|
||||
return _querySelectorInternal(node, selectors, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements querySelectorAll functionality using the shared internal logic.
|
||||
* Supports :scope pseudo-selector, direct child selectors, and common CSS selectors.
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selector - The CSS selector to match elements against.
|
||||
* @returns {Element[]} - Array of Elements matching the selector.
|
||||
*/
|
||||
export function querySelectorAll(node, selector) {
|
||||
return _querySelectorInternal(node, selector, false);
|
||||
}
|
||||
@@ -244,6 +244,27 @@ const Notification = ({
|
||||
window.open(supportUrl, '_blank', 'noopener');
|
||||
}, [ supportUrl ]);
|
||||
|
||||
const processCustomActions
|
||||
= (key?: string[], handler?: Function[], type?: string[]): {
|
||||
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
|
||||
if (key?.length && handler?.length) {
|
||||
return key.map((customAction: string, customActionIndex: number) => {
|
||||
return {
|
||||
content: t(customAction),
|
||||
onClick: () => {
|
||||
if (handler?.[customActionIndex]()) {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
type: type?.[customActionIndex],
|
||||
testId: customAction
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const mapAppearanceToButtons = useCallback((): {
|
||||
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
|
||||
switch (appearance) {
|
||||
@@ -262,7 +283,7 @@ const Notification = ({
|
||||
});
|
||||
}
|
||||
|
||||
return buttons;
|
||||
return processCustomActions(customActionNameKey, customActionHandler, customActionType).concat(buttons);
|
||||
}
|
||||
case NOTIFICATION_TYPE.WARNING:
|
||||
return [
|
||||
@@ -273,22 +294,7 @@ const Notification = ({
|
||||
];
|
||||
|
||||
default:
|
||||
if (customActionNameKey?.length && customActionHandler?.length) {
|
||||
return customActionNameKey.map((customAction: string, customActionIndex: number) => {
|
||||
return {
|
||||
content: t(customAction),
|
||||
onClick: () => {
|
||||
if (customActionHandler?.[customActionIndex]()) {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
type: customActionType?.[customActionIndex],
|
||||
testId: customAction
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
return processCustomActions(customActionNameKey, customActionHandler, customActionType);
|
||||
}
|
||||
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink, supportUrl ]);
|
||||
|
||||
|
||||
@@ -44,7 +44,14 @@ const useStyles = makeStyles()(theme => {
|
||||
right: 0,
|
||||
top: '-8px',
|
||||
transform: 'translateY(-100%)',
|
||||
width: '283px'
|
||||
width: '283px',
|
||||
|
||||
// Allow text in menu items to wrap to multiple lines.
|
||||
'& [role="button"] > div > span, & [role="menuitem"] > div > span': {
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
},
|
||||
|
||||
text: {
|
||||
|
||||
@@ -21,7 +21,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
|
||||
for (const key in pollsHistory) {
|
||||
if (pollsHistory.hasOwnProperty(key) && pollsHistory[key].saved) {
|
||||
dispatch(savePoll(key, pollsHistory[key]));
|
||||
dispatch(savePoll(pollsHistory[key]));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { IPoll } from '../polls/types';
|
||||
import { IPollData } from '../polls/types';
|
||||
|
||||
import { REMOVE_POLL_FROM_HISTORY, SAVE_POLL_IN_HISTORY } from './actionTypes';
|
||||
|
||||
@@ -11,7 +11,7 @@ const INITIAL_STATE = {
|
||||
export interface IPollsHistoryState {
|
||||
polls: {
|
||||
[meetingId: string]: {
|
||||
[pollId: string]: IPoll;
|
||||
[pollId: string]: IPollData;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export const EDIT_POLL = 'EDIT_POLL';
|
||||
* {
|
||||
* type: RECEIVE_POLL,
|
||||
* poll: Poll,
|
||||
* pollId: string,
|
||||
* notify: boolean
|
||||
* }
|
||||
*
|
||||
@@ -47,8 +46,7 @@ export const RECEIVE_POLL = 'RECEIVE_POLL';
|
||||
*
|
||||
* {
|
||||
* type: RECEIVE_ANSWER,
|
||||
* answer: Answer,
|
||||
* pollId: string,
|
||||
* answer: IIncomingAnswerData
|
||||
* }
|
||||
*/
|
||||
export const RECEIVE_ANSWER = 'RECEIVE_ANSWER';
|
||||
@@ -89,9 +87,7 @@ export const RESET_NB_UNREAD_POLLS = 'RESET_NB_UNREAD_POLLS';
|
||||
*
|
||||
* {
|
||||
* type: SAVE_POLL,
|
||||
* poll: Poll,
|
||||
* pollId: string,
|
||||
* saved: boolean
|
||||
* poll: IPollData
|
||||
* }
|
||||
*/
|
||||
export const SAVE_POLL = 'SAVE_POLL';
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
RESET_NB_UNREAD_POLLS,
|
||||
SAVE_POLL
|
||||
} from './actionTypes';
|
||||
import { IAnswer, IPoll } from './types';
|
||||
import { IIncomingAnswerData, IPoll, IPollData } from './types';
|
||||
|
||||
/**
|
||||
* Action to signal that existing polls needs to be cleared from state.
|
||||
@@ -47,7 +47,6 @@ export const setVoteChanging = (pollId: string, value: boolean) => {
|
||||
/**
|
||||
* Action to signal that a new poll was received.
|
||||
*
|
||||
* @param {string} pollId - The id of the incoming poll.
|
||||
* @param {IPoll} poll - The incoming Poll object.
|
||||
* @param {boolean} notify - Whether to send or not a notification.
|
||||
* @returns {{
|
||||
@@ -57,10 +56,9 @@ export const setVoteChanging = (pollId: string, value: boolean) => {
|
||||
* notify: boolean
|
||||
* }}
|
||||
*/
|
||||
export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
|
||||
export const receivePoll = (poll: IPoll, notify: boolean) => {
|
||||
return {
|
||||
type: RECEIVE_POLL,
|
||||
pollId,
|
||||
poll,
|
||||
notify
|
||||
};
|
||||
@@ -69,18 +67,15 @@ export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
|
||||
/**
|
||||
* Action to signal that a new answer was received.
|
||||
*
|
||||
* @param {string} pollId - The id of the incoming poll.
|
||||
* @param {IAnswer} answer - The incoming Answer object.
|
||||
* @param {IIncomingAnswerData} answer - The incoming Answer object.
|
||||
* @returns {{
|
||||
* type: RECEIVE_ANSWER,
|
||||
* pollId: string,
|
||||
* answer: IAnswer
|
||||
* answer: IIncomingAnswerData
|
||||
* }}
|
||||
*/
|
||||
export const receiveAnswer = (pollId: string, answer: IAnswer) => {
|
||||
export const receiveAnswer = (answer: IIncomingAnswerData) => {
|
||||
return {
|
||||
type: RECEIVE_ANSWER,
|
||||
pollId,
|
||||
answer
|
||||
};
|
||||
};
|
||||
@@ -120,19 +115,15 @@ export function resetNbUnreadPollsMessages() {
|
||||
/**
|
||||
* Action to signal saving a poll.
|
||||
*
|
||||
* @param {string} pollId - The id of the poll that gets to be saved.
|
||||
* @param {IPoll} poll - The Poll object that gets to be saved.
|
||||
* @param {IPollData} poll - The Poll object that gets to be saved.
|
||||
* @returns {{
|
||||
* type: SAVE_POLL,
|
||||
* meetingId: string,
|
||||
* pollId: string,
|
||||
* poll: IPoll
|
||||
* poll: IPollData
|
||||
* }}
|
||||
*/
|
||||
export function savePoll(pollId: string, poll: IPoll) {
|
||||
export function savePoll(poll: IPollData) {
|
||||
return {
|
||||
type: SAVE_POLL,
|
||||
pollId,
|
||||
poll
|
||||
};
|
||||
}
|
||||
@@ -159,18 +150,15 @@ export function editPoll(pollId: string, editing: boolean) {
|
||||
/**
|
||||
* Action to signal that existing polls needs to be removed.
|
||||
*
|
||||
* @param {string} pollId - The id of the poll that gets to be removed.
|
||||
* @param {IPoll} poll - The incoming Poll object.
|
||||
* @returns {{
|
||||
* type: REMOVE_POLL,
|
||||
* pollId: string,
|
||||
* poll: IPoll
|
||||
* }}
|
||||
*/
|
||||
export const removePoll = (pollId: string, poll: IPoll) => {
|
||||
export const removePoll = (poll: IPoll) => {
|
||||
return {
|
||||
type: REMOVE_POLL,
|
||||
pollId,
|
||||
poll
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,9 +8,8 @@ import { IReduxState } from '../../app/types';
|
||||
import { getParticipantDisplayName } from '../../base/participants/functions';
|
||||
import { useBoundSelector } from '../../base/util/hooks';
|
||||
import { registerVote, removePoll, setVoteChanging } from '../actions';
|
||||
import { COMMAND_ANSWER_POLL, COMMAND_NEW_POLL } from '../constants';
|
||||
import { getPoll } from '../functions';
|
||||
import { IPoll } from '../types';
|
||||
import { IPollData } from '../types';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of inheriting component.
|
||||
@@ -27,8 +26,7 @@ type InputProps = {
|
||||
export type AbstractProps = {
|
||||
checkBoxStates: boolean[];
|
||||
creatorName: string;
|
||||
poll: IPoll;
|
||||
pollId: string;
|
||||
poll: IPollData;
|
||||
sendPoll: () => void;
|
||||
setCheckbox: Function;
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
@@ -51,7 +49,7 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
|
||||
|
||||
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
|
||||
|
||||
const poll: IPoll = useSelector(getPoll(pollId));
|
||||
const poll: IPollData = useSelector(getPoll(pollId));
|
||||
|
||||
const { answers, lastVote, question, senderId } = poll;
|
||||
|
||||
@@ -76,11 +74,7 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const submitAnswer = useCallback(() => {
|
||||
conference?.sendMessage({
|
||||
type: COMMAND_ANSWER_POLL,
|
||||
pollId,
|
||||
answers: checkBoxStates
|
||||
});
|
||||
conference?.getPolls().answerPoll(pollId, checkBoxStates);
|
||||
|
||||
sendAnalytics(createPollEvent('vote.sent'));
|
||||
dispatch(registerVote(pollId, checkBoxStates));
|
||||
@@ -89,14 +83,9 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
|
||||
}, [ pollId, checkBoxStates, conference ]);
|
||||
|
||||
const sendPoll = useCallback(() => {
|
||||
conference?.sendMessage({
|
||||
type: COMMAND_NEW_POLL,
|
||||
pollId,
|
||||
question,
|
||||
answers: answers.map(answer => answer.name)
|
||||
});
|
||||
conference?.getPolls().createPoll(pollId, question, answers);
|
||||
|
||||
dispatch(removePoll(pollId, poll));
|
||||
dispatch(removePoll(poll));
|
||||
}, [ conference, question, answers ]);
|
||||
|
||||
const skipAnswer = useCallback(() => {
|
||||
@@ -114,7 +103,6 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
|
||||
checkBoxStates = { checkBoxStates }
|
||||
creatorName = { participantName }
|
||||
poll = { poll }
|
||||
pollId = { pollId }
|
||||
sendPoll = { sendPoll }
|
||||
setCheckbox = { setCheckbox }
|
||||
setCreateMode = { setCreateMode }
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IReduxState } from '../../app/types';
|
||||
import { getLocalParticipant } from '../../base/participants/functions';
|
||||
import { savePoll } from '../actions';
|
||||
import { hasIdenticalAnswers } from '../functions';
|
||||
import { IAnswerData, IPoll } from '../types';
|
||||
import { IAnswerData, IPollData } from '../types';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of inheriting component.
|
||||
@@ -26,7 +26,7 @@ type InputProps = {
|
||||
export type AbstractProps = InputProps & {
|
||||
addAnswer: (index?: number) => void;
|
||||
answers: Array<IAnswerData>;
|
||||
editingPoll: IPoll | undefined;
|
||||
editingPoll: IPollData | undefined;
|
||||
editingPollId: string | undefined;
|
||||
isSubmitDisabled: boolean;
|
||||
onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
|
||||
@@ -52,7 +52,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
|
||||
|
||||
const pollState = useSelector((state: IReduxState) => state['features/polls'].polls);
|
||||
|
||||
const editingPoll: [ string, IPoll ] | null = useMemo(() => {
|
||||
const editingPoll: [ string, IPollData ] | null = useMemo(() => {
|
||||
if (!pollState) {
|
||||
return null;
|
||||
}
|
||||
@@ -71,12 +71,10 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
|
||||
? editingPoll[1].answers
|
||||
: [
|
||||
{
|
||||
name: '',
|
||||
voters: []
|
||||
name: ''
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
voters: []
|
||||
name: ''
|
||||
} ];
|
||||
}, [ editingPoll ]);
|
||||
|
||||
@@ -104,8 +102,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
|
||||
sendAnalytics(createPollEvent('option.added'));
|
||||
newAnswers.splice(typeof i === 'number'
|
||||
? i : answers.length, 0, {
|
||||
name: '',
|
||||
voters: []
|
||||
name: ''
|
||||
});
|
||||
setAnswers(newAnswers);
|
||||
}, [ answers ]);
|
||||
@@ -140,7 +137,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = {
|
||||
dispatch(savePoll({
|
||||
changingVote: false,
|
||||
senderId: localParticipant?.id,
|
||||
showResults: false,
|
||||
@@ -148,14 +145,9 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
|
||||
question,
|
||||
answers: filteredAnswers,
|
||||
saved: true,
|
||||
editing: false
|
||||
};
|
||||
|
||||
if (editingPoll) {
|
||||
dispatch(savePoll(editingPoll[0], poll));
|
||||
} else {
|
||||
dispatch(savePoll(pollId, poll));
|
||||
}
|
||||
editing: false,
|
||||
pollId: editingPoll ? editingPoll[0] : pollId
|
||||
}));
|
||||
|
||||
sendAnalytics(createPollEvent('created'));
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getParticipantById, getParticipantDisplayName } from '../../base/partic
|
||||
import { useBoundSelector } from '../../base/util/hooks';
|
||||
import { setVoteChanging } from '../actions';
|
||||
import { getPoll } from '../functions';
|
||||
import { IPoll } from '../types';
|
||||
import { IAnswerData, IPollData, IVoterData } from '../types';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of inheriting component.
|
||||
@@ -23,11 +23,9 @@ type InputProps = {
|
||||
pollId: string;
|
||||
};
|
||||
|
||||
export type AnswerInfo = {
|
||||
name: string;
|
||||
export type AnswerInfo = IAnswerData & {
|
||||
percentage: number;
|
||||
voterCount: number;
|
||||
voters?: Array<{ id: string; name: string; } | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -38,6 +36,7 @@ export type AbstractProps = {
|
||||
changeVote: (e?: React.MouseEvent<HTMLButtonElement> | GestureResponderEvent) => void;
|
||||
creatorName: string;
|
||||
haveVoted: boolean;
|
||||
pollId: string;
|
||||
question: string;
|
||||
showDetails: boolean;
|
||||
t: Function;
|
||||
@@ -54,8 +53,8 @@ export type AbstractProps = {
|
||||
const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props: InputProps) => {
|
||||
const { pollId } = props;
|
||||
|
||||
const poll: IPoll = useSelector(getPoll(pollId));
|
||||
const participant = useBoundSelector(getParticipantById, poll.senderId);
|
||||
const poll: IPollData = useSelector(getPoll(pollId));
|
||||
const creatorName = useBoundSelector(getParticipantDisplayName, poll.senderId);
|
||||
const reduxState = useSelector((state: IReduxState) => state);
|
||||
|
||||
const [ showDetails, setShowDetails ] = useState(false);
|
||||
@@ -69,33 +68,27 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
|
||||
|
||||
// Getting every voters ID that participates to the poll
|
||||
for (const answer of poll.answers) {
|
||||
// checking if the voters is an array for supporting old structure model
|
||||
const voters: string[] = answer.voters.length ? answer.voters : Object.keys(answer.voters);
|
||||
|
||||
voters.forEach((voter: string) => allVoters.add(voter));
|
||||
answer.voters?.forEach(k => allVoters.add(k.id));
|
||||
}
|
||||
|
||||
return poll.answers.map(answer => {
|
||||
const nrOfVotersPerAnswer = answer.voters ? Object.keys(answer.voters).length : 0;
|
||||
const nrOfVotersPerAnswer = answer.voters?.length || 0;
|
||||
const percentage = allVoters.size > 0 ? Math.round(nrOfVotersPerAnswer / allVoters.size * 100) : 0;
|
||||
|
||||
let voters;
|
||||
|
||||
if (showDetails && answer.voters) {
|
||||
const answerVoters = answer.voters?.length ? [ ...answer.voters ] : Object.keys({ ...answer.voters });
|
||||
|
||||
voters = answerVoters.map(id => {
|
||||
return {
|
||||
id,
|
||||
name: getParticipantDisplayName(reduxState, id)
|
||||
};
|
||||
const voters = answer.voters?.reduce((acc, v) => {
|
||||
acc.push({
|
||||
id: v.id,
|
||||
name: getParticipantById(reduxState, v.id)
|
||||
? getParticipantDisplayName(reduxState, v.id) : v.name
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as Array<IVoterData>);
|
||||
|
||||
return {
|
||||
name: answer.name,
|
||||
percentage,
|
||||
voters,
|
||||
voters: voters,
|
||||
voterCount: nrOfVotersPerAnswer
|
||||
};
|
||||
});
|
||||
@@ -113,8 +106,9 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
|
||||
<Component
|
||||
answers = { answers }
|
||||
changeVote = { changeVote }
|
||||
creatorName = { participant ? participant.name : '' }
|
||||
creatorName = { creatorName }
|
||||
haveVoted = { poll.lastVote !== null }
|
||||
pollId = { pollId }
|
||||
question = { poll.question }
|
||||
showDetails = { showDetails }
|
||||
t = { t }
|
||||
|
||||
@@ -20,7 +20,6 @@ const PollAnswer = (props: AbstractProps) => {
|
||||
const {
|
||||
checkBoxStates,
|
||||
poll,
|
||||
pollId,
|
||||
sendPoll,
|
||||
setCheckbox,
|
||||
setCreateMode,
|
||||
@@ -46,7 +45,7 @@ const PollAnswer = (props: AbstractProps) => {
|
||||
</View>
|
||||
{
|
||||
pollSaved && <IconButton
|
||||
onPress = { () => dispatch(removePoll(pollId, poll)) }
|
||||
onPress = { () => dispatch(removePoll(poll)) }
|
||||
src = { IconCloseLarge } />
|
||||
}
|
||||
</View>
|
||||
@@ -79,7 +78,7 @@ const PollAnswer = (props: AbstractProps) => {
|
||||
labelKey = 'polls.answer.edit'
|
||||
onClick = { () => {
|
||||
setCreateMode(true);
|
||||
dispatch(editPoll(pollId, true));
|
||||
dispatch(editPoll(poll.pollId, true));
|
||||
} }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { SECONDARY } />
|
||||
|
||||
@@ -122,8 +122,7 @@ const PollCreate = (props: AbstractProps) => {
|
||||
maxLength = { CHAR_LIMIT }
|
||||
onChange = { name => setAnswer(index,
|
||||
{
|
||||
name,
|
||||
voters: []
|
||||
name
|
||||
}) }
|
||||
onKeyPress = { ev => onAnswerKeyDown(index, ev) }
|
||||
placeholder = { t('polls.create.answerPlaceholder', { index: index + 1 }) }
|
||||
|
||||
@@ -65,11 +65,11 @@ const PollResults = (props: AbstractProps) => {
|
||||
{ voters && voterCount > 0
|
||||
&& <View style = { resultsStyles.voters as ViewStyle }>
|
||||
{/* @ts-ignore */}
|
||||
{voters.map(({ id, name: voterName }) =>
|
||||
{voters.map(voter =>
|
||||
(<Text
|
||||
key = { id }
|
||||
key = { voter.id }
|
||||
style = { resultsStyles.voter as TextStyle }>
|
||||
{ voterName }
|
||||
{ voter.name }
|
||||
</Text>)
|
||||
)}
|
||||
</View>}
|
||||
|
||||
@@ -62,7 +62,6 @@ const PollAnswer = ({
|
||||
creatorName,
|
||||
checkBoxStates,
|
||||
poll,
|
||||
pollId,
|
||||
setCheckbox,
|
||||
setCreateMode,
|
||||
skipAnswer,
|
||||
@@ -77,12 +76,14 @@ const PollAnswer = ({
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<div
|
||||
className = { classes.container }
|
||||
id = { `poll-${poll.pollId}` }>
|
||||
{
|
||||
pollSaved && <Icon
|
||||
ariaLabel = { t('polls.closeButton') }
|
||||
className = { classes.closeBtn }
|
||||
onClick = { () => dispatch(removePoll(pollId, poll)) }
|
||||
onClick = { () => dispatch(removePoll(poll)) }
|
||||
role = 'button'
|
||||
src = { IconCloseLarge }
|
||||
tabIndex = { 0 } />
|
||||
@@ -104,6 +105,7 @@ const PollAnswer = ({
|
||||
<Checkbox
|
||||
checked = { checkBoxStates[index] }
|
||||
disabled = { poll.saved }
|
||||
id = { `poll-answer-checkbox-${poll.pollId}-${index}` }
|
||||
key = { index }
|
||||
label = { answer.name }
|
||||
onChange = { ev => setCheckbox(index, ev.target.checked) } />
|
||||
@@ -120,11 +122,11 @@ const PollAnswer = ({
|
||||
labelKey = { 'polls.answer.edit' }
|
||||
onClick = { () => {
|
||||
setCreateMode(true);
|
||||
dispatch(editPoll(pollId, true));
|
||||
dispatch(editPoll(poll.pollId, true));
|
||||
} }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.answer.send') }
|
||||
accessibilityLabel = { t('polls.create.accessibilityLabel.send') }
|
||||
labelKey = { 'polls.answer.send' }
|
||||
onClick = { sendPoll } />
|
||||
</> : <>
|
||||
|
||||
@@ -223,8 +223,7 @@ const PollCreate = ({
|
||||
label = { t('polls.create.pollOption', { index: i + 1 }) }
|
||||
maxLength = { CHAR_LIMIT }
|
||||
onChange = { name => setAnswer(i, {
|
||||
name,
|
||||
voters: []
|
||||
name
|
||||
}) }
|
||||
onKeyPress = { ev => onAnswerKeyDown(i, ev) }
|
||||
placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) }
|
||||
@@ -235,6 +234,7 @@ const PollCreate = ({
|
||||
{ answers.length > 2
|
||||
&& <button
|
||||
className = { classes.removeOption }
|
||||
data-testid = { `remove-polls-answer-input-${i}` }
|
||||
onClick = { () => removeAnswer(i) }
|
||||
type = 'button'>
|
||||
{ t('polls.create.removeOption') }
|
||||
|
||||
@@ -113,6 +113,7 @@ const PollResults = ({
|
||||
changeVote,
|
||||
creatorName,
|
||||
haveVoted,
|
||||
pollId,
|
||||
showDetails,
|
||||
question,
|
||||
t,
|
||||
@@ -121,7 +122,9 @@ const PollResults = ({
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<div
|
||||
className = { classes.container }
|
||||
id = { `poll-${pollId}` }>
|
||||
<div className = { classes.header }>
|
||||
<div className = { classes.question }>
|
||||
{question}
|
||||
@@ -136,7 +139,9 @@ const PollResults = ({
|
||||
<div className = { classes.answerName }>
|
||||
{name}
|
||||
</div>
|
||||
<div className = { classes.answerResultContainer }>
|
||||
<div
|
||||
className = { classes.answerResultContainer }
|
||||
id = { `poll-result-${pollId}-${index}` }>
|
||||
<span className = { classes.barContainer }>
|
||||
<div
|
||||
className = { classes.bar }
|
||||
@@ -148,8 +153,8 @@ const PollResults = ({
|
||||
</div>
|
||||
{showDetails && voters && voterCount > 0
|
||||
&& <ul className = { classes.voters }>
|
||||
{voters.map(voter =>
|
||||
<li key = { voter?.id }>{voter?.name}</li>
|
||||
{ voters.map(voter =>
|
||||
<li key = { voter.id }>{ voter.name }</li>
|
||||
)}
|
||||
</ul>}
|
||||
</li>)
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
export const COMMAND_NEW_POLL = 'new-poll';
|
||||
export const COMMAND_ANSWER_POLL = 'answer-poll';
|
||||
export const COMMAND_OLD_POLLS = 'old-polls';
|
||||
|
||||
export const CHAR_LIMIT = 500;
|
||||
export const ANSWERS_LIMIT = 255;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
import { IAnswerData } from './types';
|
||||
|
||||
@@ -72,6 +73,10 @@ export function hasIdenticalAnswers(currentAnswers: Array<IAnswerData>): boolean
|
||||
* @returns {boolean} - Returns true if the participant is not allowed to create polls.
|
||||
*/
|
||||
export function isCreatePollDisabled(state: IReduxState) {
|
||||
if (iAmVisitor(state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { pollCreationRequiresPermission } = state['features/dynamic-branding'];
|
||||
|
||||
if (!pollCreationRequiresPermission) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { ENDPOINT_MESSAGE_RECEIVED, NON_PARTICIPANT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import { getParticipantById, getParticipantDisplayName } from '../base/participants/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { playSound } from '../base/sounds/actions';
|
||||
@@ -11,13 +12,7 @@ import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/c
|
||||
|
||||
import { RECEIVE_POLL } from './actionTypes';
|
||||
import { clearPolls, receiveAnswer, receivePoll } from './actions';
|
||||
import {
|
||||
COMMAND_ANSWER_POLL,
|
||||
COMMAND_NEW_POLL,
|
||||
COMMAND_OLD_POLLS
|
||||
} from './constants';
|
||||
import logger from './logger';
|
||||
import { IAnswer, IPoll, IPollData } from './types';
|
||||
import { IIncomingAnswerData } from './types';
|
||||
|
||||
/**
|
||||
* The maximum number of answers a poll can have.
|
||||
@@ -28,75 +23,29 @@ const MAX_ANSWERS = 32;
|
||||
* Set up state change listener to perform maintenance tasks when the conference
|
||||
* is left or failed, e.g. Clear messages or close the chat modal if it's left
|
||||
* open.
|
||||
* When joining new conference set up the listeners for polls.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => getCurrentConference(state),
|
||||
(conference, { dispatch }, previousConference): void => {
|
||||
(conference, { dispatch, getState }, previousConference): void => {
|
||||
if (conference !== previousConference) {
|
||||
dispatch(clearPolls());
|
||||
|
||||
if (conference && !previousConference) {
|
||||
conference.on(JitsiConferenceEvents.POLL_RECEIVED, (data: any) => {
|
||||
_handleReceivedPollsData(data, dispatch, getState);
|
||||
});
|
||||
conference.on(JitsiConferenceEvents.POLL_ANSWER_RECEIVED, (data: any) => {
|
||||
_handleReceivedPollsAnswer(data, dispatch, getState);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsePollData = (pollData: Partial<IPollData>): IPoll | null => {
|
||||
if (typeof pollData !== 'object' || pollData === null) {
|
||||
return null;
|
||||
}
|
||||
const { id, senderId, question, answers } = pollData;
|
||||
|
||||
if (typeof id !== 'string' || typeof senderId !== 'string'
|
||||
|| typeof question !== 'string' || !(answers instanceof Array)) {
|
||||
logger.error('Malformed poll data received:', pollData);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate answers.
|
||||
if (answers.some(answer => typeof answer !== 'string')) {
|
||||
logger.error('Malformed answers data received:', answers);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
changingVote: false,
|
||||
senderId,
|
||||
question,
|
||||
showResults: true,
|
||||
lastVote: null,
|
||||
answers,
|
||||
saved: false,
|
||||
editing: false
|
||||
};
|
||||
};
|
||||
|
||||
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case ENDPOINT_MESSAGE_RECEIVED: {
|
||||
const { participant, data } = action;
|
||||
const isNewPoll = data.type === COMMAND_NEW_POLL;
|
||||
|
||||
_handleReceivePollsMessage({
|
||||
...data,
|
||||
senderId: isNewPoll ? participant.getId() : undefined,
|
||||
voterId: isNewPoll ? undefined : participant.getId()
|
||||
}, dispatch, getState);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case NON_PARTICIPANT_MESSAGE_RECEIVED: {
|
||||
const { id, json: data } = action;
|
||||
const isNewPoll = data.type === COMMAND_NEW_POLL;
|
||||
|
||||
_handleReceivePollsMessage({
|
||||
...data,
|
||||
senderId: isNewPoll ? id : undefined,
|
||||
voterId: isNewPoll ? undefined : id
|
||||
}, dispatch, getState);
|
||||
break;
|
||||
}
|
||||
|
||||
case RECEIVE_POLL: {
|
||||
const state = getState();
|
||||
@@ -120,7 +69,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles receiving of polls message command.
|
||||
* Handles receiving of new or history polls to load.
|
||||
*
|
||||
* @param {Object} data - The json data carried by the polls message.
|
||||
* @param {Function} dispatch - The dispatch function.
|
||||
@@ -128,82 +77,58 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleReceivePollsMessage(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
function _handleReceivedPollsData(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
if (arePollsDisabled(getState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
const { pollId, answers, senderId, question, history } = data;
|
||||
const poll = {
|
||||
changingVote: false,
|
||||
senderId,
|
||||
showResults: false,
|
||||
lastVote: null,
|
||||
question,
|
||||
answers: answers.slice(0, MAX_ANSWERS),
|
||||
saved: false,
|
||||
editing: false,
|
||||
pollId
|
||||
};
|
||||
|
||||
case COMMAND_NEW_POLL: {
|
||||
const { pollId, answers, senderId, question } = data;
|
||||
const tmp = {
|
||||
id: pollId,
|
||||
answers,
|
||||
question,
|
||||
senderId
|
||||
};
|
||||
dispatch(receivePoll(poll, !history));
|
||||
|
||||
// Check integrity of the poll data.
|
||||
// TODO(saghul): we should move this to the server side, likely by storing the
|
||||
// poll data in the room metadata.
|
||||
if (parsePollData(tmp) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = {
|
||||
changingVote: false,
|
||||
senderId,
|
||||
showResults: false,
|
||||
lastVote: null,
|
||||
question,
|
||||
answers: answers.map((answer: string) => {
|
||||
return {
|
||||
name: answer,
|
||||
voters: []
|
||||
};
|
||||
}).slice(0, MAX_ANSWERS),
|
||||
saved: false,
|
||||
editing: false
|
||||
};
|
||||
|
||||
dispatch(receivePoll(pollId, poll, true));
|
||||
if (!history) {
|
||||
dispatch(showNotification({
|
||||
appearance: NOTIFICATION_TYPE.NORMAL,
|
||||
titleKey: 'polls.notification.title',
|
||||
descriptionKey: 'polls.notification.description'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
case COMMAND_ANSWER_POLL: {
|
||||
const { pollId, answers, voterId } = data;
|
||||
|
||||
const receivedAnswer: IAnswer = {
|
||||
voterId,
|
||||
pollId,
|
||||
answers: answers.slice(0, MAX_ANSWERS).map(Boolean)
|
||||
};
|
||||
|
||||
dispatch(receiveAnswer(pollId, receivedAnswer));
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
case COMMAND_OLD_POLLS: {
|
||||
const { polls } = data;
|
||||
|
||||
for (const pollData of polls) {
|
||||
const poll = parsePollData(pollData);
|
||||
|
||||
if (poll === null) {
|
||||
logger.warn('Malformed old poll data', pollData);
|
||||
} else {
|
||||
dispatch(receivePoll(pollData.id, poll, false));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles receiving of pools answers.
|
||||
*
|
||||
* @param {Object} data - The json data carried by the polls message.
|
||||
* @param {Function} dispatch - The dispatch function.
|
||||
* @param {Function} getState - The getState function.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleReceivedPollsAnswer(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
if (arePollsDisabled(getState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { pollId, answers, senderId, senderName } = data;
|
||||
|
||||
const receivedAnswer: IIncomingAnswerData = {
|
||||
answers: answers.slice(0, MAX_ANSWERS).map(Boolean),
|
||||
pollId,
|
||||
senderId,
|
||||
voterName: getParticipantById(getState(), senderId)
|
||||
? getParticipantDisplayName(getState(), senderId) : senderName
|
||||
};
|
||||
|
||||
dispatch(receiveAnswer(receivedAnswer));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
RESET_NB_UNREAD_POLLS,
|
||||
SAVE_POLL
|
||||
} from './actionTypes';
|
||||
import { IAnswer, IPoll } from './types';
|
||||
import { IIncomingAnswerData, IPollData } from './types';
|
||||
|
||||
const INITIAL_STATE = {
|
||||
polls: {},
|
||||
@@ -23,7 +23,7 @@ const INITIAL_STATE = {
|
||||
export interface IPollsState {
|
||||
nbUnreadPolls: number;
|
||||
polls: {
|
||||
[pollId: string]: IPoll;
|
||||
[pollId: string]: IPollData;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[action.pollId]: action.poll
|
||||
[action.poll.pollId]: action.poll
|
||||
},
|
||||
nbUnreadPolls: state.nbUnreadPolls + 1
|
||||
};
|
||||
@@ -72,7 +72,7 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[action.pollId]: action.poll
|
||||
[action.poll.pollId]: action.poll
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,9 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
// The answer is added to an existing poll
|
||||
case RECEIVE_ANSWER: {
|
||||
|
||||
const { pollId, answer }: { answer: IAnswer; pollId: string; } = action;
|
||||
const { answer }: { answer: IIncomingAnswerData; } = action;
|
||||
const pollId = answer.pollId;
|
||||
const poll = state.polls[pollId];
|
||||
|
||||
// if the poll doesn't exist
|
||||
if (!(pollId in state.polls)) {
|
||||
@@ -91,33 +93,22 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
}
|
||||
|
||||
// if the poll exists, we update it with the incoming answer
|
||||
const newAnswers = state.polls[pollId].answers
|
||||
.map(_answer => {
|
||||
// checking if the voters is an array for supporting old structure model
|
||||
const answerVoters = _answer.voters
|
||||
? _answer.voters.length
|
||||
? [ ..._answer.voters ] : Object.keys(_answer.voters) : [];
|
||||
|
||||
return {
|
||||
name: _answer.name,
|
||||
voters: answerVoters
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
for (let i = 0; i < newAnswers.length; i++) {
|
||||
for (let i = 0; i < poll.answers.length; i++) {
|
||||
// if the answer was chosen, we add the senderId to the array of voters of this answer
|
||||
const voters = newAnswers[i].voters as any;
|
||||
let voters = poll.answers[i].voters || [];
|
||||
|
||||
const index = voters.indexOf(answer.voterId);
|
||||
|
||||
if (answer.answers[i]) {
|
||||
if (index === -1) {
|
||||
voters.push(answer.voterId);
|
||||
if (voters.find(user => user.id === answer.senderId)) {
|
||||
if (!answer.answers[i]) {
|
||||
voters = voters.filter(user => user.id !== answer.senderId);
|
||||
}
|
||||
} else if (index > -1) {
|
||||
voters.splice(index, 1);
|
||||
} else if (answer.answers[i]) {
|
||||
voters.push({
|
||||
id: answer.senderId,
|
||||
name: answer.voterName
|
||||
});
|
||||
}
|
||||
|
||||
poll.answers[i].voters = voters?.length ? voters : undefined;
|
||||
}
|
||||
|
||||
// finally we update the state by returning the updated poll
|
||||
@@ -126,8 +117,8 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
polls: {
|
||||
...state.polls,
|
||||
[pollId]: {
|
||||
...state.polls[pollId],
|
||||
answers: newAnswers
|
||||
...poll,
|
||||
answers: [ ...poll.answers ]
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -179,7 +170,7 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [action.pollId]: _removedPoll, ...newState } = state.polls;
|
||||
const { [action.poll.pollId]: _removedPoll, ...newState } = state.polls;
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface IAnswer {
|
||||
|
||||
/**
|
||||
* TODO: move to ljm.
|
||||
*/
|
||||
export interface IIncomingAnswer {
|
||||
/**
|
||||
* An array of boolean: true if the answer was chosen by the responder, else false.
|
||||
*/
|
||||
@@ -11,16 +13,24 @@ export interface IAnswer {
|
||||
pollId: string;
|
||||
|
||||
/**
|
||||
* ID of the voter for this answer.
|
||||
* ID of the sender of this answer.
|
||||
*/
|
||||
voterId: string;
|
||||
senderId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension of IIncomingAnswer with UI only fields.
|
||||
*/
|
||||
export interface IIncomingAnswerData extends IIncomingAnswer {
|
||||
/**
|
||||
* Name of the voter for this answer.
|
||||
*/
|
||||
voterName?: string;
|
||||
voterName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: move to ljm and use it from there.
|
||||
*/
|
||||
export interface IPoll {
|
||||
|
||||
/**
|
||||
@@ -30,7 +40,27 @@ export interface IPoll {
|
||||
answers: Array<IAnswerData>;
|
||||
|
||||
/**
|
||||
* Whether the poll vote is being edited/changed.
|
||||
* The unique ID of this poll.
|
||||
*/
|
||||
pollId: string;
|
||||
|
||||
/**
|
||||
* The question asked by this poll.
|
||||
*/
|
||||
question: string;
|
||||
|
||||
/**
|
||||
* ID of the sender of this poll.
|
||||
*/
|
||||
senderId: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension of IPoll with UI only fields.
|
||||
*/
|
||||
export interface IPollData extends IPoll {
|
||||
/**
|
||||
* Whether the poll vote is being edited/changed. UI only, not stored on the backend.
|
||||
*/
|
||||
changingVote: boolean;
|
||||
|
||||
@@ -46,30 +76,35 @@ export interface IPoll {
|
||||
lastVote: Array<boolean> | null;
|
||||
|
||||
/**
|
||||
* The question asked by this poll.
|
||||
*/
|
||||
question: string;
|
||||
|
||||
/**
|
||||
* Whether poll is saved or not?.
|
||||
* Whether poll is saved or not?. UI only, not stored on the backend.
|
||||
*/
|
||||
saved: boolean;
|
||||
|
||||
/**
|
||||
* ID of the sender of this poll.
|
||||
*/
|
||||
senderId: string | undefined;
|
||||
|
||||
/**
|
||||
* Whether the results should be shown instead of the answer form.
|
||||
* UI only, not stored on the backend.
|
||||
*/
|
||||
showResults: boolean;
|
||||
}
|
||||
|
||||
export interface IPollData extends IPoll {
|
||||
/**
|
||||
* TODO: move to ljm and use it from there.
|
||||
*/
|
||||
export interface IVoterData {
|
||||
/**
|
||||
* The id of the voter.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Voter name if voter is not in the meeting.
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: move to ljm and use it from there.
|
||||
*/
|
||||
export interface IAnswerData {
|
||||
|
||||
/**
|
||||
@@ -80,5 +115,5 @@ export interface IAnswerData {
|
||||
/**
|
||||
* An array of voters.
|
||||
*/
|
||||
voters: Array<string>;
|
||||
voters?: Array<IVoterData>;
|
||||
}
|
||||
|
||||
@@ -33,12 +33,14 @@ import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { openDisplayNamePrompt } from '../../../display-name/actions';
|
||||
import BrandingImageBackground from '../../../dynamic-branding/components/native/BrandingImageBackground';
|
||||
import LargeVideo from '../../../large-video/components/LargeVideo.native';
|
||||
import { getLobbyConfig } from '../../../lobby/functions';
|
||||
import HeaderNavigationButton from '../../../mobile/navigation/components/HeaderNavigationButton';
|
||||
import { navigateRoot } from '../../../mobile/navigation/rootNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import HangupButton from '../../../toolbox/components/HangupButton';
|
||||
import AudioMuteButton from '../../../toolbox/components/native/AudioMuteButton';
|
||||
import VideoMuteButton from '../../../toolbox/components/native/VideoMuteButton';
|
||||
import { isDisplayNameRequired, isRoomNameEnabled } from '../../functions';
|
||||
import { isDisplayNameRequired, isRoomNameEnabled } from '../../functions.native';
|
||||
import { IPrejoinProps } from '../../types';
|
||||
import { hasDisplayName } from '../../utils';
|
||||
|
||||
@@ -59,6 +61,8 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
|
||||
const isDisplayNameReadonly = useSelector(isNameReadOnly);
|
||||
const roomName = useSelector((state: IReduxState) => getConferenceName(state));
|
||||
const roomNameEnabled = useSelector((state: IReduxState) => isRoomNameEnabled(state));
|
||||
const { showHangUp = true } = useSelector((state: IReduxState) => getLobbyConfig(state));
|
||||
const { knocking } = useSelector((state: IReduxState) => state['features/lobby']);
|
||||
const participantName = localParticipant?.name;
|
||||
const [ displayName, setDisplayName ]
|
||||
= useState(participantName || '');
|
||||
@@ -185,6 +189,11 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
|
||||
styles = { styles.buttonStylesBorderless } />
|
||||
<VideoMuteButton
|
||||
styles = { styles.buttonStylesBorderless } />
|
||||
{
|
||||
showHangUp && knocking
|
||||
&& <HangupButton
|
||||
styles = { styles.buttonStylesBorderless } />
|
||||
}
|
||||
</View>
|
||||
{
|
||||
showDisplayNameInput && <Input
|
||||
|
||||
@@ -74,8 +74,8 @@ export const preJoinStyles = {
|
||||
height: 60,
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: BaseTheme.spacing[3],
|
||||
paddingHorizontal: BaseTheme.spacing[2],
|
||||
width: 148
|
||||
padding: BaseTheme.spacing[2],
|
||||
width: 'auto'
|
||||
},
|
||||
|
||||
customInput: {
|
||||
|
||||
@@ -2,10 +2,8 @@ import JitsiMeetJS, { RTCStatsEvents } from '../base/lib-jitsi-meet';
|
||||
|
||||
import logger from './logger';
|
||||
import {
|
||||
DominantSpeakerData,
|
||||
E2ERTTData,
|
||||
FaceLandmarksData,
|
||||
VideoTypeData
|
||||
FaceLandmarksData
|
||||
} from './types';
|
||||
|
||||
// TODO(saghul): expose these in libn-jitsi-meet?
|
||||
@@ -46,16 +44,6 @@ class RTCStats {
|
||||
JitsiMeetJS.rtcstats.sendStatsEntry('logs', logEntries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send dominant speaker data, the data will be processed by rtcstats-server and saved in the dump file.
|
||||
*
|
||||
* @param {Object} dominantSpeakerData - Dominant speaker data to be saved in the rtcstats dump.
|
||||
* @returns {void}
|
||||
*/
|
||||
sendDominantSpeakerData(dominantSpeakerData: DominantSpeakerData) {
|
||||
JitsiMeetJS.rtcstats.sendStatsEntry('dominantSpeaker', dominantSpeakerData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send e2e rtt data, the data will be processed by rtcstats-server and saved in the dump file.
|
||||
*
|
||||
@@ -87,16 +75,6 @@ class RTCStats {
|
||||
JitsiMeetJS.rtcstats.sendStatsEntry('conferenceStartTimestamp', timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send videoType data, the data will be processed by rtcstats-server and saved in the dump file.
|
||||
*
|
||||
* @param {Object} videoTypeData - The object that holds the videoType data.
|
||||
* @returns {void}
|
||||
*/
|
||||
sendVideoTypeData(videoTypeData: VideoTypeData) {
|
||||
JitsiMeetJS.rtcstats.sendStatsEntry('setVideoType', videoTypeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send face landmarks data, the data will be processed by rtcstats-server and saved in the dump file.
|
||||
*
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
CONFERENCE_JOINED,
|
||||
E2E_RTT_CHANGED
|
||||
} from '../base/conference/actionTypes';
|
||||
import { DOMINANT_SPEAKER_CHANGED } from '../base/participants/actionTypes';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { TRACK_ADDED, TRACK_UPDATED } from '../base/tracks/actionTypes';
|
||||
import { TRACK_UPDATED } from '../base/tracks/actionTypes';
|
||||
import { ADD_FACE_LANDMARKS } from '../face-landmarks/actionTypes';
|
||||
import { FaceLandmarks } from '../face-landmarks/types';
|
||||
import { sendGetCustomerIdRequest } from '../jaas/functions';
|
||||
@@ -47,25 +46,10 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TRACK_ADDED: {
|
||||
if (isRTCStatsEnabled(state)) {
|
||||
const jitsiTrack = action?.track?.jitsiTrack;
|
||||
const { ssrc, videoType } = jitsiTrack || { };
|
||||
|
||||
// Remote tracks store their ssrc in the jitsiTrack object. Local tracks don't. See getSsrcByTrack.
|
||||
if (videoType && ssrc && !jitsiTrack.isLocal() && !jitsiTrack.isAudioTrack()) {
|
||||
RTCStats.sendVideoTypeData({
|
||||
ssrc,
|
||||
videoType
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TRACK_UPDATED: {
|
||||
if (isRTCStatsEnabled(state)) {
|
||||
const { videoType, jitsiTrack, muted } = action?.track || { };
|
||||
const { ssrc, isLocal, videoType: trackVideoType, conference } = jitsiTrack || { };
|
||||
const { jitsiTrack, muted } = action?.track || { };
|
||||
const { isLocal, videoType: trackVideoType, conference } = jitsiTrack || { };
|
||||
|
||||
if (trackVideoType === 'camera' && conference && isLocal()) {
|
||||
RTCStats.sendFaceLandmarksData({
|
||||
@@ -74,27 +58,6 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// if the videoType of the remote track has changed we expect to find it in track.videoType. grep for
|
||||
// trackVideoTypeChanged.
|
||||
if (videoType && ssrc && !jitsiTrack.isLocal() && !jitsiTrack.isAudioTrack()) {
|
||||
|
||||
RTCStats.sendVideoTypeData({
|
||||
ssrc,
|
||||
videoType
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DOMINANT_SPEAKER_CHANGED: {
|
||||
if (isRTCStatsEnabled(state)) {
|
||||
const { id, previousSpeakers, silence } = action.participant;
|
||||
|
||||
RTCStats.sendDominantSpeakerData({
|
||||
dominantSpeakerEndpoint: silence ? null : id,
|
||||
previousSpeakers
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
export type VideoTypeData = {
|
||||
ssrc: number;
|
||||
videoType: string;
|
||||
};
|
||||
|
||||
export type DominantSpeakerData = {
|
||||
dominantSpeakerEndpoint: string;
|
||||
previousSpeakers: string[];
|
||||
};
|
||||
|
||||
// Types for RTC stats entries.
|
||||
export type E2ERTTData = {
|
||||
remoteEndpointId: string;
|
||||
remoteRegion: string;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { IStore } from '../app/types';
|
||||
import { setTokenAuthUrlSuccess } from '../authentication/actions.web';
|
||||
import { isTokenAuthEnabled } from '../authentication/functions';
|
||||
import {
|
||||
setFollowMe,
|
||||
setFollowMeRecorder,
|
||||
setStartMutedPolicy,
|
||||
setStartReactionsMuted
|
||||
} from '../base/conference/actions';
|
||||
@@ -19,6 +17,7 @@ import { updateSettings } from '../base/settings/actions';
|
||||
import { IAudioSettings } from '../base/settings/reducer';
|
||||
import { getLocalVideoTrack } from '../base/tracks/functions.web';
|
||||
import { appendURLHashParam } from '../base/util/uri';
|
||||
import { setFollowMe, setFollowMeRecorder } from '../follow-me/actions';
|
||||
import { disableKeyboardShortcuts, enableKeyboardShortcuts } from '../keyboard-shortcuts/actions';
|
||||
import { toggleBackgroundEffect } from '../virtual-background/actions';
|
||||
import virtualBackgroundLogger from '../virtual-background/logger';
|
||||
|
||||
@@ -3,13 +3,12 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import {
|
||||
setFollowMe,
|
||||
setFollowMeRecorder,
|
||||
setStartMutedPolicy,
|
||||
setStartReactionsMuted
|
||||
} from '../../../base/conference/actions';
|
||||
import { updateSettings } from '../../../base/settings/actions';
|
||||
import Switch from '../../../base/ui/components/native/Switch';
|
||||
import { setFollowMe, setFollowMeRecorder } from '../../../follow-me/actions';
|
||||
import { getModeratorTabProps } from '../../functions.native';
|
||||
|
||||
import FormRow from './FormRow';
|
||||
|
||||
@@ -151,12 +151,11 @@ export function getModeratorTabProps(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const {
|
||||
conference,
|
||||
followMeEnabled,
|
||||
followMeRecorderEnabled,
|
||||
startAudioMutedPolicy,
|
||||
startVideoMutedPolicy,
|
||||
startReactionsMuted
|
||||
} = state['features/base/conference'];
|
||||
const { followMeEnabled, followMeRecorderEnabled } = state['features/follow-me'];
|
||||
const { groupChatWithPermissions } = state['features/chat'];
|
||||
const { disableReactionsModeration } = state['features/base/config'];
|
||||
const followMeActive = isFollowMeActive(state);
|
||||
|
||||
@@ -228,7 +228,7 @@ class Whiteboard extends PureComponent<IProps> {
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated
|
||||
* {@code WaitForOwnerDialog}'s props.
|
||||
* {@code Whiteboard}'s props.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @private
|
||||
|
||||
@@ -55,25 +55,35 @@ Setting up configuration for the main prosody is a manual process:
|
||||
s2sout_override = {
|
||||
["conference.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
|
||||
["v1.meet.jitsi"] = "tcp://127.0.0.1:52691"; -- needed for v1.meet.jitsi->visitors.jitmeet.example.com
|
||||
["polls.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
|
||||
["conference.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
|
||||
["v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
|
||||
["polls.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
|
||||
["conference.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
|
||||
["v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
|
||||
["polls.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
|
||||
["conference.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
|
||||
["v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
|
||||
["polls.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
|
||||
["conference.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
|
||||
["v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
|
||||
["polls.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
|
||||
["conference.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
|
||||
["v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
|
||||
["polls.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
|
||||
["conference.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
|
||||
["v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
|
||||
["polls.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
|
||||
["conference.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
|
||||
["v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
|
||||
["polls.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
|
||||
}
|
||||
-- allowed list of server-2-server connections
|
||||
s2s_whitelist = {
|
||||
"conference.v1.meet.jitsi", "conference.v2.meet.jitsi", "conference.v3.meet.jitsi", "conference.v4.meet.jitsi",
|
||||
"conference.v5.meet.jitsi", "conference.v6.meet.jitsi", "conference.v7.meet.jitsi", "conference.v8.meet.jitsi"
|
||||
"conference.v5.meet.jitsi", "conference.v6.meet.jitsi", "conference.v7.meet.jitsi", "conference.v8.meet.jitsi",
|
||||
'polls.v1.meet.jitsi', 'polls.v2.meet.jitsi', 'polls.v3.meet.jitsi', 'polls.v4.meet.jitsi',
|
||||
'polls.v5.meet.jitsi', 'polls.v6.meet.jitsi', 'polls.v7.meet.jitsi', 'polls.v8.meet.jitsi'
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -38,12 +38,14 @@ s2s_whitelist = {
|
||||
'conference.jitmeet.example.com', -- needed for visitors to send messages to main room
|
||||
'visitors.jitmeet.example.com'; -- needed for sending promotion request to visitors.jitmeet.example.com component
|
||||
'jitmeet.example.com'; -- unavailable presences back to main room
|
||||
'polls.jitmeet.example.com'; -- polls component
|
||||
};
|
||||
|
||||
s2sout_override = {
|
||||
["conference.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for visitors to send messages to main room
|
||||
["jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for the main room when connecting in to send main participants
|
||||
["visitors.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for sending promotion request to visitors.jitmeet.example.com component
|
||||
['polls.jitmeet.example.com'] = "tcp://127.0.0.1:5269"; -- polls component
|
||||
}
|
||||
|
||||
external_service_secret = '__turnSecret__';
|
||||
@@ -106,6 +108,7 @@ VirtualHost 'vX.meet.jitsi'
|
||||
'smacks';
|
||||
'jiconop';
|
||||
'conference_duration';
|
||||
'features_identity';
|
||||
}
|
||||
main_muc = 'conference.vX.meet.jitsi';
|
||||
|
||||
@@ -138,3 +141,5 @@ Component 'conference.vX.meet.jitsi' 'muc'
|
||||
};
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
Component 'polls.vX.meet.jitsi' 'polls_component'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#### room._data fields
|
||||
- allModerators - If is set to true, all authenticated participants are moderators. You need a custom module to set participants as moderators based on the field.
|
||||
- allowUnauthenticatedAccess - Option that works only with tenants. When it is false only jwt authenticated participants(visitors) are allowed in the meeting and their token(or sub claim for jaas) should match the one in the room. Default value when missing is true.
|
||||
- av_can_unmute - Default value is missing/true. If set to false, when the first moderator joining the room we enable AV moderation and disable the ability for participants to unmute themselves.
|
||||
- av_first_moderator_joined - When av_can_unmute is set to false, this flag is used to indicate that the first moderator has joined the room and AV moderation is enabled.
|
||||
- breakout_rooms - A table containing breakout rooms created in the main room. The keys are the JIDs of the breakout rooms, and the values are their subjects.
|
||||
|
||||
@@ -17,6 +17,7 @@ local filters = require 'util.filters';
|
||||
local array = require 'util.array';
|
||||
local set = require 'util.set';
|
||||
local json = require 'cjson.safe';
|
||||
local datetime = require 'util.datetime';
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
@@ -476,6 +477,46 @@ local function message_handler(event)
|
||||
end
|
||||
end
|
||||
|
||||
-- Receives history messages from the main prosody and adds them to the local history
|
||||
-- this happens before the first participant joins and that participant gets the history using the standard flow on join
|
||||
local function history_message_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
local delay_elem = stanza:get_child('delay', 'urn:xmpp:delay');
|
||||
if not delay_elem then
|
||||
return;
|
||||
end
|
||||
|
||||
-- now let's add it history, we do not use the event as we want to keep
|
||||
-- the delay element
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(
|
||||
jid.bare(stanza.attr.from):sub(1, -(main_domain:len() + 1))..local_domain));
|
||||
|
||||
if not room then
|
||||
return;
|
||||
end
|
||||
|
||||
if room:get_historylength() == 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
local history = room._history;
|
||||
if not history then history = {}; room._history = history; end
|
||||
|
||||
local history_stanza = st.clone(stanza);
|
||||
history_stanza.attr.to = '';
|
||||
|
||||
local node, host, resource = jid.split(room_jid_match_rewrite(history_stanza.attr.from));
|
||||
history_stanza.attr.from = jid.join(node, local_muc_domain, resource);
|
||||
-- the from in the delay extension is still the main prosody jid, not used for now
|
||||
local entry = { stanza = history_stanza, timestamp = datetime.parse(delay_elem.attr.stamp) };
|
||||
table.insert(history, entry);
|
||||
while #history > room:get_historylength() do table.remove(history, 1) end
|
||||
|
||||
return false;
|
||||
end
|
||||
module:hook('message/host', history_message_handler);
|
||||
|
||||
process_host_module(local_domain, function(host_module, host)
|
||||
host_module:hook('iq/host', stanza_handler, 10);
|
||||
host_module:hook('message/full', message_handler);
|
||||
@@ -698,6 +739,10 @@ local function iq_from_main_handler(event)
|
||||
local createdTimestamp = node.attr.createdTimestamp;
|
||||
room.created_timestamp = createdTimestamp and tonumber(createdTimestamp) or nil;
|
||||
|
||||
if node.attr.allowUnauthenticatedAccess then
|
||||
room._data.allowUnauthenticatedAccess = node.attr.allowUnauthenticatedAccess == 'true';
|
||||
end
|
||||
|
||||
if node.attr.lobby == 'true' then
|
||||
room._main_room_lobby_enabled = true;
|
||||
elseif node.attr.lobby == 'false' then
|
||||
@@ -749,6 +794,13 @@ local function iq_from_main_handler(event)
|
||||
end
|
||||
end
|
||||
|
||||
local pollsEl = node:get_child('polls');
|
||||
if pollsEl then
|
||||
local polls = json.decode(pollsEl:get_text());
|
||||
-- let's find is there a new poll
|
||||
module:fire_event('jitsi-polls-update', { room = room; polls = polls; });
|
||||
end
|
||||
|
||||
if fire_jicofo_unlock then
|
||||
-- everything is connected allow participants to join
|
||||
module:fire_event('jicofo-unlock-room', { room = room; fmuc_fired = true; });
|
||||
@@ -814,6 +866,36 @@ function route_s2s_stanza(event)
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('answer-poll', function(answerData)
|
||||
local room = answerData.room;
|
||||
local room_jid = room_jid_match_rewrite(jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain));
|
||||
|
||||
-- now send it to the main prosody
|
||||
local data = {
|
||||
answers = answerData.data.answers;
|
||||
command = 'answer-poll';
|
||||
pollId = answerData.pollId;
|
||||
roomJid = room_jid,
|
||||
senderId = answerData.voterId;
|
||||
senderName = answerData.voterName;
|
||||
type = 'polls';
|
||||
};
|
||||
|
||||
local data_str, error = json.encode(data);
|
||||
if not data_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
local stanza = st.message({
|
||||
from = module.host,
|
||||
to = 'polls.'..main_domain
|
||||
})
|
||||
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet"; roomJid = room_jid; })
|
||||
:text(data_str)
|
||||
:up();
|
||||
room:route_stanza(stanza);
|
||||
end);
|
||||
|
||||
-- routing to sessions in mod_s2s is -1 and -10, we want to hook before that to make sure to is correct
|
||||
-- or if we want to filter that stanza
|
||||
module:hook("route/remote", route_s2s_stanza, 10);
|
||||
|
||||
@@ -100,14 +100,14 @@ module:hook('muc-add-history', function(event)
|
||||
local session = sessions[occupant.jid];
|
||||
if session and session.jitsi_meet_context_user then
|
||||
if ignore_jwt_name then
|
||||
name = occupant:get_presence():get_child('nick', NICK_NS):get_text();
|
||||
name = occupant:get_presence():get_child_text('nick', NICK_NS);
|
||||
else
|
||||
name = session.jitsi_meet_context_user.name;
|
||||
end
|
||||
|
||||
source = 'token';
|
||||
else
|
||||
name = occupant:get_presence():get_child('nick', NICK_NS):get_text();
|
||||
name = occupant:get_presence():get_child_text('nick', NICK_NS);
|
||||
source = 'guest';
|
||||
end
|
||||
else
|
||||
|
||||
@@ -94,7 +94,7 @@ function module.add_host(host_module)
|
||||
|
||||
token_util = module:require "token/util".new(host_module);
|
||||
|
||||
if asapKeyServer then
|
||||
if asapKeyServer ~= "" then
|
||||
-- init token util with our asap keyserver
|
||||
token_util:set_asap_key_server(asapKeyServer)
|
||||
end
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
-- This module provides persistence for the "polls" feature,
|
||||
-- by keeping track of the state of polls in each room, and sending
|
||||
-- that state to new participants when they join.
|
||||
|
||||
local json = require 'cjson.safe';
|
||||
local st = require("util.stanza");
|
||||
local jid = require "util.jid";
|
||||
local util = module:require("util");
|
||||
local muc = module:depends("muc");
|
||||
|
||||
local NS_NICK = 'http://jabber.org/protocol/nick';
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
|
||||
local POLLS_LIMIT = 128;
|
||||
local POLL_PAYLOAD_LIMIT = 1024;
|
||||
|
||||
-- Logs a warning and returns true if a room does not
|
||||
-- have poll data associated with it.
|
||||
local function check_polls(room)
|
||||
if room.polls == nil then
|
||||
module:log("warn", "no polls data in room");
|
||||
return true;
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
--- Returns a table having occupant id and occupant name.
|
||||
--- If the id cannot be extracted from nick a nil value is returned
|
||||
--- if the occupant name cannot be extracted from presence the Fellow Jitster
|
||||
--- name is used
|
||||
local function get_occupant_details(occupant)
|
||||
if not occupant then
|
||||
return nil
|
||||
end
|
||||
local presence = occupant:get_presence();
|
||||
local occupant_name;
|
||||
if presence then
|
||||
occupant_name = presence:get_child("nick", NS_NICK) and presence:get_child("nick", NS_NICK):get_text() or 'Fellow Jitster';
|
||||
else
|
||||
occupant_name = 'Fellow Jitster'
|
||||
end
|
||||
local _, _, occupant_id = jid.split(occupant.nick)
|
||||
if not occupant_id then
|
||||
return nil
|
||||
end
|
||||
return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
|
||||
end
|
||||
|
||||
-- Sets up poll data in new rooms.
|
||||
module:hook("muc-room-created", function(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then return end
|
||||
module:log("debug", "setting up polls in room %s", room.jid);
|
||||
room.polls = {
|
||||
by_id = {};
|
||||
order = {};
|
||||
count = 0;
|
||||
};
|
||||
end);
|
||||
|
||||
-- Keeps track of the current state of the polls in each room,
|
||||
-- by listening to "new-poll" and "answer-poll" messages,
|
||||
-- and updating the room poll data accordingly.
|
||||
-- This mirrors the client-side poll update logic.
|
||||
module:hook('jitsi-endpoint-message-received', function(event)
|
||||
local data, error, occupant, room, origin, stanza
|
||||
= event.message, event.error, event.occupant, event.room, event.origin, event.stanza;
|
||||
|
||||
if not data or (data.type ~= "new-poll" and data.type ~= "answer-poll") then
|
||||
return;
|
||||
end
|
||||
|
||||
if string.len(event.raw_message) >= POLL_PAYLOAD_LIMIT then
|
||||
module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
|
||||
return true;
|
||||
end
|
||||
|
||||
if data.type == "new-poll" then
|
||||
if check_polls(room) then return end
|
||||
|
||||
local poll_creator = get_occupant_details(occupant)
|
||||
if not poll_creator then
|
||||
module:log("error", "Cannot retrieve poll creator id and name for %s from %s", occupant.jid, room.jid)
|
||||
return
|
||||
end
|
||||
|
||||
if room.polls.count >= POLLS_LIMIT then
|
||||
module:log("error", "Too many polls created in %s", room.jid)
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.polls.by_id[data.pollId] ~= nil then
|
||||
module:log("error", "Poll already exists: %s", data.pollId);
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.jitsiMetadata and room.jitsiMetadata.permissions
|
||||
and room.jitsiMetadata.permissions.pollCreationRestricted
|
||||
and not is_feature_allowed('create-polls', origin.jitsi_meet_context_features) then
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local answers = {}
|
||||
local compact_answers = {}
|
||||
for i, name in ipairs(data.answers) do
|
||||
table.insert(answers, { name = name, voters = {} });
|
||||
table.insert(compact_answers, { key = i, name = name});
|
||||
end
|
||||
|
||||
local poll = {
|
||||
id = data.pollId,
|
||||
sender_id = poll_creator.occupant_id,
|
||||
sender_name = poll_creator.occupant_name,
|
||||
question = data.question,
|
||||
answers = answers
|
||||
};
|
||||
|
||||
room.polls.by_id[data.pollId] = poll
|
||||
table.insert(room.polls.order, poll)
|
||||
room.polls.count = room.polls.count + 1;
|
||||
|
||||
local pollData = {
|
||||
event = event,
|
||||
room = room,
|
||||
poll = {
|
||||
pollId = data.pollId,
|
||||
senderId = poll_creator.occupant_id,
|
||||
senderName = poll_creator.occupant_name,
|
||||
question = data.question,
|
||||
answers = compact_answers
|
||||
}
|
||||
}
|
||||
module:fire_event("poll-created", pollData);
|
||||
elseif data.type == "answer-poll" then
|
||||
if check_polls(room) then return end
|
||||
|
||||
local poll = room.polls.by_id[data.pollId];
|
||||
if poll == nil then
|
||||
module:log("warn", "answering inexistent poll");
|
||||
return;
|
||||
end
|
||||
|
||||
local voter = get_occupant_details(occupant)
|
||||
if not voter then
|
||||
module:log("error", "Cannot retrieve voter id and name for %s from %s", occupant.jid, room.jid)
|
||||
return
|
||||
end
|
||||
|
||||
local answers = {};
|
||||
for vote_option_idx, vote_flag in ipairs(data.answers) do
|
||||
table.insert(answers, {
|
||||
key = vote_option_idx,
|
||||
value = vote_flag,
|
||||
name = poll.answers[vote_option_idx].name,
|
||||
});
|
||||
poll.answers[vote_option_idx].voters[voter.occupant_id] = vote_flag and voter.occupant_name or nil;
|
||||
end
|
||||
local answerData = {
|
||||
event = event,
|
||||
room = room,
|
||||
pollId = poll.id,
|
||||
voterName = voter.occupant_name,
|
||||
voterId = voter.occupant_id,
|
||||
answers = answers
|
||||
}
|
||||
module:fire_event("answer-poll", answerData);
|
||||
end
|
||||
end);
|
||||
|
||||
-- Sends the current poll state to new occupants after joining a room.
|
||||
module:hook("muc-occupant-joined", function(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then return end
|
||||
if room.polls == nil or #room.polls.order == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local data = {
|
||||
type = "old-polls",
|
||||
polls = {},
|
||||
};
|
||||
for i, poll in ipairs(room.polls.order) do
|
||||
data.polls[i] = {
|
||||
id = poll.id,
|
||||
senderId = poll.sender_id,
|
||||
senderName = poll.sender_name,
|
||||
question = poll.question,
|
||||
answers = poll.answers
|
||||
};
|
||||
end
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
local stanza = st.message({
|
||||
from = room.jid,
|
||||
to = event.occupant.jid
|
||||
})
|
||||
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
|
||||
:text(json_msg_str)
|
||||
:up();
|
||||
room:route_stanza(stanza);
|
||||
end);
|
||||
518
resources/prosody-plugins/mod_polls_component.lua
Normal file
518
resources/prosody-plugins/mod_polls_component.lua
Normal file
@@ -0,0 +1,518 @@
|
||||
-- This module provides persistence for the "polls" feature,
|
||||
-- by keeping track of the state of polls in each room, and sending
|
||||
-- that state to new participants when they join.
|
||||
|
||||
local it = require 'util.iterators';
|
||||
local json = require 'cjson.safe';
|
||||
local array = require 'util.array';
|
||||
local st = require("util.stanza");
|
||||
local jid = require "util.jid";
|
||||
local util = module:require("util");
|
||||
local muc = module:depends("muc");
|
||||
|
||||
local NS_NICK = 'http://jabber.org/protocol/nick';
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local table_compare = util.table_compare;
|
||||
|
||||
local POLLS_LIMIT = 128;
|
||||
local POLL_PAYLOAD_LIMIT = 1024;
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No muc_mapper_domain_base option set.');
|
||||
return;
|
||||
end
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
|
||||
-- this is the main virtual host of the main prosody that this vnode serves
|
||||
local main_domain = module:get_option_string('main_domain');
|
||||
-- only the visitor prosody has main_domain setting
|
||||
local is_visitor_prosody = main_domain ~= nil;
|
||||
|
||||
-- Logs a warning and returns true if a room does not
|
||||
-- have poll data associated with it.
|
||||
local function check_polls(room)
|
||||
if room.polls == nil then
|
||||
module:log("warn", "no polls data in room");
|
||||
return true;
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
local function validate_polls(data)
|
||||
if type(data) ~= 'table' then
|
||||
return false;
|
||||
end
|
||||
if data.type ~= 'polls' or type(data.pollId) ~= 'string' then
|
||||
return false;
|
||||
end
|
||||
if data.command ~= 'new-poll' and data.command ~= 'answer-poll' then
|
||||
return false;
|
||||
end
|
||||
if type(data.answers) ~= 'table' then
|
||||
return false;
|
||||
end
|
||||
|
||||
if data.command == "new-poll" then
|
||||
if type(data.question) ~= 'string' then
|
||||
return false;
|
||||
end
|
||||
|
||||
for _, answer in ipairs(data.answers) do
|
||||
if type(answer) ~= "table" or type(answer.name) ~= "string" then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
elseif data.command == "answer-poll" then
|
||||
for _, answer in ipairs(data.answers) do
|
||||
if type(answer) ~= "boolean" then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
--- Returns a table having occupant id and occupant name.
|
||||
--- If the id cannot be extracted from nick a nil value is returned same and for name
|
||||
local function get_occupant_details(occupant)
|
||||
if not occupant then
|
||||
return nil
|
||||
end
|
||||
local presence = occupant:get_presence();
|
||||
local occupant_name;
|
||||
if presence then
|
||||
occupant_name = presence:get_child_text('nick', NS_NICK);
|
||||
end
|
||||
local _, _, occupant_id = jid.split(occupant.nick)
|
||||
if not occupant_id then
|
||||
return nil
|
||||
end
|
||||
return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
|
||||
end
|
||||
|
||||
local function send_polls_message(room, data_str, to)
|
||||
local stanza = st.message({
|
||||
from = module.host,
|
||||
to = to
|
||||
})
|
||||
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
|
||||
:text(data_str)
|
||||
:up();
|
||||
room:route_stanza(stanza);
|
||||
end
|
||||
|
||||
local function send_polls_message_to_all(room, data_str)
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
-- in case of visitor node send only to visitors
|
||||
if not is_visitor_prosody or room_occupant.role == 'visitor' then
|
||||
send_polls_message(room, data_str, room_occupant.jid);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Keeps track of the current state of the polls in each room,
|
||||
-- by listening to "new-poll" and "answer-poll" messages,
|
||||
-- and updating the room poll data accordingly.
|
||||
-- This mirrors the client-side poll update logic.
|
||||
module:hook('message/host', function(event)
|
||||
local session, stanza = event.origin, event.stanza;
|
||||
|
||||
-- we are interested in all messages without a body that are not groupchat
|
||||
if stanza.attr.type == 'groupchat' or stanza:get_child('body') then
|
||||
return;
|
||||
end
|
||||
|
||||
local json_message = stanza:get_child('json-message', 'http://jitsi.org/jitmeet')
|
||||
or stanza:get_child('json-message');
|
||||
if not json_message then
|
||||
return;
|
||||
end
|
||||
|
||||
local room;
|
||||
if session.type == 's2sin' then
|
||||
if not json_message.attr.roomJid then
|
||||
module:log('warn', 'No room jid found in %s', stanza);
|
||||
return;
|
||||
end
|
||||
room = get_room_from_jid(room_jid_match_rewrite(json_message.attr.roomJid));
|
||||
else
|
||||
room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
end
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
return;
|
||||
end
|
||||
|
||||
local json_message_text = json_message:get_text();
|
||||
if string.len(json_message_text) >= POLL_PAYLOAD_LIMIT then
|
||||
module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
|
||||
return true;
|
||||
end
|
||||
|
||||
local data, error = json.decode(json_message_text);
|
||||
if error then
|
||||
module:log('error', 'Error decoding data error:%s Sender: %s to:%s', error, stanza.attr.from, stanza.attr.to);
|
||||
return true;
|
||||
end
|
||||
|
||||
if not data or (data.command ~= "new-poll" and data.command ~= "answer-poll") then
|
||||
return;
|
||||
end
|
||||
|
||||
if not validate_polls(data) then
|
||||
module:log('error', 'Invalid poll data. Sender: %s (%s)', stanza.attr.from, json_message_text);
|
||||
return true;
|
||||
end
|
||||
|
||||
local occupant_details;
|
||||
if session.type ~= 's2sin' then
|
||||
local occupant_jid = stanza.attr.from;
|
||||
occupant = room:get_occupant_by_real_jid(occupant_jid);
|
||||
if not occupant then
|
||||
module:log("error", "Occupant sending msg %s was not found in room %s", occupant_jid, room.jid)
|
||||
return;
|
||||
end
|
||||
occupant_details = get_occupant_details(occupant)
|
||||
if not occupant_details then
|
||||
module:log("error", "Cannot retrieve poll creator or voter id and name for %s from %s",
|
||||
occupant.jid, room.jid)
|
||||
return
|
||||
end
|
||||
else
|
||||
-- this is a message from a visitor prosody, we will trust it
|
||||
occupant_details = { occupant_id = data.senderId; occupant_name = data.senderName; };
|
||||
end
|
||||
|
||||
if data.command == "new-poll" then
|
||||
if is_visitor_prosody then
|
||||
module:log("error", "Poll cannot be created on visitor node.");
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll cannot be created by visitor node'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if check_polls(room) then return end
|
||||
|
||||
local poll_creator = occupant_details;
|
||||
|
||||
if room.polls.count >= POLLS_LIMIT then
|
||||
module:log("error", "Too many polls created in %s", room.jid)
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.polls.by_id[data.pollId] ~= nil then
|
||||
module:log("error", "Poll already exists: %s", data.pollId);
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.jitsiMetadata and room.jitsiMetadata.permissions
|
||||
and room.jitsiMetadata.permissions.pollCreationRestricted
|
||||
and not is_feature_allowed('create-polls', session.jitsi_meet_context_features) then
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local answers = {}
|
||||
local compact_answers = {}
|
||||
for i, a in ipairs(data.answers) do
|
||||
table.insert(answers, { name = a.name });
|
||||
table.insert(compact_answers, { key = i, name = a.name});
|
||||
end
|
||||
|
||||
local poll = {
|
||||
pollId = data.pollId,
|
||||
senderId = poll_creator.occupant_id,
|
||||
senderName = poll_creator.occupant_name,
|
||||
question = data.question,
|
||||
answers = answers
|
||||
};
|
||||
|
||||
room.polls.by_id[data.pollId] = poll
|
||||
table.insert(room.polls.order, poll)
|
||||
room.polls.count = room.polls.count + 1;
|
||||
|
||||
local pollData = {
|
||||
event = event,
|
||||
room = room,
|
||||
poll = {
|
||||
pollId = data.pollId,
|
||||
senderId = poll_creator.occupant_id,
|
||||
senderName = poll_creator.occupant_name,
|
||||
question = data.question,
|
||||
answers = compact_answers
|
||||
}
|
||||
}
|
||||
|
||||
-- now send message to all participants
|
||||
data.senderId = poll_creator.occupant_id;
|
||||
data.type = 'polls';
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
|
||||
module:context(jid.host(room.jid)):fire_event('poll-created', pollData);
|
||||
elseif data.command == "answer-poll" then
|
||||
if check_polls(room) then return end
|
||||
|
||||
local poll = room.polls.by_id[data.pollId];
|
||||
if poll == nil then
|
||||
module:log("warn", "answering inexistent poll %s", data.pollId);
|
||||
return;
|
||||
end
|
||||
|
||||
local voter = occupant_details;
|
||||
|
||||
local answers = {};
|
||||
for vote_option_idx, vote_flag in ipairs(data.answers) do
|
||||
local answer = poll.answers[vote_option_idx]
|
||||
|
||||
table.insert(answers, {
|
||||
key = vote_option_idx,
|
||||
value = vote_flag,
|
||||
name = answer.name,
|
||||
});
|
||||
|
||||
if vote_flag then
|
||||
local voters = answer.voters;
|
||||
if not voters then
|
||||
answer.voters = {};
|
||||
voters = answer.voters;
|
||||
end
|
||||
|
||||
table.insert(voters, {
|
||||
id = voter.occupant_id;
|
||||
name = vote_flag and voter.occupant_name or nil;
|
||||
});
|
||||
end
|
||||
end
|
||||
|
||||
local answerData = {
|
||||
data = data,
|
||||
event = event,
|
||||
room = room,
|
||||
pollId = poll.pollId,
|
||||
voterName = voter.occupant_name,
|
||||
voterId = voter.occupant_id,
|
||||
answers = answers,
|
||||
}
|
||||
|
||||
data.senderId = voter.occupant_id;
|
||||
data.senderName = voter.occupant_name;
|
||||
data.type = 'polls';
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
|
||||
module:context(jid.host(room.jid)):fire_event('answer-poll', answerData);
|
||||
end
|
||||
|
||||
return true;
|
||||
end);
|
||||
|
||||
-- Find in which poll in newPolls we have updated answers
|
||||
-- @returns poll, senderId, array of boolean values for the answers of this sender
|
||||
function find_updated_poll(oldPolls, newPolls)
|
||||
for _, v in pairs(newPolls) do
|
||||
local existing_poll = oldPolls[v.pollId];
|
||||
local senderId;
|
||||
|
||||
for idx, newAnswer in ipairs(v.answers) do
|
||||
-- let's examine now the voters
|
||||
-- Create lookup tables using id as key for efficient searching
|
||||
local oldLookup = {}
|
||||
local newLookup = {}
|
||||
|
||||
-- Build lookup table for old array
|
||||
if existing_poll.answers[idx].voters then
|
||||
for _, element in ipairs(existing_poll.answers[idx].voters) do
|
||||
oldLookup[element.id] = element
|
||||
end
|
||||
end
|
||||
|
||||
-- Build lookup table for new array
|
||||
if newAnswer.voters then
|
||||
for _, element in ipairs(newAnswer.voters) do
|
||||
newLookup[element.id] = element
|
||||
end
|
||||
end
|
||||
|
||||
-- Find removed elements (in old but not in new)
|
||||
if existing_poll.answers[idx].voters then
|
||||
for _, element in ipairs(existing_poll.answers[idx].voters) do
|
||||
if not newLookup[element.id] then
|
||||
senderId = element.id;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Find added elements (in new but not in old)
|
||||
if newAnswer.voters then
|
||||
for _, element in ipairs(newAnswer.voters) do
|
||||
if not oldLookup[element.id] then
|
||||
senderId = element.id;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if senderId ~= nil then
|
||||
-- an array of true/false values for this sender
|
||||
local senderAnswers = {};
|
||||
for idx, newAnswer in ipairs(v.answers) do
|
||||
senderAnswers[idx] = false;
|
||||
if newAnswer.voters then
|
||||
for _, element in ipairs(newAnswer.voters) do
|
||||
if element.id == senderId then
|
||||
senderAnswers[idx] = true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return v, senderId, senderAnswers;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local setup_muc_component = function(host_module, host)
|
||||
-- Sets up poll data in new rooms.
|
||||
host_module:hook("muc-room-created", function(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then return end
|
||||
room.polls = {
|
||||
by_id = {};
|
||||
order = {};
|
||||
count = 0;
|
||||
};
|
||||
end);
|
||||
|
||||
-- Sends the current poll state to new occupants after joining a room.
|
||||
host_module:hook("muc-occupant-joined", function(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then return end
|
||||
if room.polls == nil or #room.polls.order == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local data = {
|
||||
command = "old-polls",
|
||||
polls = {},
|
||||
type = 'polls'
|
||||
};
|
||||
for i, poll in ipairs(room.polls.order) do
|
||||
data.polls[i] = {
|
||||
pollId = poll.pollId,
|
||||
senderId = poll.senderId,
|
||||
senderName = poll.senderName,
|
||||
question = poll.question,
|
||||
answers = poll.answers
|
||||
};
|
||||
end
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
send_polls_message(room, json_msg_str, event.occupant.jid);
|
||||
end);
|
||||
|
||||
-- Handles poll updates coming for a visitor node, the event contain polls structure
|
||||
-- like 'old-polls' one
|
||||
host_module:hook('jitsi-polls-update', function(event)
|
||||
local polls_command, room = event.polls, event.room;
|
||||
-- this is the initial state coming from the main prosody when only jicofo is in the room
|
||||
if room.polls.count == 0 and it.count(room:each_occupant()) == 1 then
|
||||
for i, v in ipairs(polls_command.polls) do
|
||||
room.polls.by_id[v.pollId] = v;
|
||||
table.insert(room.polls.order, v);
|
||||
room.polls.count = room.polls.count + 1;
|
||||
end
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
-- at this point we need to find which is the new poll
|
||||
local new_poll;
|
||||
for _, v in pairs(polls_command.polls) do
|
||||
if not room.polls.by_id[v.pollId] then
|
||||
new_poll = v;
|
||||
break;
|
||||
end
|
||||
end
|
||||
|
||||
if not new_poll then
|
||||
-- this is an update of the voters in some of the existing polls
|
||||
local updatedPoll, senderId, answers = find_updated_poll(room.polls.by_id, polls_command.polls);
|
||||
|
||||
if not updatedPoll then
|
||||
module:log('warn', 'no new or updated poll found in update for room %s', room.jid);
|
||||
return;
|
||||
end
|
||||
|
||||
local data = {
|
||||
answers = answers,
|
||||
command = 'answer-poll',
|
||||
pollId = updatedPoll.pollId,
|
||||
senderId = senderId,
|
||||
roomJid = internal_room_jid_match_rewrite(room.jid),
|
||||
type = 'polls'
|
||||
};
|
||||
|
||||
-- we need to update the history
|
||||
room.polls.by_id[updatedPoll.pollId].answers = updatedPoll.answers;
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
room.polls.by_id[new_poll.pollId] = new_poll;
|
||||
table.insert(room.polls.order, new_poll);
|
||||
room.polls.count = room.polls.count + 1;
|
||||
|
||||
local data = {
|
||||
answers = new_poll.answers,
|
||||
command = 'new-poll',
|
||||
pollId = new_poll.pollId,
|
||||
question = new_poll.question,
|
||||
senderId = new_poll.senderId,
|
||||
roomJid = internal_room_jid_match_rewrite(room.jid),
|
||||
type = 'polls'
|
||||
};
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
end);
|
||||
end
|
||||
|
||||
process_host_module(muc_domain_prefix..'.'..main_virtual_host, setup_muc_component);
|
||||
process_host_module('breakout.' .. main_virtual_host, setup_muc_component);
|
||||
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
module:context(host_module.host):fire_event('jitsi-add-identity', {
|
||||
name = 'polls'; host = module.host;
|
||||
});
|
||||
end);
|
||||
@@ -76,12 +76,21 @@ local function verify_user(session, stanza)
|
||||
end
|
||||
|
||||
if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end
|
||||
if not token_util:verify_room(session, stanza.attr.to) then
|
||||
module:log("error", "Token %s not allowed to join: %s",
|
||||
tostring(session.auth_token), tostring(stanza.attr.to));
|
||||
session.send(
|
||||
st.error_reply(
|
||||
stanza, "cancel", "not-allowed", "Room and token mismatched"));
|
||||
local res, err, reason = token_util:verify_room(session, stanza.attr.to);
|
||||
if not res then
|
||||
if not err and not reason then
|
||||
reason = 'Room and token mismatched';
|
||||
end
|
||||
|
||||
module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s',
|
||||
tostring(session.auth_token), tostring(stanza.attr.to), err, reason);
|
||||
|
||||
local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason);
|
||||
if err then
|
||||
response:tag(err, { xmlns = 'http://jitsi.org/jitmeet' });
|
||||
end
|
||||
|
||||
session.send(response);
|
||||
return false; -- we need to just return non nil
|
||||
end
|
||||
if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end
|
||||
|
||||
@@ -17,6 +17,7 @@ local is_admin = util.is_admin;
|
||||
local presence_check_status = util.presence_check_status;
|
||||
local process_host_module = util.process_host_module;
|
||||
local is_transcriber_jigasi = util.is_transcriber_jigasi;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local json = require 'cjson.safe';
|
||||
|
||||
-- Debug flag
|
||||
@@ -70,7 +71,8 @@ local function send_visitors_iq(conference_service, room, type)
|
||||
password = type ~= 'disconnect' and room:get_password() or '',
|
||||
lobby = room._data.lobbyroom and 'true' or 'false',
|
||||
meetingId = room._data.meetingId,
|
||||
createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil
|
||||
createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil,
|
||||
allowUnauthenticatedAccess = room._data.allowUnauthenticatedAccess ~= nil and tostring(room._data.allowUnauthenticatedAccess) or nil
|
||||
});
|
||||
|
||||
if type == 'update' then
|
||||
@@ -94,6 +96,31 @@ local function send_visitors_iq(conference_service, room, type)
|
||||
end
|
||||
visitors_iq:up();
|
||||
end
|
||||
|
||||
if room.polls and room.polls.count > 0 then
|
||||
-- polls created in the room that we want to send to the visitor nodes
|
||||
local data = {
|
||||
command = "old-polls",
|
||||
polls = {},
|
||||
type = 'polls'
|
||||
};
|
||||
for i, poll in ipairs(room.polls.order) do
|
||||
data.polls[i] = {
|
||||
pollId = poll.pollId,
|
||||
senderId = poll.senderId,
|
||||
senderName = poll.senderName,
|
||||
question = poll.question,
|
||||
answers = poll.answers
|
||||
};
|
||||
end
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
visitors_iq:tag('polls'):text(json_msg_str):up();
|
||||
end
|
||||
end
|
||||
|
||||
visitors_iq:up();
|
||||
@@ -141,6 +168,20 @@ local function connect_vnode(event)
|
||||
-- send update initially so we can report the moderators that will join
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
|
||||
-- let's send message history
|
||||
local event = {
|
||||
room = room;
|
||||
to = conference_service;
|
||||
next_stanza = function() end; -- muc-get-history should define this iterator
|
||||
};
|
||||
module:context(main_muc_component_config):fire_event("muc-get-history", event);
|
||||
for msg in event.next_stanza, event do
|
||||
-- the messages stored in history has been stored before domain_mapper and
|
||||
-- contain the virtual jid for a from
|
||||
msg.attr.from = room_jid_match_rewrite(msg.attr.from);
|
||||
room:route_stanza(msg);
|
||||
end
|
||||
|
||||
for _, o in room:each_occupant() do
|
||||
if not is_admin(o.bare_jid) then
|
||||
local fmuc_pr = filter_stanza_nick_if_needed(st.clone(o:get_presence()), room);
|
||||
@@ -472,6 +513,38 @@ process_host_module(main_muc_component_config, function(host_module, host)
|
||||
end
|
||||
end
|
||||
end, -2);
|
||||
|
||||
host_module:hook('poll-created', function (event)
|
||||
local room = event.room;
|
||||
|
||||
if not visitors_nodes[room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end);
|
||||
host_module:hook('answer-poll', function (event)
|
||||
local room, stanza = event.room, event.event.stanza;
|
||||
|
||||
if not visitors_nodes[room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
local from = stanza.attr.from;
|
||||
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
-- skip sending the answer to the node from where it originates
|
||||
if conference_service ~= from then
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end
|
||||
end);
|
||||
end);
|
||||
|
||||
local function update_vnodes_for_room(event)
|
||||
|
||||
@@ -213,6 +213,9 @@ end
|
||||
local function disconnect_vnode_received(room, vnode)
|
||||
module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; });
|
||||
|
||||
if not room._connected_vnodes then
|
||||
return;
|
||||
end
|
||||
room._connected_vnodes:set(vnode..'.meet.jitsi', nil);
|
||||
|
||||
if room._connected_vnodes:count() == 0 then
|
||||
|
||||
@@ -15,6 +15,7 @@ local http_get_with_retry = main_util.http_get_with_retry;
|
||||
local extract_subdomain = main_util.extract_subdomain;
|
||||
local starts_with = main_util.starts_with;
|
||||
local table_shallow_copy = main_util.table_shallow_copy;
|
||||
local get_room_from_jid = main_util.get_room_from_jid;
|
||||
local cjson_safe = require 'cjson.safe'
|
||||
local timer = require "util.timer";
|
||||
local async = require "util.async";
|
||||
@@ -374,16 +375,22 @@ end
|
||||
-- it and returns false in case verification was processed
|
||||
-- and was not successful
|
||||
function Util:verify_room(session, room_address)
|
||||
if self.allowEmptyToken and session.auth_token == nil then
|
||||
--module:log("debug", "Skipped room token verification - empty tokens are allowed");
|
||||
return true;
|
||||
end
|
||||
|
||||
-- extract room name using all chars, except the not allowed ones
|
||||
local room,_,_ = jid.split(room_address);
|
||||
if room == nil then
|
||||
log("error",
|
||||
"Unable to get name of the MUC room ? to: %s", room_address);
|
||||
module:log('error', 'Unable to get name of the MUC room ? to: %s', room_address);
|
||||
return false, 'invalid-room-address', 'Room address is invalid';
|
||||
end
|
||||
local room_instance = get_room_from_jid(jid.join(room, self.muc_domain));
|
||||
if not room_instance then
|
||||
module:log('info', 'Room does not exists:%s %s', room, debug.traceback());
|
||||
return false, 'room-does-not-exist', 'Room does not exist';
|
||||
end
|
||||
|
||||
if self.allowEmptyToken and session.auth_token == nil then
|
||||
if room_instance._data.allowUnauthenticatedAccess == false then
|
||||
return false, 'authentication-required', 'Authentication required';
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
@@ -395,11 +402,11 @@ function Util:verify_room(session, room_address)
|
||||
module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room));
|
||||
end
|
||||
end
|
||||
|
||||
if not self.enableDomainVerification then
|
||||
-- if auth_room is missing, this means user is anonymous (no token for
|
||||
-- its domain) we let it through, jicofo is verifying creation domain
|
||||
-- if auth_room is missing, this means user is anonymous (no token for its domain) we let it through
|
||||
if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then
|
||||
return false;
|
||||
return false, 'room-mismatch', 'Room does not match the room from token';
|
||||
end
|
||||
|
||||
return true;
|
||||
@@ -439,7 +446,7 @@ function Util:verify_room(session, room_address)
|
||||
-- not a regex
|
||||
room_to_check = auth_room;
|
||||
end
|
||||
-- module:log("debug", "room to check: %s", room_to_check)
|
||||
|
||||
if not room_to_check then
|
||||
if not self.requireRoomClaim then
|
||||
-- if we do not require to have the room claim, and it is missing
|
||||
@@ -447,17 +454,22 @@ function Util:verify_room(session, room_address)
|
||||
return true;
|
||||
end
|
||||
|
||||
return false;
|
||||
return false, 'room-name-does-not-match', 'Room name cannot be matched to the one from token.';
|
||||
end
|
||||
end
|
||||
|
||||
if session.jitsi_meet_str_tenant
|
||||
and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then
|
||||
session.jitsi_meet_tenant_mismatch = true;
|
||||
|
||||
if room_instance._data.allowUnauthenticatedAccess == false then
|
||||
return false;
|
||||
end
|
||||
|
||||
module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s',
|
||||
session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '',
|
||||
session.jitsi_meet_context_group,
|
||||
session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant);
|
||||
session.jitsi_meet_tenant_mismatch = true;
|
||||
end
|
||||
|
||||
local auth_domain = string.lower(session.jitsi_meet_domain);
|
||||
@@ -474,7 +486,7 @@ function Util:verify_room(session, room_address)
|
||||
-- deny access if option is missing
|
||||
if not self.muc_domain_base then
|
||||
module:log("warn", "No 'muc_domain_base' option set, denying access!");
|
||||
return false;
|
||||
return false, 'server-missing-config', 'Misconfiguration of server';
|
||||
end
|
||||
|
||||
return room_address_to_verify == jid.join(
|
||||
|
||||
@@ -59,6 +59,12 @@ export class Participant {
|
||||
|
||||
private _iFrameApi: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the current frame is the main frame. This could coincide with the Jitsi Meet frame (when it's loaded
|
||||
* directly), or not (when it's loaded in an iframe).
|
||||
*/
|
||||
private _inMainFrame: boolean = true;
|
||||
|
||||
/**
|
||||
* The default config to use when joining.
|
||||
*
|
||||
@@ -149,9 +155,17 @@ export class Participant {
|
||||
*/
|
||||
async getEndpointId(): Promise<string> {
|
||||
if (!this._endpointId) {
|
||||
const wasInMainFrame = this._inMainFrame;
|
||||
|
||||
await this.switchToIFrame();
|
||||
|
||||
this._endpointId = await this.execute(() => { // eslint-disable-line arrow-body-style
|
||||
return APP?.conference?.getMyUserId();
|
||||
});
|
||||
|
||||
if (wasInMainFrame) {
|
||||
await this.switchToMainFrame();
|
||||
}
|
||||
}
|
||||
|
||||
return this._endpointId;
|
||||
@@ -626,21 +640,31 @@ export class Participant {
|
||||
/**
|
||||
* Switches to the main frame context (outside the iFrame; where the Jitsi Meet iFrame API is available).
|
||||
*
|
||||
* If this Participant was initialized with iFrameApi=false this has no effect, as there aren't any other contexts.
|
||||
* If this Participant was initialized with iFrameApi=false this is a no-op.
|
||||
*/
|
||||
async switchToMainFrame() {
|
||||
if (!this._iFrameApi || this._inMainFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.driver.switchFrame(null);
|
||||
this._inMainFrame = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to the iFrame context (inside the iFrame; where the Jitsi Meet application runs).
|
||||
*
|
||||
* If this Participant was initialized with iFrameApi=false this will result in an error.
|
||||
* If this Participant was initialized with iFrameApi=false this is a no-op.
|
||||
*/
|
||||
async switchToIFrame() {
|
||||
if (!this._iFrameApi || !this._inMainFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iframe = this.driver.$('iframe');
|
||||
|
||||
await this.driver.switchFrame(iframe);
|
||||
this._inMainFrame = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* An interface that tests can export (as a TEST_PROPERTIES property) to define what they require.
|
||||
*/
|
||||
export type ITestProperties = {
|
||||
/** The test uses the iFrame API. */
|
||||
useIFrameApi: boolean;
|
||||
/** The test requires jaas, it should be skipped when the jaas configuration is not enabled. */
|
||||
useJaas: boolean;
|
||||
/** The test requires the webhook proxy. */
|
||||
@@ -12,7 +10,6 @@ export type ITestProperties = {
|
||||
};
|
||||
|
||||
const defaultProperties: ITestProperties = {
|
||||
useIFrameApi: false,
|
||||
useWebhookProxy: false,
|
||||
useJaas: false,
|
||||
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
|
||||
|
||||
@@ -4,26 +4,67 @@
|
||||
export const config = {
|
||||
/** Enable debug logging. Note this includes private information from .env */
|
||||
debug: Boolean(process.env.JITSI_DEBUG?.trim()),
|
||||
iframe: {
|
||||
customerId: process.env.IFRAME_TENANT?.trim()?.replace('vpaas-magic-cookie-', ''),
|
||||
tenant: process.env.IFRAME_TENANT?.trim(),
|
||||
/** Whether the configuration specifies a JaaS account for the iFrame API tests. */
|
||||
usesJaas: Boolean(process.env.JWT_PRIVATE_KEY_PATH && process.env.JWT_KID?.startsWith('vpaas-magic-cookie-')),
|
||||
},
|
||||
/** Whether to expect the environment to automatically elect a new moderator when the existing moderator leaves. */
|
||||
autoModerator: (() => {
|
||||
if (typeof process.env.AUTO_MODERATOR !== 'undefined') {
|
||||
return process.env.AUTO_MODERATOR?.trim() === 'true';
|
||||
}
|
||||
|
||||
// If not explicitly configured, fallback to recognizing whether we're running against one of the JaaS
|
||||
// environments which are known to have the setting disabled.
|
||||
return !Boolean(
|
||||
process.env.JWT_PRIVATE_KEY_PATH && process.env.JWT_KID?.startsWith('vpaas-magic-cookie-')
|
||||
);
|
||||
})(),
|
||||
jaas: {
|
||||
customerId: (() => {
|
||||
if (typeof process.env.JAAS_TENANT !== 'undefined') {
|
||||
return process.env.JAAS_TENANT?.trim()?.replace('vpaas-magic-cookie-', '');
|
||||
}
|
||||
|
||||
return process.env.IFRAME_TENANT?.trim()?.replace('vpaas-magic-cookie-', '');
|
||||
})(),
|
||||
/** Whether the configuration for JaaS specific tests is enabled. */
|
||||
enabled: Boolean(process.env.JAAS_TENANT && process.env.JAAS_PRIVATE_KEY_PATH && process.env.JAAS_KID),
|
||||
enabled: Boolean(
|
||||
(process.env.JAAS_TENANT || process.env.IFRAME_TENANT)
|
||||
&& (process.env.JAAS_PRIVATE_KEY_PATH || process.env.JWT_PRIVATE_KEY_PATH)
|
||||
&& (process.env.JAAS_KID || process.env.JWT_KID)),
|
||||
/** The JaaS key ID, used to sign the tokens. */
|
||||
kid: process.env.JAAS_KID?.trim(),
|
||||
kid: (() => {
|
||||
if (typeof process.env.JAAS_KID !== 'undefined') {
|
||||
return process.env.JAAS_KID?.trim();
|
||||
}
|
||||
|
||||
return process.env.JWT_KID?.trim();
|
||||
})(),
|
||||
/** The path to the JaaS private key, used to sign JaaS tokens. */
|
||||
privateKeyPath: process.env.JAAS_PRIVATE_KEY_PATH?.trim(),
|
||||
privateKeyPath: (() => {
|
||||
if (typeof process.env.JAAS_PRIVATE_KEY_PATH != 'undefined') {
|
||||
return process.env.JAAS_PRIVATE_KEY_PATH?.trim();
|
||||
}
|
||||
|
||||
return process.env.JWT_PRIVATE_KEY_PATH?.trim();
|
||||
})(),
|
||||
/** The JaaS tenant (vpaas-magic-cookie-<ID>) . */
|
||||
tenant: process.env.JAAS_TENANT?.trim(),
|
||||
tenant: (() => {
|
||||
if (typeof process.env.JAAS_TENANT !== 'undefined') {
|
||||
return process.env.JAAS_TENANT?.trim();
|
||||
}
|
||||
|
||||
return process.env.IFRAME_TENANT?.trim();
|
||||
})()
|
||||
},
|
||||
jwt: {
|
||||
kid: process.env.JWT_KID?.trim(),
|
||||
/** A pre-configured token used by some tests. */
|
||||
preconfiguredToken: process.env.JWT_ACCESS_TOKEN?.trim(),
|
||||
preconfiguredJwt: process.env.JWT_ACCESS_TOKEN?.trim(),
|
||||
preconfiguredToken: (() => {
|
||||
if (process.env.JWT_ACCESS_TOKEN) {
|
||||
return { jwt: process.env.JWT_ACCESS_TOKEN?.trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})(),
|
||||
privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH?.trim()
|
||||
},
|
||||
roomName: {
|
||||
|
||||
41
tests/helpers/jaas.ts
Normal file
41
tests/helpers/jaas.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Participant } from './Participant';
|
||||
import { config } from './TestsConfig';
|
||||
import { joinMuc } from './joinMuc';
|
||||
import { IToken, ITokenOptions, generateToken } from './token';
|
||||
import { IParticipantJoinOptions, IParticipantOptions } from './types';
|
||||
|
||||
export function generateJaasToken(options: ITokenOptions): IToken {
|
||||
if (!config.jaas.enabled) {
|
||||
throw new Error('JaaS is not configured.');
|
||||
}
|
||||
|
||||
// Don't override the keyId and keyPath if they are already set in options, allow tests to set them.
|
||||
return generateToken({
|
||||
...options,
|
||||
keyId: options.keyId || config.jaas.kid,
|
||||
keyPath: options.keyPath || config.jaas.privateKeyPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Participant and joins the MUC with the given options. The jaas-specific properties must be set as
|
||||
* environment variables (see env.example and TestsConfig.ts). If no room name is specified, the default room name
|
||||
* from the context is used.
|
||||
*
|
||||
* @param participantOptions
|
||||
* @param joinOptions options to use when joining the MUC.
|
||||
* @returns {Promise<Participant>} The Participant that has joined the MUC.
|
||||
*/
|
||||
export async function joinJaasMuc(
|
||||
participantOptions?: Partial<IParticipantOptions>,
|
||||
joinOptions?: Partial<IParticipantJoinOptions>): Promise<Participant> {
|
||||
|
||||
if (!config.jaas.enabled) {
|
||||
throw new Error('JaaS is not configured.');
|
||||
}
|
||||
|
||||
return await joinMuc(participantOptions, {
|
||||
...joinOptions,
|
||||
tenant: joinOptions?.tenant || config.jaas.tenant
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,9 @@
|
||||
import { Participant } from '../../helpers/Participant';
|
||||
import { config } from '../../helpers/TestsConfig';
|
||||
import { IToken, ITokenOptions, generateToken } from '../../helpers/token';
|
||||
import { IParticipantJoinOptions, IParticipantOptions } from '../../helpers/types';
|
||||
|
||||
export function generateJaasToken(options: ITokenOptions): IToken {
|
||||
if (!config.jaas.enabled) {
|
||||
throw new Error('JaaS is not configured.');
|
||||
}
|
||||
|
||||
// Don't override the keyId and keyPath if they are already set in options, allow tests to set them.
|
||||
return generateToken({
|
||||
...options,
|
||||
keyId: options.keyId || config.jaas.kid,
|
||||
keyPath: options.keyPath || config.jaas.privateKeyPath
|
||||
});
|
||||
}
|
||||
import { Participant } from './Participant';
|
||||
import { IParticipantJoinOptions, IParticipantOptions } from './types';
|
||||
|
||||
/**
|
||||
* Creates a new Participant and joins the MUC with the given options. The jaas-specific properties must be set as
|
||||
* environment variables (see env.example and TestsConfig.ts). If no room name is specified, the default room name
|
||||
* from the context is used.
|
||||
* Creates a new Participant and joins the MUC with the given options. If no room name is specified, the default room
|
||||
* name from the context is used.
|
||||
*
|
||||
* @param participantOptions
|
||||
* @param joinOptions options to use when joining the MUC.
|
||||
@@ -31,10 +15,6 @@ export async function joinMuc(
|
||||
|
||||
const name = participantOptions?.name || 'p1';
|
||||
|
||||
if (!config.jaas.enabled) {
|
||||
throw new Error('JaaS is not configured.');
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const p = ctx[name] as Participant;
|
||||
|
||||
@@ -55,7 +35,6 @@ export async function joinMuc(
|
||||
|
||||
return await newParticipant.joinConference({
|
||||
...joinOptions,
|
||||
tenant: joinOptions?.tenant || config.jaas.tenant,
|
||||
roomName: joinOptions?.roomName || ctx.roomName,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { P1, P2, P3, P4, Participant } from './Participant';
|
||||
import { config } from './TestsConfig';
|
||||
import { generateToken } from './token';
|
||||
import { IJoinOptions, IParticipantOptions } from './types';
|
||||
|
||||
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
|
||||
@@ -10,31 +9,22 @@ const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
|
||||
* Ensure that the first participant is moderator if there is such an option.
|
||||
*
|
||||
* @param {IJoinOptions} options - The options to use when joining the participant.
|
||||
* @param participantOptions
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function ensureOneParticipant(options?: IJoinOptions): Promise<void> {
|
||||
const participantOps = { name: P1 } as IParticipantOptions;
|
||||
export async function ensureOneParticipant(
|
||||
options?: IJoinOptions, participantOptions?: IParticipantOptions): Promise<void> {
|
||||
if (!participantOptions) {
|
||||
participantOptions = { name: P1 };
|
||||
}
|
||||
participantOptions.name = P1;
|
||||
|
||||
if (!options?.skipFirstModerator) {
|
||||
const jwtPrivateKeyPath = config.jwt.privateKeyPath;
|
||||
|
||||
// we prioritize the access token when iframe is not used and private key is set,
|
||||
// otherwise if private key is not specified we use the access token if set
|
||||
if (config.jwt.preconfiguredToken
|
||||
&& ((jwtPrivateKeyPath && !ctx.testProperties.useIFrameApi && !options?.preferGenerateToken)
|
||||
|| !jwtPrivateKeyPath)) {
|
||||
participantOps.token = { jwt: config.jwt.preconfiguredToken };
|
||||
} else if (jwtPrivateKeyPath) {
|
||||
participantOps.token = generateToken({
|
||||
...options?.tokenOptions,
|
||||
displayName: participantOps.name,
|
||||
moderator: true
|
||||
});
|
||||
}
|
||||
if (!participantOptions.token) {
|
||||
participantOptions.token = config.jwt.preconfiguredToken;
|
||||
}
|
||||
|
||||
// make sure the first participant is moderator, if supported by deployment
|
||||
await joinParticipant(participantOps, options);
|
||||
await joinParticipant(participantOptions, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,23 +126,18 @@ export async function ensureFourParticipants(options?: IJoinOptions): Promise<vo
|
||||
* Ensure that there are two participants.
|
||||
*
|
||||
* @param {IJoinOptions} options - The options to join.
|
||||
* @param participantOptions
|
||||
*/
|
||||
export async function ensureTwoParticipants(options?: IJoinOptions): Promise<void> {
|
||||
await ensureOneParticipant(options);
|
||||
export async function ensureTwoParticipants(
|
||||
options?: IJoinOptions, participantOptions?: IParticipantOptions): Promise<void> {
|
||||
await ensureOneParticipant(options, participantOptions);
|
||||
|
||||
const participantOptions = { name: P2 } as IParticipantOptions;
|
||||
|
||||
if (options?.preferGenerateToken) {
|
||||
participantOptions.token = generateToken({
|
||||
...options?.tokenOptions,
|
||||
displayName: participantOptions.name,
|
||||
});
|
||||
if (!participantOptions) {
|
||||
participantOptions = { name: P2 };
|
||||
}
|
||||
participantOptions.name = P2;
|
||||
|
||||
await joinParticipant({
|
||||
...participantOptions,
|
||||
name: P2
|
||||
}, options);
|
||||
await joinParticipant(participantOptions, options);
|
||||
|
||||
if (options?.skipInMeetingChecks) {
|
||||
return Promise.resolve();
|
||||
@@ -171,8 +156,7 @@ export async function ensureTwoParticipants(options?: IJoinOptions): Promise<voi
|
||||
/**
|
||||
* Creates a new participant instance, or returns an existing one if it is already joined.
|
||||
* @param participantOptions - The participant options, with required name set.
|
||||
* @param {boolean} options - Join options.
|
||||
* @param reuse whether to reuse an existing participant instance if one is available.
|
||||
* @param options - Join options.
|
||||
* @returns {Promise<Participant>} - The participant instance.
|
||||
*/
|
||||
async function joinParticipant( // eslint-disable-line max-params
|
||||
@@ -180,13 +164,11 @@ async function joinParticipant( // eslint-disable-line max-params
|
||||
options?: IJoinOptions
|
||||
): Promise<Participant> {
|
||||
|
||||
participantOptions.iFrameApi = ctx.testProperties.useIFrameApi;
|
||||
|
||||
// @ts-ignore
|
||||
const p = ctx[participantOptions.name] as Participant;
|
||||
|
||||
if (p) {
|
||||
if (ctx.testProperties.useIFrameApi) {
|
||||
if (participantOptions.iFrameApi) {
|
||||
await p.switchToIFrame();
|
||||
}
|
||||
|
||||
@@ -194,7 +176,7 @@ async function joinParticipant( // eslint-disable-line max-params
|
||||
return p;
|
||||
}
|
||||
|
||||
if (ctx.testProperties.useIFrameApi) {
|
||||
if (participantOptions.iFrameApi) {
|
||||
// when loading url make sure we are on the top page context or strange errors may occur
|
||||
await p.switchToMainFrame();
|
||||
}
|
||||
@@ -209,87 +191,12 @@ async function joinParticipant( // eslint-disable-line max-params
|
||||
// @ts-ignore
|
||||
ctx[participantOptions.name] = newParticipant;
|
||||
|
||||
let tenant = options?.tenant;
|
||||
|
||||
if (options?.preferGenerateToken && !ctx.testProperties.useIFrameApi
|
||||
&& config.iframe.usesJaas && config.iframe.tenant) {
|
||||
tenant = config.iframe.tenant;
|
||||
}
|
||||
|
||||
if (!tenant && ctx.testProperties.useIFrameApi) {
|
||||
tenant = config.iframe.tenant;
|
||||
}
|
||||
|
||||
return await newParticipant.joinConference({
|
||||
...options,
|
||||
tenant: tenant,
|
||||
roomName: options?.roomName || ctx.roomName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the mute state of a specific Meet conference participant and verifies that a specific other Meet
|
||||
* conference participants sees a specific mute state for the former.
|
||||
*
|
||||
* @param {Participant} testee - The {@code Participant} which represents the Meet conference participant whose
|
||||
* mute state is to be toggled.
|
||||
* @param {Participant} observer - The {@code Participant} which represents the Meet conference participant to verify
|
||||
* the mute state of {@code testee}.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function muteAudioAndCheck(testee: Participant, observer: Participant): Promise<void> {
|
||||
await testee.getToolbar().clickAudioMuteButton();
|
||||
|
||||
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
|
||||
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
|
||||
|
||||
await observer.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee);
|
||||
await testee.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute audio, checks if the local UI has been updated accordingly and then does the verification from
|
||||
* the other observer participant perspective.
|
||||
* @param testee
|
||||
* @param observer
|
||||
*/
|
||||
export async function unmuteAudioAndCheck(testee: Participant, observer: Participant) {
|
||||
await testee.getNotifications().closeAskToUnmuteNotification(true);
|
||||
await testee.getNotifications().closeAVModerationMutedNotification(true);
|
||||
await testee.getToolbar().clickAudioUnmuteButton();
|
||||
|
||||
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
|
||||
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
|
||||
|
||||
await testee.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee, true);
|
||||
await observer.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the video on testee and check on observer.
|
||||
* @param testee
|
||||
* @param observer
|
||||
*/
|
||||
export async function unmuteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
|
||||
await testee.getToolbar().clickVideoUnmuteButton();
|
||||
|
||||
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
|
||||
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the video on testee and check on observer.
|
||||
* @param testee
|
||||
* @param observer
|
||||
*/
|
||||
export async function muteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
|
||||
await testee.getToolbar().clickVideoMuteButton();
|
||||
|
||||
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
|
||||
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JID string.
|
||||
* @param str the string to parse.
|
||||
|
||||
@@ -37,8 +37,8 @@ export type IContext = {
|
||||
export type IParticipantOptions = {
|
||||
/** Whether it should use the iFrame API. */
|
||||
iFrameApi?: boolean;
|
||||
/** Must be 'p1', 'p2', 'p3', or 'p4'. */
|
||||
name: string;
|
||||
/** Determines the browser instance to use. */
|
||||
name: 'p1' | 'p2' | 'p3' | 'p4';
|
||||
/** An optional token to use. */
|
||||
token?: IToken;
|
||||
};
|
||||
@@ -80,12 +80,6 @@ export type IJoinOptions = {
|
||||
*/
|
||||
configOverwrite?: IConfig;
|
||||
|
||||
/**
|
||||
* When joining the first participant and jwt singing material is available and a provided token
|
||||
* is available, prefer generating a new token for the first participant.
|
||||
*/
|
||||
preferGenerateToken?: boolean;
|
||||
|
||||
/**
|
||||
* To be able to override the ctx generated room name. If missing the one from the context will be used.
|
||||
*/
|
||||
@@ -96,11 +90,6 @@ export type IJoinOptions = {
|
||||
*/
|
||||
skipDisplayName?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to skip setting the moderator role for the first participant (whether to use jwt for it).
|
||||
*/
|
||||
skipFirstModerator?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to skip in meeting checks like ice connected and send receive data. For single in meeting participant.
|
||||
*/
|
||||
|
||||
@@ -19,4 +19,189 @@ export default class ChatPanel extends BasePageObject {
|
||||
await this.participant.driver.$('body').click();
|
||||
await this.participant.driver.keys([ 'c' ]);
|
||||
}
|
||||
|
||||
async sendMessage(message: string) {
|
||||
if (!await this.isOpen()) {
|
||||
await this.pressShortcut();
|
||||
}
|
||||
if (!await this.isOpen()) {
|
||||
throw new Error('Chat panel failed to open');
|
||||
}
|
||||
|
||||
const inputField = this.participant.driver.$('#chat-input');
|
||||
|
||||
await inputField.click();
|
||||
await this.participant.driver.keys(`${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the polls tab in the chat panel.
|
||||
*/
|
||||
async openPollsTab() {
|
||||
await this.participant.driver.$('#polls-tab').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the polls tab is visible.
|
||||
*/
|
||||
async isPollsTabVisible() {
|
||||
return this.participant.driver.$('#polls-tab-panel').isDisplayed();
|
||||
}
|
||||
|
||||
async clickCreatePollButton() {
|
||||
await this.participant.driver.$('aria/Create a poll').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the new poll input to be visible.
|
||||
*/
|
||||
async waitForNewPollInput() {
|
||||
await this.participant.driver.$(
|
||||
'#polls-create-input')
|
||||
.waitForExist({
|
||||
timeout: 2000,
|
||||
timeoutMsg: 'New poll not created'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the option input to be visible.
|
||||
* @param index
|
||||
*/
|
||||
async waitForOptionInput(index: number) {
|
||||
await this.participant.driver.$(
|
||||
`#polls-answer-input-${index}`)
|
||||
.waitForExist({
|
||||
timeout: 1000,
|
||||
timeoutMsg: `Answer input ${index} not created`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the option input to be non-existing.
|
||||
* @param index
|
||||
*/
|
||||
async waitForOptionInputNonExisting(index: number) {
|
||||
await this.participant.driver.$(
|
||||
`#polls-answer-input-${index}`)
|
||||
.waitForExist({
|
||||
reverse: true,
|
||||
timeout: 2000,
|
||||
timeoutMsg: `Answer input ${index} still exists`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Add option" button.
|
||||
*/
|
||||
async clickAddOptionButton() {
|
||||
await this.participant.driver.$('aria/Add option').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Remove option" button.
|
||||
* @param index
|
||||
*/
|
||||
async clickRemoveOptionButton(index: number) {
|
||||
await this.participant.driver.$(`[data-testid="remove-polls-answer-input-${index}"]`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in the poll question.
|
||||
* @param question
|
||||
*/
|
||||
async fillPollQuestion(question: string) {
|
||||
const input = await this.participant.driver.$('#polls-create-input');
|
||||
|
||||
await input.click();
|
||||
await this.participant.driver.keys(question);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in the poll option.
|
||||
* @param index
|
||||
* @param option
|
||||
*/
|
||||
async fillPollOption(index: number, option: string) {
|
||||
const input = await this.participant.driver.$(`#polls-answer-input-${index}`);
|
||||
|
||||
await input.click();
|
||||
await this.participant.driver.keys(option);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the poll option.
|
||||
* @param index
|
||||
*/
|
||||
async getOption(index: number) {
|
||||
return this.participant.driver.$(`#polls-answer-input-${index}`).getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Save" button.
|
||||
*/
|
||||
async clickSavePollButton() {
|
||||
await this.participant.driver.$('aria/Save').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Edit" button.
|
||||
*/
|
||||
async clickEditPollButton() {
|
||||
await this.participant.driver.$('aria/Edit').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Skip" button.
|
||||
*/
|
||||
async clickSkipPollButton() {
|
||||
await this.participant.driver.$('aria/Skip').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Send" button.
|
||||
*/
|
||||
async clickSendPollButton() {
|
||||
await this.participant.driver.$('aria/Send poll').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the "Send" button to be visible.
|
||||
*/
|
||||
async waitForSendButton() {
|
||||
await this.participant.driver.$('aria/Send poll').waitForExist({
|
||||
timeout: 1000,
|
||||
timeoutMsg: 'Send button not visible'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Votes for the given option in the given poll.
|
||||
* @param pollId
|
||||
* @param index
|
||||
*/
|
||||
async voteForOption(pollId: string, index: number) {
|
||||
await this.participant.driver.execute(
|
||||
(id, ix) => document.getElementById(`poll-answer-checkbox-${id}-${ix}`)?.click(),
|
||||
pollId, index);
|
||||
|
||||
await this.participant.driver.$('aria/Submit').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given poll is visible.
|
||||
* @param pollId
|
||||
*/
|
||||
async isPollVisible(pollId: string) {
|
||||
return this.participant.driver.$(`#poll-${pollId}`).isDisplayed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the result text for the given option in the given poll.
|
||||
* @param pollId
|
||||
* @param optionIndex
|
||||
*/
|
||||
async getResult(pollId: string, optionIndex: number) {
|
||||
return await this.participant.driver.$(`#poll-result-${pollId}-${optionIndex}`).getText();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
ensureOneParticipant,
|
||||
ensureTwoParticipants,
|
||||
joinSecondParticipant,
|
||||
} from '../../helpers/participants';
|
||||
import {
|
||||
muteAudioAndCheck,
|
||||
unmuteAudioAndCheck,
|
||||
unmuteVideoAndCheck
|
||||
} from '../../helpers/participants';
|
||||
} from '../helpers/mute';
|
||||
|
||||
describe('Mute', () => {
|
||||
it('joining the meeting', () => ensureTwoParticipants());
|
||||
123
tests/specs/2way/polls.spec.ts
Normal file
123
tests/specs/2way/polls.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ensureTwoParticipants } from '../../helpers/participants';
|
||||
|
||||
describe('Polls', () => {
|
||||
it('joining the meeting', async () => {
|
||||
await ensureTwoParticipants();
|
||||
});
|
||||
it('create poll', async () => {
|
||||
const { p1 } = ctx;
|
||||
|
||||
await p1.getToolbar().clickChatButton();
|
||||
expect(await p1.getChatPanel().isOpen()).toBe(true);
|
||||
|
||||
expect(await p1.getChatPanel().isPollsTabVisible()).toBe(false);
|
||||
|
||||
await p1.getChatPanel().openPollsTab();
|
||||
expect(await p1.getChatPanel().isPollsTabVisible()).toBe(true);
|
||||
|
||||
// create poll
|
||||
await p1.getChatPanel().clickCreatePollButton();
|
||||
await p1.getChatPanel().waitForNewPollInput();
|
||||
});
|
||||
|
||||
it('fill in poll', async () => {
|
||||
const { p1 } = ctx;
|
||||
|
||||
await p1.getChatPanel().fillPollQuestion('My Poll question?');
|
||||
|
||||
await p1.getChatPanel().waitForOptionInput(0);
|
||||
await p1.getChatPanel().waitForOptionInput(1);
|
||||
await p1.getChatPanel().fillPollOption(0, 'First option');
|
||||
await p1.getChatPanel().fillPollOption(1, 'Second option');
|
||||
|
||||
|
||||
await p1.getChatPanel().clickAddOptionButton();
|
||||
await p1.getChatPanel().waitForOptionInput(2);
|
||||
await p1.getChatPanel().fillPollOption(2, 'Third option');
|
||||
|
||||
await p1.getChatPanel().clickAddOptionButton();
|
||||
await p1.getChatPanel().waitForOptionInput(3);
|
||||
await p1.getChatPanel().fillPollOption(3, 'Fourth option');
|
||||
|
||||
await p1.getChatPanel().clickRemoveOptionButton(2);
|
||||
// we remove the option and reindexing happens, so we check for index 3
|
||||
await p1.getChatPanel().waitForOptionInputNonExisting(3);
|
||||
|
||||
expect(await p1.getChatPanel().getOption(2)).toBe('Fourth option');
|
||||
});
|
||||
|
||||
it('save and edit poll', async () => {
|
||||
const { p1 } = ctx;
|
||||
|
||||
await p1.getChatPanel().clickSavePollButton();
|
||||
|
||||
await p1.getChatPanel().waitForSendButton();
|
||||
|
||||
await p1.getChatPanel().clickEditPollButton();
|
||||
|
||||
await p1.getChatPanel().fillPollOption(0, ' edited!');
|
||||
|
||||
await p1.getChatPanel().clickSavePollButton();
|
||||
|
||||
await p1.getChatPanel().waitForSendButton();
|
||||
});
|
||||
|
||||
it('send poll', async () => {
|
||||
const { p1 } = ctx;
|
||||
|
||||
await p1.getChatPanel().clickSendPollButton();
|
||||
});
|
||||
|
||||
it('vote on poll', async () => {
|
||||
const { p1 } = ctx;
|
||||
|
||||
// await p1.getNotifications().closePollsNotification();
|
||||
|
||||
// we have only one poll, so we get its ID
|
||||
const pollId: string = await p1.driver.waitUntil(() => p1.driver.execute(() => {
|
||||
return Object.keys(APP.store.getState()['features/polls'].polls)[0];
|
||||
}), { timeout: 2000 });
|
||||
|
||||
// we have just send the poll, so the UI should be in a state for voting
|
||||
await p1.getChatPanel().voteForOption(pollId, 0);
|
||||
});
|
||||
|
||||
it('check for vote', async () => {
|
||||
const { p1, p2 } = ctx;
|
||||
const pollId: string = await p1.driver.execute('return Object.keys(APP.store.getState()["features/polls"].polls)[0];');
|
||||
|
||||
// now let's check on p2 side
|
||||
await p2.getToolbar().clickChatButton();
|
||||
expect(await p2.getChatPanel().isOpen()).toBe(true);
|
||||
|
||||
expect(await p2.getChatPanel().isPollsTabVisible()).toBe(false);
|
||||
|
||||
await p2.getChatPanel().openPollsTab();
|
||||
expect(await p2.getChatPanel().isPollsTabVisible()).toBe(true);
|
||||
|
||||
expect(await p2.getChatPanel().isPollVisible(pollId));
|
||||
|
||||
await p2.getChatPanel().clickSkipPollButton();
|
||||
|
||||
expect(await p2.getChatPanel().getResult(pollId, 0)).toBe('1 (100%)');
|
||||
});
|
||||
|
||||
it('leave and check for vote', async () => {
|
||||
await ctx.p2.hangup();
|
||||
|
||||
await ensureTwoParticipants();
|
||||
|
||||
const { p1, p2 } = ctx;
|
||||
const pollId: string = await p1.driver.execute('return Object.keys(APP.store.getState()["features/polls"].polls)[0];');
|
||||
|
||||
|
||||
await p2.getToolbar().clickChatButton();
|
||||
await p2.getChatPanel().openPollsTab();
|
||||
|
||||
expect(await p2.getChatPanel().isPollVisible(pollId));
|
||||
|
||||
await p2.getChatPanel().clickSkipPollButton();
|
||||
|
||||
expect(await p2.getChatPanel().getResult(pollId, 0)).toBe('1 (100%)');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ensureTwoParticipants, muteVideoAndCheck, unmuteVideoAndCheck } from '../../helpers/participants';
|
||||
import { ensureTwoParticipants } from '../../helpers/participants';
|
||||
import { muteVideoAndCheck, unmuteVideoAndCheck } from '../helpers/mute';
|
||||
|
||||
describe('Stop video', () => {
|
||||
it('joining the meeting', () => ensureTwoParticipants());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant } from '../../helpers/Participant';
|
||||
import { ensureThreeParticipants, muteAudioAndCheck } from '../../helpers/participants';
|
||||
import { ensureThreeParticipants } from '../../helpers/participants';
|
||||
import { muteAudioAndCheck } from '../helpers/mute';
|
||||
|
||||
describe('ActiveSpeaker', () => {
|
||||
it('testActiveSpeaker', async () => {
|
||||
|
||||
@@ -3,10 +3,9 @@ import { config } from '../../helpers/TestsConfig';
|
||||
import {
|
||||
ensureOneParticipant,
|
||||
ensureThreeParticipants, ensureTwoParticipants,
|
||||
hangupAllParticipants,
|
||||
unmuteAudioAndCheck,
|
||||
unmuteVideoAndCheck
|
||||
hangupAllParticipants
|
||||
} from '../../helpers/participants';
|
||||
import { unmuteAudioAndCheck, unmuteVideoAndCheck } from '../helpers/mute';
|
||||
|
||||
describe('AVModeration', () => {
|
||||
|
||||
@@ -78,8 +77,9 @@ describe('AVModeration', () => {
|
||||
});
|
||||
|
||||
it('hangup and change moderator', async () => {
|
||||
// no moderator switching if jaas is available.
|
||||
if (config.iframe.usesJaas) {
|
||||
// The test below is only correct when the environment is configured to automatically elect a new moderator
|
||||
// when the moderator leaves. For environments where this is not the case, the test is skipped.
|
||||
if (!config.autoModerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user