mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-01-04 22:02:28 +00:00
Compare commits
15 Commits
ref-rtcsta
...
8829
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
469406d7cd | ||
|
|
60679aa2d3 | ||
|
|
319e8d1e4b | ||
|
|
40b8d6168b | ||
|
|
753d0399c9 | ||
|
|
2475aff21a | ||
|
|
121aabeb25 | ||
|
|
086f01aa5b | ||
|
|
6b6920693b | ||
|
|
566b3ba2d5 | ||
|
|
7373123166 | ||
|
|
cc312877f4 | ||
|
|
eb8b6159ec | ||
|
|
f9d8feacd2 | ||
|
|
f5668b6e8b |
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": "中文(繁體)"
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
@@ -748,7 +747,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 +964,9 @@
|
||||
"by": "By {{ name }}",
|
||||
"closeButton": "Close poll",
|
||||
"create": {
|
||||
"accessibilityLabel": {
|
||||
"send": "Send poll"
|
||||
},
|
||||
"addOption": "Add option",
|
||||
"answerPlaceholder": "Option {{index}}",
|
||||
"cancel": "Cancel",
|
||||
@@ -972,8 +975,7 @@
|
||||
"pollQuestion": "Poll Question",
|
||||
"questionPlaceholder": "Ask a question",
|
||||
"removeOption": "Remove option",
|
||||
"save": "Save",
|
||||
"send": "Send"
|
||||
"save": "Save"
|
||||
},
|
||||
"errors": {
|
||||
"notUniqueOption": "Options must be unique"
|
||||
|
||||
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/v2089.0.0+75c1c6ff/lib-jitsi-meet.tgz",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2095.0.0+43bbd502/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/v2089.0.0+75c1c6ff/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-1sd9+YztXYhJ5mI5fksr+EHDqEVZZYz1oIe3Uv+QIicABWL06kwFc4lTRdBUSrYf+GRXZ9F37UQjxuOOh1lu8A==",
|
||||
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2095.0.0+43bbd502/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-Q+dRiQaCiddlVIurIPnq+evAva7miyCf7FIWhZxxwoZX32djfLn9mzTWekdTOEEFDoMj80N6S8OSseMqtkOPsg==",
|
||||
"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/v2089.0.0+75c1c6ff/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-1sd9+YztXYhJ5mI5fksr+EHDqEVZZYz1oIe3Uv+QIicABWL06kwFc4lTRdBUSrYf+GRXZ9F37UQjxuOOh1lu8A==",
|
||||
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2095.0.0+43bbd502/lib-jitsi-meet.tgz",
|
||||
"integrity": "sha512-Q+dRiQaCiddlVIurIPnq+evAva7miyCf7FIWhZxxwoZX32djfLn9mzTWekdTOEEFDoMj80N6S8OSseMqtkOPsg==",
|
||||
"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/v2089.0.0+75c1c6ff/lib-jitsi-meet.tgz",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2095.0.0+43bbd502/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;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { AudioMixerEffect } from '../../stream-effects/audio-mixer/AudioMixerEff
|
||||
import { iAmVisitor } from '../../visitors/functions';
|
||||
import { overwriteConfig } from '../config/actions';
|
||||
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, CONNECTION_WILL_CONNECT } from '../connection/actionTypes';
|
||||
import { connectionDisconnected, disconnect } from '../connection/actions';
|
||||
import { connect, connectionDisconnected, disconnect, setPreferVisitor } from '../connection/actions';
|
||||
import { validateJwt } from '../jwt/functions';
|
||||
import { JitsiConferenceErrors, JitsiConferenceEvents, JitsiConnectionErrors } from '../lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../media/constants';
|
||||
@@ -78,6 +78,11 @@ import { IConferenceMetadata } from './reducer';
|
||||
*/
|
||||
let beforeUnloadHandler: ((e?: any) => void) | undefined;
|
||||
|
||||
/**
|
||||
* A simple flag to avoid retrying more than once to join as a visitor when hitting max occupants reached.
|
||||
*/
|
||||
let retryAsVisitorOnMaxError = true;
|
||||
|
||||
/**
|
||||
* Implements the middleware of the feature base/conference.
|
||||
*
|
||||
@@ -202,11 +207,20 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
|
||||
break;
|
||||
}
|
||||
case JitsiConferenceErrors.CONFERENCE_MAX_USERS: {
|
||||
dispatch(showErrorNotification({
|
||||
hideErrorSupportLink: true,
|
||||
descriptionKey: 'dialog.maxUsersLimitReached',
|
||||
titleKey: 'dialog.maxUsersLimitReachedTitle'
|
||||
}));
|
||||
let retryAsVisitor = false;
|
||||
|
||||
if (error.params?.length && error.params[0]?.visitorsSupported) {
|
||||
// visitors are supported, so let's try joining that way
|
||||
retryAsVisitor = true;
|
||||
}
|
||||
|
||||
if (!retryAsVisitor) {
|
||||
dispatch(showErrorNotification({
|
||||
hideErrorSupportLink: true,
|
||||
descriptionKey: 'dialog.maxUsersLimitReached',
|
||||
titleKey: 'dialog.maxUsersLimitReachedTitle'
|
||||
}));
|
||||
}
|
||||
|
||||
// In case of max users(it can be from a visitor node), let's restore
|
||||
// oldConfig if any as we will be back to the main prosody.
|
||||
@@ -220,6 +234,18 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
|
||||
.then(() => dispatch(disconnect()));
|
||||
}
|
||||
|
||||
if (retryAsVisitor && !newConfig && retryAsVisitorOnMaxError) {
|
||||
retryAsVisitorOnMaxError = false;
|
||||
|
||||
logger.info('On max user reached will retry joining as a visitor');
|
||||
|
||||
dispatch(disconnect(true)).then(() => {
|
||||
dispatch(setPreferVisitor(true));
|
||||
|
||||
return dispatch(connect());
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case JitsiConferenceErrors.NOT_ALLOWED_ERROR: {
|
||||
@@ -300,6 +326,8 @@ function _conferenceJoined({ dispatch, getState }: IStore, next: Function, actio
|
||||
requireDisplayName
|
||||
} = getState()['features/base/config'];
|
||||
|
||||
retryAsVisitorOnMaxError = true;
|
||||
|
||||
dispatch(removeLobbyChatParticipant(true));
|
||||
|
||||
pendingSubjectChange && dispatch(setSubject(pendingSubjectChange));
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface IJitsiConference {
|
||||
getParticipantById: Function;
|
||||
getParticipantCount: Function;
|
||||
getParticipants: Function;
|
||||
getPolls: Function;
|
||||
getRole: Function;
|
||||
getShortTermCredentials: Function;
|
||||
getSpeakerStats: () => ISpeakerStats;
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -30,5 +30,11 @@ export function shouldDisplayNotifications(stateful: IStateful) {
|
||||
export function arePollsDisabled(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
const { conference } = state['features/base/conference'];
|
||||
|
||||
if (!conference?.getPolls()?.isSupported()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state['features/base/config']?.disablePolls || iAmVisitor(state);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -149,7 +149,7 @@ const Notification = ({
|
||||
const titleText = title || (titleKey && t(titleKey, titleArguments));
|
||||
const descriptionArray = _getDescription();
|
||||
|
||||
if (descriptionArray?.length) {
|
||||
if (descriptionArray?.length && titleText) {
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
@@ -168,15 +168,29 @@ const Notification = ({
|
||||
}
|
||||
</>
|
||||
);
|
||||
} else if (descriptionArray?.length && !titleText) {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
descriptionArray.map((line, index) => (
|
||||
<Text
|
||||
key = { index }
|
||||
style = { styles.contentTextDescription }>
|
||||
{ line.length >= CHAR_LIMIT ? line : replaceNonUnicodeEmojis(line) }
|
||||
</Text>
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.contentTextTitle as TextStyle }>
|
||||
{ titleText }
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.contentTextTitle as TextStyle }>
|
||||
{ titleText }
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,6 +49,12 @@ export default {
|
||||
paddingTop: BaseTheme.spacing[1]
|
||||
},
|
||||
|
||||
contentTextDescription: {
|
||||
color: BaseTheme.palette.text04,
|
||||
paddingLeft: BaseTheme.spacing[4],
|
||||
paddingTop: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
contentTextTitleDescription: {
|
||||
color: BaseTheme.palette.text04,
|
||||
fontWeight: 'bold',
|
||||
|
||||
@@ -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 { 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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,7 +72,7 @@ function filter_stanza_in(stanza, session)
|
||||
|
||||
local nick_element = stanza:get_child('nick', NICK_NS);
|
||||
|
||||
if nick_element:get_text() ~= session.jitsi_meet_context_user.name then
|
||||
if nick_element and nick_element:get_text() ~= session.jitsi_meet_context_user.name then
|
||||
stanza:remove_children('nick', NICK_NS);
|
||||
stanza:tag('nick', { xmlns = NICK_NS }):text(session.jitsi_meet_context_user.name):up();
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -43,7 +43,7 @@ local function check_for_max_occupants(event)
|
||||
|
||||
-- If there is no whitelist, just check the count.
|
||||
if not whitelist and count >= slots then
|
||||
module:log("info", "Attempt to enter a maxed out MUC");
|
||||
module:log("info", "Attempt to enter a maxed out room: %s", room.jid);
|
||||
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
|
||||
return true;
|
||||
end
|
||||
@@ -60,7 +60,7 @@ local function check_for_max_occupants(event)
|
||||
|
||||
-- If the room is full (<0 slots left), error out.
|
||||
if slots <= 0 then
|
||||
module:log("info", "Attempt to enter a maxed out MUC");
|
||||
module:log("info", "Attempt to enter a maxed out room:%s", room.jid);
|
||||
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
|
||||
return true;
|
||||
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);
|
||||
341
resources/prosody-plugins/mod_polls_component.lua
Normal file
341
resources/prosody-plugins/mod_polls_component.lua
Normal file
@@ -0,0 +1,341 @@
|
||||
-- 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 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 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');
|
||||
|
||||
-- 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
|
||||
send_polls_message(room, data_str, room_occupant.jid);
|
||||
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 = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
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 occupant_jid = stanza.attr.from;
|
||||
local 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
|
||||
|
||||
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
|
||||
|
||||
if data.command == "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, 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
|
||||
}
|
||||
}
|
||||
|
||||
module:context(jid.host(room.jid)):fire_event('poll-created', pollData);
|
||||
|
||||
-- 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);
|
||||
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");
|
||||
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
|
||||
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 = {
|
||||
event = event,
|
||||
room = room,
|
||||
pollId = poll.pollId,
|
||||
voterName = voter.occupant_name,
|
||||
voterId = voter.occupant_id,
|
||||
answers = answers
|
||||
}
|
||||
module:context(jid.host(room.jid)):fire_event("answer-poll", answerData);
|
||||
|
||||
data.senderId = voter.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);
|
||||
end
|
||||
|
||||
return true;
|
||||
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);
|
||||
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);
|
||||
@@ -216,6 +216,10 @@ async function joinParticipant( // eslint-disable-line max-params
|
||||
tenant = config.iframe.tenant;
|
||||
}
|
||||
|
||||
if (!tenant && ctx.testProperties.useIFrameApi) {
|
||||
tenant = config.iframe.tenant;
|
||||
}
|
||||
|
||||
return await newParticipant.joinConference({
|
||||
...options,
|
||||
tenant: tenant,
|
||||
|
||||
@@ -19,4 +19,175 @@ export default class ChatPanel extends BasePageObject {
|
||||
await this.participant.driver.$('body').click();
|
||||
await this.participant.driver.keys([ 'c' ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
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,81 +0,0 @@
|
||||
import { expect } from '@wdio/globals';
|
||||
|
||||
import { setTestProperties } from '../../helpers/TestProperties';
|
||||
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
|
||||
|
||||
setTestProperties(__filename, {
|
||||
useIFrameApi: true,
|
||||
useWebhookProxy: true,
|
||||
usesBrowsers: [ 'p1', 'p2' ]
|
||||
});
|
||||
|
||||
describe('Visitors', () => {
|
||||
it('joining the meeting', async () => {
|
||||
const { webhooksProxy } = ctx;
|
||||
|
||||
if (webhooksProxy) {
|
||||
webhooksProxy.defaultMeetingSettings = {
|
||||
visitorsEnabled: true,
|
||||
visitorsLive: false
|
||||
};
|
||||
}
|
||||
|
||||
await ensureOneParticipant();
|
||||
|
||||
const { p1 } = ctx;
|
||||
|
||||
if (await p1.execute(() => config.disableIframeAPI)) {
|
||||
// skip the test if iframeAPI is disabled or visitors are not supported
|
||||
ctx.skipSuiteTests = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await p1.driver.waitUntil(() => p1.execute(() => APP.conference._room.isVisitorsSupported()), {
|
||||
timeout: 2000
|
||||
}).then(async () => {
|
||||
await p1.switchToMainFrame();
|
||||
}).catch(() => {
|
||||
ctx.skipSuiteTests = true;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('go live', async () => {
|
||||
await ensureTwoParticipants({
|
||||
preferGenerateToken: true,
|
||||
tokenOptions: { visitor: true },
|
||||
skipWaitToJoin: true,
|
||||
skipInMeetingChecks: true
|
||||
});
|
||||
|
||||
const { p1, p2 } = ctx;
|
||||
const p2Visitors = p2.getVisitors();
|
||||
const p1Visitors = p1.getVisitors();
|
||||
|
||||
await p2.driver.waitUntil(async () => p2Visitors.isVisitorsQueueUIShown(), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'Missing visitors queue UI'
|
||||
});
|
||||
|
||||
await p1.driver.waitUntil(async () => await p1Visitors.getWaitingVisitorsInQueue()
|
||||
=== 'Viewers waiting in queue: 1', {
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'Missing visitors queue count in UI'
|
||||
});
|
||||
|
||||
await p1Visitors.goLive();
|
||||
|
||||
await p2.waitToJoinMUC();
|
||||
await p2.waitForReceiveMedia(15000, 'Visitor is not receiving media');
|
||||
await p2.waitForRemoteStreams(1);
|
||||
|
||||
await p2.driver.waitUntil(() => p2Visitors.hasVisitorsDialog(), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'Missing visitors dialog'
|
||||
});
|
||||
|
||||
expect((await p1Visitors.getVisitorsCount()).trim()).toBe('1');
|
||||
expect((await p1Visitors.getVisitorsHeaderFromParticipantsPane()).trim()).toBe('Viewers 1');
|
||||
});
|
||||
});
|
||||
74
tests/specs/jaas/visitors/visitorsLive.spec.ts
Normal file
74
tests/specs/jaas/visitors/visitorsLive.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { expect } from '@wdio/globals';
|
||||
|
||||
import { Participant } from '../../../helpers/Participant';
|
||||
import { setTestProperties } from '../../../helpers/TestProperties';
|
||||
import { joinMuc, generateJaasToken as t } from '../../helpers/jaas';
|
||||
|
||||
setTestProperties(__filename, {
|
||||
useJaas: true,
|
||||
useWebhookProxy: true,
|
||||
usesBrowsers: [ 'p1', 'p2' ]
|
||||
});
|
||||
|
||||
describe('Visitors', () => {
|
||||
let visitor: Participant, moderator: Participant;
|
||||
|
||||
it('setup', async () => {
|
||||
ctx.webhooksProxy.defaultMeetingSettings = {
|
||||
visitorsEnabled: true,
|
||||
visitorsLive: false
|
||||
};
|
||||
|
||||
moderator = await joinMuc({
|
||||
name: 'p1',
|
||||
token: t({ room: ctx.roomName, displayName: 'Mo de Rator', moderator: true })
|
||||
});
|
||||
|
||||
// TODO: Remove this in favor of configurable test expectations
|
||||
await moderator.driver.waitUntil(() => moderator.execute(() => APP.conference._room.isVisitorsSupported()), {
|
||||
timeout: 2000
|
||||
}).catch(e => {
|
||||
console.log(`Skipping test due to error: ${e}`);
|
||||
ctx.skipSuiteTests = true;
|
||||
});
|
||||
|
||||
visitor = await joinMuc({
|
||||
name: 'p2',
|
||||
token: t({ room: ctx.roomName, displayName: 'Visi Tor', visitor: true })
|
||||
}, {
|
||||
skipWaitToJoin: true
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('go live', async () => {
|
||||
|
||||
const vVisitors = visitor.getVisitors();
|
||||
const mVisitors = moderator.getVisitors();
|
||||
|
||||
await visitor.driver.waitUntil(async () => vVisitors.isVisitorsQueueUIShown(), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'Missing visitors queue UI'
|
||||
});
|
||||
|
||||
await moderator.driver.waitUntil(async () => await mVisitors.getWaitingVisitorsInQueue()
|
||||
=== 'Viewers waiting in queue: 1', {
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'Missing visitors queue count in UI'
|
||||
});
|
||||
|
||||
await mVisitors.goLive();
|
||||
|
||||
await visitor.waitToJoinMUC();
|
||||
await visitor.waitForReceiveMedia(15000, 'Visitor is not receiving media');
|
||||
await visitor.waitForRemoteStreams(1);
|
||||
|
||||
await visitor.driver.waitUntil(() => vVisitors.hasVisitorsDialog(), {
|
||||
timeout: 5000,
|
||||
timeoutMsg: 'Missing visitors dialog'
|
||||
});
|
||||
|
||||
expect((await mVisitors.getVisitorsCount()).trim()).toBe('1');
|
||||
expect((await mVisitors.getVisitorsHeaderFromParticipantsPane()).trim()).toBe('Viewers 1');
|
||||
});
|
||||
});
|
||||
@@ -245,8 +245,17 @@ export const config: WebdriverIO.MultiremoteConfig = {
|
||||
// If we are running the iFrameApi tests, we need to mark it as such and if needed to create the proxy
|
||||
// and connect to it.
|
||||
if (testProperties.useWebhookProxy && testsConfig.webhooksProxy.enabled && !globalAny.ctx.webhooksProxy) {
|
||||
// Note this prevents iframe and jaas test from running together.
|
||||
const tenant = testsConfig.jaas.enabled ? testsConfig.jaas.tenant : testsConfig.iframe.tenant;
|
||||
let tenant = testsConfig.jaas.tenant;
|
||||
|
||||
if (!testProperties.useJaas) {
|
||||
tenant = testsConfig.iframe.tenant;
|
||||
}
|
||||
if (!tenant) {
|
||||
console.log(`Can not configure WebhookProxy, missing tenant in config. Skipping ${testName}.`);
|
||||
globalAny.ctx.skipSuiteTests = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
globalAny.ctx.webhooksProxy = new WebhookProxy(
|
||||
`${testsConfig.webhooksProxy.url}?tenant=${tenant}&room=${globalAny.ctx.roomName}`,
|
||||
|
||||
Reference in New Issue
Block a user