Compare commits

...

23 Commits

Author SHA1 Message Date
Hristo Terezov
64307306f9 fix(participants-pane): Allow multiline text in footer context menu
Fixes text truncation issue in participants pane footer context menu
items (the three-dot menu). Menu items now wrap naturally to multiple
lines instead of being truncated mid-word, improving readability for
languages with longer text strings like French.

The fix uses standard CSS properties (whiteSpace, wordBreak,
overflowWrap) without browser-specific prefixes. It is specific to
the footer context menu in the participants pane and does not affect
other context menus.
2025-09-29 14:32:52 -05:00
Дамян Минков
36ce5a1661 feat(token_verification): Adds more token failure reasons on verify room. (#16473)
* feat(token_verification): Adds more token failure reasons on verify room.

* squash: Update resources/prosody-plugins/token/util.lib.lua

Co-authored-by: bgrozev <boris@jitsi.org>

---------

Co-authored-by: bgrozev <boris@jitsi.org>
2025-09-29 08:22:31 -05:00
xinfei.wu
23c831e9b0 fix: check if asapKeyServer is empty string 2025-09-27 08:27:29 -05:00
Дамян Минков
e6fbeb9458 feat(conference): Process unauthenticated access disabled error. (#16465)
* feat(conference): Process unauthenticated access disabled error.

Shows notification with a button to login.

* squash: Fix texts.

* feat(visitors): Propagate and use allowUnauthenticatedAccess.

* squash: Avoids always sending a value, even when not set.

* squash: Rename error.

* squash: Fix comments.

* squash: Move check before log.
2025-09-26 14:05:19 -05:00
damencho
e15a59c994 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2095.0.0+43bbd502...v2097.0.0+58646fc3
2025-09-26 14:05:05 -05:00
Hristo Terezov
f5e1a97d64 feat(i18n): Complete Bulgarian translation with missing keys (#16464)
* feat(i18n): Complete Bulgarian translation with missing keys

- Added 587+ missing Bulgarian translations for all untranslated keys
- Achieved 100% translation coverage (1,469/1,469 keys)
- Maintained consistency with existing Bulgarian terminology and tone
- Removed 34 orphaned keys that weren't present in English version
- Applied proper 4-space indentation formatting
- Sorted all keys alphabetically to match project standards
- Created comprehensive professional Bulgarian localization

---------

Co-authored-by: Дамян Минков <damencho@jitsi.org>
Co-authored-by: bgrozev <boris@jitsi.org>
2025-09-26 11:06:28 -05:00
Calin-Teodor
cd25652182 .github(workflows): add clean Xcode step 2025-09-26 16:35:11 +03:00
damencho
2bf0b1922f feat(visitors): Adds support for visitors voting in polls. 2025-09-26 07:04:02 -05:00
Дамян Минков
469406d7cd feat(polls): Move polls to using a component (#16406)
* squash: Renames module.

* squash: Loads polls component.

* squash: Attach needed logic when components/hosts load.

* squash: Moves to use component.

* squash: Uses json-message format with types.

* squash: Checks for polls support.

* squash: Fixes comments and moves validate polls to backend.

* squash: Fix debian build.

* fix(polls): Fixes polls in breakout rooms.

* squash: Further simplify types.

Separate type that needs to go into ljm and those used only for the UI part.
Simplify answer/voter type to be unified across operations which simplifies and its logic.

* squash: Change voters structure to be {id, name}.

* squash: Update react/features/conference/functions.any.ts

Co-authored-by: Saúl Ibarra Corretgé <saghul@jitsi.org>

* squash: Drops roomJid from messages. Uses the connection information as breakout does.

---------

Co-authored-by: Saúl Ibarra Corretgé <saghul@jitsi.org>
2025-09-25 16:46:06 -05:00
damencho
60679aa2d3 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2094.0.0+13aeca6c...v2095.0.0+43bbd502
2025-09-25 15:32:25 -05:00
Hristo Terezov
319e8d1e4b feat(CLAUDE.md): Add 2025-09-25 08:23:51 -05:00
Calin-Teodor
40b8d6168b feat(base/flags): add warning for unsupported feature flags 2025-09-25 16:21:56 +03:00
Hristo Terezov
753d0399c9 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2091.0.0+518cec5f...v2094.0.0+13aeca6c
2025-09-24 18:33:23 -05:00
Hristo Terezov
2475aff21a feat(RN): Add querySelector polyfill. 2025-09-24 16:48:40 -05:00
damencho
121aabeb25 fix(muc_displayname): Handles few more cases for missing nick element. 2025-09-24 10:32:17 -05:00
damencho
086f01aa5b fix(i18n): Uses language-variant for translations.
This way we can take advantage of internal i18next mechanism for fallback from en-US to en and from es-ES to es and so on.
2025-09-24 08:31:22 -05:00
Mihaela Dumitru
6b6920693b feat(lobby) integrate login in lobby + configs (#16401)
* feat(lobby) integrate login in lobby + configs

* fixed toolboxContainer styles, used HangupButton

* make hangup button visible by default

* use hangup button

* feat(prejoin): fixed indent, import extension

* squash: Restore back wait for owner dialog.

* squash: Drops not used state and functions.

---------

Co-authored-by: Calin-Teodor <calin.chitu@8x8.com>
Co-authored-by: damencho <damencho@jitsi.org>
2025-09-22 16:56:29 +03:00
Calin-Teodor
566b3ba2d5 chore(android): apply edge to edge if supported or enforced 2025-09-22 13:17:32 +03:00
bgrozev
7373123166 fix: Fix the tenant used for webhook proxy. (#16445)
* fix: Fix the tenant used for webhook proxy.

* squash: Linting, skip test if WH proxy is required but not configured.

* ref: Change visitorsLive to use the JaaS utils.

* ref: Move visitorsLive to the specs/jaas.

* squash: Fix import paths.

* fix: Use the iframe configured tenant for iframe tests.
2025-09-19 14:32:36 -05:00
damencho
cc312877f4 fix(muc_displayname): Adds a nil check. 2025-09-19 09:45:15 -05:00
Calin-Teodor
eb8b6159ec feat(notifications/native): fix case for no title notifications 2025-09-19 13:38:17 +03:00
damencho
f9d8feacd2 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2089.0.0+75c1c6ff...v2091.0.0+518cec5f
2025-09-18 14:29:55 -05:00
damencho
f5668b6e8b feat(visitors): Retries as a visitor when max occupants reached. 2025-09-18 14:29:55 -05:00
81 changed files with 3228 additions and 938 deletions

View File

@@ -139,6 +139,12 @@ jobs:
xcode-select -p
sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
xcodebuild -version
- name: clean Xcode
run: |
rm -rf ios/sdk/out
xcodebuild clean \
-workspace ios/jitsi-meet.xcworkspace \
-scheme JitsiMeetSDK
- name: setup-cocoapods
uses: ruby/setup-ruby@v1
with:
@@ -149,9 +155,6 @@ jobs:
working-directory: ./ios
run: bundle exec pod install --repo-update --deployment
- run: |
xcodebuild clean \
-workspace ios/jitsi-meet.xcworkspace \
-scheme JitsiMeetSDK
xcodebuild -downloadPlatform iOS -buildVersion 18.2
xcodebuild archive \
-workspace ios/jitsi-meet.xcworkspace \

267
CLAUDE.md Normal file
View 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

View File

@@ -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();

View File

@@ -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.

View File

@@ -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

View File

@@ -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"

View File

@@ -12,13 +12,13 @@
"en": "English",
"eo": "Esperanto",
"es": "Español",
"esUS": "Español (Latinoamérica)",
"es-US": "Español (Latinoamérica)",
"et": "Eesti",
"eu": "Euskara",
"fa": "فارسی",
"fi": "Suomi",
"fr": "Français",
"frCA": "Français (Canada)",
"fr-CA": "Français (Canada)",
"gl": "Galego",
"he": "עברית",
"hi": "हिन्दी",
@@ -43,7 +43,7 @@
"oc": "Occitan",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
"pt-BR": "Português (Brasil)",
"ro": "Română",
"ru": "Русский",
"sc": "Sardu",
@@ -56,6 +56,6 @@
"tr": "Türkçe",
"uk": "Українська",
"vi": "Tiếng Việt",
"zhCN": "中文(简体)",
"zhTW": "中文(繁體)"
"zh-CN": "中文(简体)",
"zh-TW": "中文(繁體)"
}

File diff suppressed because it is too large Load Diff

View File

@@ -280,7 +280,6 @@
"Submit": "Submit",
"Understand": "I understand, keep me muted for now",
"UnderstandAndUnmute": "I understand, please unmute me",
"WaitForHostMsg": "The conference has not yet started because no moderators have yet arrived. If you'd like to become a moderator please log-in. Otherwise, please wait.",
"WaitForHostNoAuthMsg": "The conference has not yet started because no moderators have yet arrived. Please wait.",
"WaitingForHostButton": "Wait for moderator",
"WaitingForHostTitle": "Waiting for a moderator…",
@@ -523,6 +522,7 @@
"tokenAuthFailedWithReasons": "Sorry, you're not allowed to join this call. Possible reasons: {{reason}}",
"tokenAuthUnsupported": "Token URL is not supported.",
"transcribing": "Transcribing",
"unauthenticatedAccessDisabled": "This call requires authentication. Please login in order to proceed.",
"unlockRoom": "Remove meeting $t(lockRoomPassword)",
"user": "User",
"userIdentifier": "User identifier",
@@ -748,7 +748,8 @@
"notificationTitle": "Lobby",
"passwordJoinButton": "Join",
"title": "Lobby",
"toggleLabel": "Enable lobby"
"toggleLabel": "Enable lobby",
"waitForModerator": "The conference has not yet started because no moderators have yet arrived. If you'd like to become a moderator please log-in. Otherwise, please wait."
},
"localRecording": {
"clientState": {
@@ -964,6 +965,9 @@
"by": "By {{ name }}",
"closeButton": "Close poll",
"create": {
"accessibilityLabel": {
"send": "Send poll"
},
"addOption": "Add option",
"answerPlaceholder": "Option {{index}}",
"cancel": "Cancel",
@@ -972,8 +976,7 @@
"pollQuestion": "Poll Question",
"questionPlaceholder": "Ask a question",
"removeOption": "Remove option",
"save": "Save",
"send": "Send"
"save": "Save"
},
"errors": {
"notUniqueOption": "Options must be unique"
@@ -1434,8 +1437,8 @@
"linkToSalesforce": "Link to Salesforce",
"lobbyButtonDisable": "Disable lobby mode",
"lobbyButtonEnable": "Enable lobby mode",
"login": "Log-in",
"logout": "Log-out",
"login": "Log In",
"logout": "Log Out",
"love": "Heart",
"lowerYourHand": "Lower your hand",
"moreActions": "More actions",

12
package-lock.json generated
View File

@@ -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/v2097.0.0+58646fc3/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/v2097.0.0+58646fc3/lib-jitsi-meet.tgz",
"integrity": "sha512-CViaK78aH8jmlmUkx+J3StpYFDDWyd5ry2CIoBEJx9uZtSnqczVjOBkbx/9VFifd8ZTr+VClfDRM/ZpkJye8rg==",
"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/v2097.0.0+58646fc3/lib-jitsi-meet.tgz",
"integrity": "sha512-CViaK78aH8jmlmUkx+J3StpYFDDWyd5ry2CIoBEJx9uZtSnqczVjOBkbx/9VFifd8ZTr+VClfDRM/ZpkJye8rg==",
"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",

View File

@@ -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/v2097.0.0+58646fc3/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",

View File

@@ -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

View File

@@ -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.
*

View File

@@ -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 } />

View File

@@ -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>
);

View File

@@ -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.
*

View File

@@ -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;

View File

@@ -13,6 +13,7 @@ import {
import { sendAnalytics } from '../../analytics/functions';
import { reloadNow } from '../../app/actions';
import { IStore } from '../../app/types';
import { login } from '../../authentication/actions.any';
import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name/actions';
import { isVpaasMeeting } from '../../jaas/functions';
@@ -26,7 +27,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 +79,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 +208,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,12 +235,24 @@ 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: {
const [ type, msg ] = error.params;
let descriptionKey;
let descriptionKey, customActionNameKey, customActionHandler;
let titleKey = 'dialog.tokenAuthFailed';
if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.NO_MAIN_PARTICIPANTS) {
@@ -237,9 +264,15 @@ function _conferenceFailed({ dispatch, getState }: IStore, next: Function, actio
descriptionKey = 'visitors.notification.notAllowedPromotion';
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.ROOM_CREATION_RESTRICTION) {
descriptionKey = 'dialog.errorRoomCreationRestriction';
} else if (type === JitsiConferenceErrors.AUTH_ERROR_TYPES.ROOM_UNAUTHENTICATED_ACCESS_DISABLED) {
titleKey = 'dialog.unauthenticatedAccessDisabled';
customActionNameKey = [ 'toolbar.login' ];
customActionHandler = [ () => dispatch(login()) ];
}
dispatch(showErrorNotification({
customActionNameKey,
customActionHandler,
descriptionKey,
hideErrorSupportLink: true,
titleKey
@@ -300,6 +333,8 @@ function _conferenceJoined({ dispatch, getState }: IStore, next: Function, actio
requireDisplayName
} = getState()['features/base/config'];
retryAsVisitorOnMaxError = true;
dispatch(removeLobbyChatParticipant(true));
pendingSubjectChange && dispatch(setSubject(pendingSubjectChange));

View File

@@ -107,6 +107,7 @@ export interface IJitsiConference {
getParticipantById: Function;
getParticipantCount: Function;
getParticipants: Function;
getPolls: Function;
getRole: Function;
getShortTermCredentials: Function;
getSpeakerStats: () => ISpeakerStats;

View File

@@ -464,6 +464,7 @@ export interface IConfig {
lobby?: {
autoKnock?: boolean;
enableChat?: boolean;
showHangUp?: boolean;
};
localRecording?: {
disable?: boolean;

View File

@@ -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

View File

@@ -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')
}
};

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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' />

View File

@@ -1,7 +1,5 @@
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
import { iAmVisitor } from '../visitors/functions';
/**
* Tells whether or not the notifications should be displayed within
@@ -30,5 +28,11 @@ export function shouldDisplayNotifications(stateful: IStateful) {
export function arePollsDisabled(stateful: IStateful) {
const state = toState(stateful);
return state['features/base/config']?.disablePolls || iAmVisitor(state);
const { conference } = state['features/base/conference'];
if (!conference?.getPolls()?.isSupported()) {
return true;
}
return state['features/base/config']?.disablePolls;
}

View File

@@ -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,

View File

@@ -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());
}
}
/**

View File

@@ -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));

View File

@@ -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;
}

View 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);
}

View File

@@ -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 (

View File

@@ -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',

View File

@@ -244,6 +244,27 @@ const Notification = ({
window.open(supportUrl, '_blank', 'noopener');
}, [ supportUrl ]);
const processCustomActions
= (key?: string[], handler?: Function[], type?: string[]): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
if (key?.length && handler?.length) {
return key.map((customAction: string, customActionIndex: number) => {
return {
content: t(customAction),
onClick: () => {
if (handler?.[customActionIndex]()) {
onDismiss();
}
},
type: type?.[customActionIndex],
testId: customAction
};
});
}
return [];
};
const mapAppearanceToButtons = useCallback((): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
switch (appearance) {
@@ -262,7 +283,7 @@ const Notification = ({
});
}
return buttons;
return processCustomActions(customActionNameKey, customActionHandler, customActionType).concat(buttons);
}
case NOTIFICATION_TYPE.WARNING:
return [
@@ -273,22 +294,7 @@ const Notification = ({
];
default:
if (customActionNameKey?.length && customActionHandler?.length) {
return customActionNameKey.map((customAction: string, customActionIndex: number) => {
return {
content: t(customAction),
onClick: () => {
if (customActionHandler?.[customActionIndex]()) {
onDismiss();
}
},
type: customActionType?.[customActionIndex],
testId: customAction
};
});
}
return [];
return processCustomActions(customActionNameKey, customActionHandler, customActionType);
}
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink, supportUrl ]);

View File

@@ -44,7 +44,14 @@ const useStyles = makeStyles()(theme => {
right: 0,
top: '-8px',
transform: 'translateY(-100%)',
width: '283px'
width: '283px',
// Allow text in menu items to wrap to multiple lines.
'& [role="button"] > div > span, & [role="menuitem"] > div > span': {
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}
},
text: {

View File

@@ -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;

View File

@@ -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;
};
};
}

View File

@@ -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';

View File

@@ -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
};
};

View File

@@ -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 }

View File

@@ -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'));

View File

@@ -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 }

View File

@@ -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 } />

View File

@@ -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 }) }

View File

@@ -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>}

View File

@@ -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 } />
</> : <>

View File

@@ -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') }

View File

@@ -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>)

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
import { IReduxState } from '../app/types';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { iAmVisitor } from '../visitors/functions';
import { IAnswerData } from './types';
@@ -72,6 +73,10 @@ export function hasIdenticalAnswers(currentAnswers: Array<IAnswerData>): boolean
* @returns {boolean} - Returns true if the participant is not allowed to create polls.
*/
export function isCreatePollDisabled(state: IReduxState) {
if (iAmVisitor(state)) {
return true;
}
const { pollCreationRequiresPermission } = state['features/dynamic-branding'];
if (!pollCreationRequiresPermission) {

View File

@@ -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));
}

View File

@@ -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,

View File

@@ -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>;
}

View File

@@ -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

View File

@@ -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: {

View File

@@ -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

View File

@@ -55,25 +55,35 @@ Setting up configuration for the main prosody is a manual process:
s2sout_override = {
["conference.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
["v1.meet.jitsi"] = "tcp://127.0.0.1:52691"; -- needed for v1.meet.jitsi->visitors.jitmeet.example.com
["polls.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
["conference.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["polls.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
["conference.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
["v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
["polls.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
["conference.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
["v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
["polls.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
["conference.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
["v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
["polls.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
["conference.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
["v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
["polls.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
["conference.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
["v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
["polls.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
["conference.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
["v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
["polls.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
}
-- allowed list of server-2-server connections
s2s_whitelist = {
"conference.v1.meet.jitsi", "conference.v2.meet.jitsi", "conference.v3.meet.jitsi", "conference.v4.meet.jitsi",
"conference.v5.meet.jitsi", "conference.v6.meet.jitsi", "conference.v7.meet.jitsi", "conference.v8.meet.jitsi"
"conference.v5.meet.jitsi", "conference.v6.meet.jitsi", "conference.v7.meet.jitsi", "conference.v8.meet.jitsi",
'polls.v1.meet.jitsi', 'polls.v2.meet.jitsi', 'polls.v3.meet.jitsi', 'polls.v4.meet.jitsi',
'polls.v5.meet.jitsi', 'polls.v6.meet.jitsi', 'polls.v7.meet.jitsi', 'polls.v8.meet.jitsi'
};
```

View File

@@ -38,12 +38,14 @@ s2s_whitelist = {
'conference.jitmeet.example.com', -- needed for visitors to send messages to main room
'visitors.jitmeet.example.com'; -- needed for sending promotion request to visitors.jitmeet.example.com component
'jitmeet.example.com'; -- unavailable presences back to main room
'polls.jitmeet.example.com'; -- polls component
};
s2sout_override = {
["conference.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for visitors to send messages to main room
["jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for the main room when connecting in to send main participants
["visitors.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for sending promotion request to visitors.jitmeet.example.com component
['polls.jitmeet.example.com'] = "tcp://127.0.0.1:5269"; -- polls component
}
external_service_secret = '__turnSecret__';
@@ -106,6 +108,7 @@ VirtualHost 'vX.meet.jitsi'
'smacks';
'jiconop';
'conference_duration';
'features_identity';
}
main_muc = 'conference.vX.meet.jitsi';
@@ -138,3 +141,5 @@ Component 'conference.vX.meet.jitsi' 'muc'
};
muc_room_locking = false
muc_room_default_public_jids = true
Component 'polls.vX.meet.jitsi' 'polls_component'

View File

@@ -1,5 +1,6 @@
#### room._data fields
- allModerators - If is set to true, all authenticated participants are moderators. You need a custom module to set participants as moderators based on the field.
- allowUnauthenticatedAccess - Option that works only with tenants. When it is false only jwt authenticated participants(visitors) are allowed in the meeting and their token(or sub claim for jaas) should match the one in the room. Default value when missing is true.
- av_can_unmute - Default value is missing/true. If set to false, when the first moderator joining the room we enable AV moderation and disable the ability for participants to unmute themselves.
- av_first_moderator_joined - When av_can_unmute is set to false, this flag is used to indicate that the first moderator has joined the room and AV moderation is enabled.
- breakout_rooms - A table containing breakout rooms created in the main room. The keys are the JIDs of the breakout rooms, and the values are their subjects.

View File

@@ -698,6 +698,10 @@ local function iq_from_main_handler(event)
local createdTimestamp = node.attr.createdTimestamp;
room.created_timestamp = createdTimestamp and tonumber(createdTimestamp) or nil;
if node.attr.allowUnauthenticatedAccess then
room._data.allowUnauthenticatedAccess = node.attr.allowUnauthenticatedAccess == 'true';
end
if node.attr.lobby == 'true' then
room._main_room_lobby_enabled = true;
elseif node.attr.lobby == 'false' then
@@ -749,6 +753,13 @@ local function iq_from_main_handler(event)
end
end
local pollsEl = node:get_child('polls');
if pollsEl then
local polls = json.decode(pollsEl:get_text());
-- let's find is there a new poll
module:fire_event('jitsi-polls-update', { room = room; polls = polls; });
end
if fire_jicofo_unlock then
-- everything is connected allow participants to join
module:fire_event('jicofo-unlock-room', { room = room; fmuc_fired = true; });
@@ -814,6 +825,36 @@ function route_s2s_stanza(event)
end
end
module:hook('answer-poll', function(answerData)
local room = answerData.room;
local room_jid = room_jid_match_rewrite(jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain));
-- now send it to the main prosody
local data = {
answers = answerData.data.answers;
command = 'answer-poll';
pollId = answerData.pollId;
roomJid = room_jid,
senderId = answerData.voterId;
senderName = answerData.voterName;
type = 'polls';
};
local data_str, error = json.encode(data);
if not data_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
local stanza = st.message({
from = module.host,
to = 'polls.'..main_domain
})
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet"; roomJid = room_jid; })
:text(data_str)
:up();
room:route_stanza(stanza);
end);
-- routing to sessions in mod_s2s is -1 and -10, we want to hook before that to make sure to is correct
-- or if we want to filter that stanza
module:hook("route/remote", route_s2s_stanza, 10);

View File

@@ -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

View File

@@ -94,7 +94,7 @@ function module.add_host(host_module)
token_util = module:require "token/util".new(host_module);
if asapKeyServer then
if asapKeyServer ~= "" then
-- init token util with our asap keyserver
token_util:set_asap_key_server(asapKeyServer)
end

View File

@@ -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

View File

@@ -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);

View File

@@ -0,0 +1,518 @@
-- This module provides persistence for the "polls" feature,
-- by keeping track of the state of polls in each room, and sending
-- that state to new participants when they join.
local it = require 'util.iterators';
local json = require 'cjson.safe';
local array = require 'util.array';
local st = require("util.stanza");
local jid = require "util.jid";
local util = module:require("util");
local muc = module:depends("muc");
local NS_NICK = 'http://jabber.org/protocol/nick';
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_healthcheck_room = util.is_healthcheck_room;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local table_compare = util.table_compare;
local POLLS_LIMIT = 128;
local POLL_PAYLOAD_LIMIT = 1024;
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
if not main_virtual_host then
module:log('warn', 'No muc_mapper_domain_base option set.');
return;
end
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
-- this is the main virtual host of the main prosody that this vnode serves
local main_domain = module:get_option_string('main_domain');
-- only the visitor prosody has main_domain setting
local is_visitor_prosody = main_domain ~= nil;
-- Logs a warning and returns true if a room does not
-- have poll data associated with it.
local function check_polls(room)
if room.polls == nil then
module:log("warn", "no polls data in room");
return true;
end
return false;
end
local function validate_polls(data)
if type(data) ~= 'table' then
return false;
end
if data.type ~= 'polls' or type(data.pollId) ~= 'string' then
return false;
end
if data.command ~= 'new-poll' and data.command ~= 'answer-poll' then
return false;
end
if type(data.answers) ~= 'table' then
return false;
end
if data.command == "new-poll" then
if type(data.question) ~= 'string' then
return false;
end
for _, answer in ipairs(data.answers) do
if type(answer) ~= "table" or type(answer.name) ~= "string" then
return false;
end
end
return true;
elseif data.command == "answer-poll" then
for _, answer in ipairs(data.answers) do
if type(answer) ~= "boolean" then
return false;
end
end
return true;
end
return false;
end
--- Returns a table having occupant id and occupant name.
--- If the id cannot be extracted from nick a nil value is returned same and for name
local function get_occupant_details(occupant)
if not occupant then
return nil
end
local presence = occupant:get_presence();
local occupant_name;
if presence then
occupant_name = presence:get_child_text('nick', NS_NICK);
end
local _, _, occupant_id = jid.split(occupant.nick)
if not occupant_id then
return nil
end
return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
end
local function send_polls_message(room, data_str, to)
local stanza = st.message({
from = module.host,
to = to
})
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
:text(data_str)
:up();
room:route_stanza(stanza);
end
local function send_polls_message_to_all(room, data_str)
for _, room_occupant in room:each_occupant() do
-- in case of visitor node send only to visitors
if not is_visitor_prosody or room_occupant.role == 'visitor' then
send_polls_message(room, data_str, room_occupant.jid);
end
end
end
-- Keeps track of the current state of the polls in each room,
-- by listening to "new-poll" and "answer-poll" messages,
-- and updating the room poll data accordingly.
-- This mirrors the client-side poll update logic.
module:hook('message/host', function(event)
local session, stanza = event.origin, event.stanza;
-- we are interested in all messages without a body that are not groupchat
if stanza.attr.type == 'groupchat' or stanza:get_child('body') then
return;
end
local json_message = stanza:get_child('json-message', 'http://jitsi.org/jitmeet')
or stanza:get_child('json-message');
if not json_message then
return;
end
local room;
if session.type == 's2sin' then
if not json_message.attr.roomJid then
module:log('warn', 'No room jid found in %s', stanza);
return;
end
room = get_room_from_jid(room_jid_match_rewrite(json_message.attr.roomJid));
else
room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
end
if not room then
module:log('warn', 'No room found found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
return;
end
local json_message_text = json_message:get_text();
if string.len(json_message_text) >= POLL_PAYLOAD_LIMIT then
module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
return true;
end
local data, error = json.decode(json_message_text);
if error then
module:log('error', 'Error decoding data error:%s Sender: %s to:%s', error, stanza.attr.from, stanza.attr.to);
return true;
end
if not data or (data.command ~= "new-poll" and data.command ~= "answer-poll") then
return;
end
if not validate_polls(data) then
module:log('error', 'Invalid poll data. Sender: %s (%s)', stanza.attr.from, json_message_text);
return true;
end
local occupant_details;
if session.type ~= 's2sin' then
local occupant_jid = stanza.attr.from;
occupant = room:get_occupant_by_real_jid(occupant_jid);
if not occupant then
module:log("error", "Occupant sending msg %s was not found in room %s", occupant_jid, room.jid)
return;
end
occupant_details = get_occupant_details(occupant)
if not occupant_details then
module:log("error", "Cannot retrieve poll creator or voter id and name for %s from %s",
occupant.jid, room.jid)
return
end
else
-- this is a message from a visitor prosody, we will trust it
occupant_details = { occupant_id = data.senderId; occupant_name = data.senderName; };
end
if data.command == "new-poll" then
if is_visitor_prosody then
module:log("error", "Poll cannot be created on visitor node.");
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll cannot be created by visitor node'));
return true;
end
if check_polls(room) then return end
local poll_creator = occupant_details;
if room.polls.count >= POLLS_LIMIT then
module:log("error", "Too many polls created in %s", room.jid)
return true;
end
if room.polls.by_id[data.pollId] ~= nil then
module:log("error", "Poll already exists: %s", data.pollId);
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
return true;
end
if room.jitsiMetadata and room.jitsiMetadata.permissions
and room.jitsiMetadata.permissions.pollCreationRestricted
and not is_feature_allowed('create-polls', session.jitsi_meet_context_features) then
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
return true;
end
local answers = {}
local compact_answers = {}
for i, a in ipairs(data.answers) do
table.insert(answers, { name = a.name });
table.insert(compact_answers, { key = i, name = a.name});
end
local poll = {
pollId = data.pollId,
senderId = poll_creator.occupant_id,
senderName = poll_creator.occupant_name,
question = data.question,
answers = answers
};
room.polls.by_id[data.pollId] = poll
table.insert(room.polls.order, poll)
room.polls.count = room.polls.count + 1;
local pollData = {
event = event,
room = room,
poll = {
pollId = data.pollId,
senderId = poll_creator.occupant_id,
senderName = poll_creator.occupant_name,
question = data.question,
answers = compact_answers
}
}
-- now send message to all participants
data.senderId = poll_creator.occupant_id;
data.type = 'polls';
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
send_polls_message_to_all(room, json_msg_str);
module:context(jid.host(room.jid)):fire_event('poll-created', pollData);
elseif data.command == "answer-poll" then
if check_polls(room) then return end
local poll = room.polls.by_id[data.pollId];
if poll == nil then
module:log("warn", "answering inexistent poll %s", data.pollId);
return;
end
local voter = occupant_details;
local answers = {};
for vote_option_idx, vote_flag in ipairs(data.answers) do
local answer = poll.answers[vote_option_idx]
table.insert(answers, {
key = vote_option_idx,
value = vote_flag,
name = answer.name,
});
if vote_flag then
local voters = answer.voters;
if not voters then
answer.voters = {};
voters = answer.voters;
end
table.insert(voters, {
id = voter.occupant_id;
name = vote_flag and voter.occupant_name or nil;
});
end
end
local answerData = {
data = data,
event = event,
room = room,
pollId = poll.pollId,
voterName = voter.occupant_name,
voterId = voter.occupant_id,
answers = answers,
}
data.senderId = voter.occupant_id;
data.senderName = voter.occupant_name;
data.type = 'polls';
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
return;
end
send_polls_message_to_all(room, json_msg_str);
module:context(jid.host(room.jid)):fire_event('answer-poll', answerData);
end
return true;
end);
-- Find in which poll in newPolls we have updated answers
-- @returns poll, senderId, array of boolean values for the answers of this sender
function find_updated_poll(oldPolls, newPolls)
for _, v in pairs(newPolls) do
local existing_poll = oldPolls[v.pollId];
local senderId;
for idx, newAnswer in ipairs(v.answers) do
-- let's examine now the voters
-- Create lookup tables using id as key for efficient searching
local oldLookup = {}
local newLookup = {}
-- Build lookup table for old array
if existing_poll.answers[idx].voters then
for _, element in ipairs(existing_poll.answers[idx].voters) do
oldLookup[element.id] = element
end
end
-- Build lookup table for new array
if newAnswer.voters then
for _, element in ipairs(newAnswer.voters) do
newLookup[element.id] = element
end
end
-- Find removed elements (in old but not in new)
if existing_poll.answers[idx].voters then
for _, element in ipairs(existing_poll.answers[idx].voters) do
if not newLookup[element.id] then
senderId = element.id;
end
end
end
-- Find added elements (in new but not in old)
if newAnswer.voters then
for _, element in ipairs(newAnswer.voters) do
if not oldLookup[element.id] then
senderId = element.id;
end
end
end
end
if senderId ~= nil then
-- an array of true/false values for this sender
local senderAnswers = {};
for idx, newAnswer in ipairs(v.answers) do
senderAnswers[idx] = false;
if newAnswer.voters then
for _, element in ipairs(newAnswer.voters) do
if element.id == senderId then
senderAnswers[idx] = true;
end
end
end
end
return v, senderId, senderAnswers;
end
end
end
local setup_muc_component = function(host_module, host)
-- Sets up poll data in new rooms.
host_module:hook("muc-room-created", function(event)
local room = event.room;
if is_healthcheck_room(room.jid) then return end
room.polls = {
by_id = {};
order = {};
count = 0;
};
end);
-- Sends the current poll state to new occupants after joining a room.
host_module:hook("muc-occupant-joined", function(event)
local room = event.room;
if is_healthcheck_room(room.jid) then return end
if room.polls == nil or #room.polls.order == 0 then
return
end
local data = {
command = "old-polls",
polls = {},
type = 'polls'
};
for i, poll in ipairs(room.polls.order) do
data.polls[i] = {
pollId = poll.pollId,
senderId = poll.senderId,
senderName = poll.senderName,
question = poll.question,
answers = poll.answers
};
end
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
send_polls_message(room, json_msg_str, event.occupant.jid);
end);
-- Handles poll updates coming for a visitor node, the event contain polls structure
-- like 'old-polls' one
host_module:hook('jitsi-polls-update', function(event)
local polls_command, room = event.polls, event.room;
-- this is the initial state coming from the main prosody when only jicofo is in the room
if room.polls.count == 0 and it.count(room:each_occupant()) == 1 then
for i, v in ipairs(polls_command.polls) do
room.polls.by_id[v.pollId] = v;
table.insert(room.polls.order, v);
room.polls.count = room.polls.count + 1;
end
return;
end
-- at this point we need to find which is the new poll
local new_poll;
for _, v in pairs(polls_command.polls) do
if not room.polls.by_id[v.pollId] then
new_poll = v;
break;
end
end
if not new_poll then
-- this is an update of the voters in some of the existing polls
local updatedPoll, senderId, answers = find_updated_poll(room.polls.by_id, polls_command.polls);
if not updatedPoll then
module:log('warn', 'no new or updated poll found in update for room %s', room.jid);
return;
end
local data = {
answers = answers,
command = 'answer-poll',
pollId = updatedPoll.pollId,
senderId = senderId,
roomJid = internal_room_jid_match_rewrite(room.jid),
type = 'polls'
};
-- we need to update the history
room.polls.by_id[updatedPoll.pollId].answers = updatedPoll.answers;
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
send_polls_message_to_all(room, json_msg_str);
return;
end
room.polls.by_id[new_poll.pollId] = new_poll;
table.insert(room.polls.order, new_poll);
room.polls.count = room.polls.count + 1;
local data = {
answers = new_poll.answers,
command = 'new-poll',
pollId = new_poll.pollId,
question = new_poll.question,
senderId = new_poll.senderId,
roomJid = internal_room_jid_match_rewrite(room.jid),
type = 'polls'
};
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
send_polls_message_to_all(room, json_msg_str);
end);
end
process_host_module(muc_domain_prefix..'.'..main_virtual_host, setup_muc_component);
process_host_module('breakout.' .. main_virtual_host, setup_muc_component);
process_host_module(main_virtual_host, function(host_module)
module:context(host_module.host):fire_event('jitsi-add-identity', {
name = 'polls'; host = module.host;
});
end);

View File

@@ -76,12 +76,21 @@ local function verify_user(session, stanza)
end
if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end
if not token_util:verify_room(session, stanza.attr.to) then
module:log("error", "Token %s not allowed to join: %s",
tostring(session.auth_token), tostring(stanza.attr.to));
session.send(
st.error_reply(
stanza, "cancel", "not-allowed", "Room and token mismatched"));
local res, err, reason = token_util:verify_room(session, stanza.attr.to);
if not res then
if not err and not reason then
reason = 'Room and token mismatched';
end
module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s',
tostring(session.auth_token), tostring(stanza.attr.to), err, reason);
local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason);
if err then
response:tag(err, { xmlns = 'http://jitsi.org/jitmeet' });
end
session.send(response);
return false; -- we need to just return non nil
end
if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end

View File

@@ -70,7 +70,8 @@ local function send_visitors_iq(conference_service, room, type)
password = type ~= 'disconnect' and room:get_password() or '',
lobby = room._data.lobbyroom and 'true' or 'false',
meetingId = room._data.meetingId,
createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil
createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil,
allowUnauthenticatedAccess = room._data.allowUnauthenticatedAccess ~= nil and tostring(room._data.allowUnauthenticatedAccess) or nil
});
if type == 'update' then
@@ -94,6 +95,31 @@ local function send_visitors_iq(conference_service, room, type)
end
visitors_iq:up();
end
if room.polls and room.polls.count > 0 then
-- polls created in the room that we want to send to the visitor nodes
local data = {
command = "old-polls",
polls = {},
type = 'polls'
};
for i, poll in ipairs(room.polls.order) do
data.polls[i] = {
pollId = poll.pollId,
senderId = poll.senderId,
senderName = poll.senderName,
question = poll.question,
answers = poll.answers
};
end
local json_msg_str, error = json.encode(data);
if not json_msg_str then
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
end
visitors_iq:tag('polls'):text(json_msg_str):up();
end
end
visitors_iq:up();
@@ -472,6 +498,38 @@ process_host_module(main_muc_component_config, function(host_module, host)
end
end
end, -2);
host_module:hook('poll-created', function (event)
local room = event.room;
if not visitors_nodes[room.jid] then
return;
end
-- we need to update all vnodes
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
send_visitors_iq(conference_service, room, 'update');
end
end);
host_module:hook('answer-poll', function (event)
local room, stanza = event.room, event.event.stanza;
if not visitors_nodes[room.jid] then
return;
end
local from = stanza.attr.from;
-- we need to update all vnodes
local vnodes = visitors_nodes[room.jid].nodes;
for conference_service in pairs(vnodes) do
-- skip sending the answer to the node from where it originates
if conference_service ~= from then
send_visitors_iq(conference_service, room, 'update');
end
end
end);
end);
local function update_vnodes_for_room(event)

View File

@@ -213,6 +213,9 @@ end
local function disconnect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; });
if not room._connected_vnodes then
return;
end
room._connected_vnodes:set(vnode..'.meet.jitsi', nil);
if room._connected_vnodes:count() == 0 then

View File

@@ -15,6 +15,7 @@ local http_get_with_retry = main_util.http_get_with_retry;
local extract_subdomain = main_util.extract_subdomain;
local starts_with = main_util.starts_with;
local table_shallow_copy = main_util.table_shallow_copy;
local get_room_from_jid = main_util.get_room_from_jid;
local cjson_safe = require 'cjson.safe'
local timer = require "util.timer";
local async = require "util.async";
@@ -374,16 +375,22 @@ end
-- it and returns false in case verification was processed
-- and was not successful
function Util:verify_room(session, room_address)
if self.allowEmptyToken and session.auth_token == nil then
--module:log("debug", "Skipped room token verification - empty tokens are allowed");
return true;
end
-- extract room name using all chars, except the not allowed ones
local room,_,_ = jid.split(room_address);
if room == nil then
log("error",
"Unable to get name of the MUC room ? to: %s", room_address);
module:log('error', 'Unable to get name of the MUC room ? to: %s', room_address);
return false, 'invalid-room-address', 'Room address is invalid';
end
local room_instance = get_room_from_jid(jid.join(room, self.muc_domain));
if not room_instance then
module:log('info', 'Room does not exists:%s %s', room, debug.traceback());
return false, 'room-does-not-exist', 'Room does not exist';
end
if self.allowEmptyToken and session.auth_token == nil then
if room_instance._data.allowUnauthenticatedAccess == false then
return false, 'authentication-required', 'Authentication required';
end
return true;
end
@@ -395,11 +402,11 @@ function Util:verify_room(session, room_address)
module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room));
end
end
if not self.enableDomainVerification then
-- if auth_room is missing, this means user is anonymous (no token for
-- its domain) we let it through, jicofo is verifying creation domain
-- if auth_room is missing, this means user is anonymous (no token for its domain) we let it through
if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then
return false;
return false, 'room-mismatch', 'Room does not match the room from token';
end
return true;
@@ -439,7 +446,7 @@ function Util:verify_room(session, room_address)
-- not a regex
room_to_check = auth_room;
end
-- module:log("debug", "room to check: %s", room_to_check)
if not room_to_check then
if not self.requireRoomClaim then
-- if we do not require to have the room claim, and it is missing
@@ -447,17 +454,22 @@ function Util:verify_room(session, room_address)
return true;
end
return false;
return false, 'room-name-does-not-match', 'Room name cannot be matched to the one from token.';
end
end
if session.jitsi_meet_str_tenant
and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then
session.jitsi_meet_tenant_mismatch = true;
if room_instance._data.allowUnauthenticatedAccess == false then
return false;
end
module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s',
session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '',
session.jitsi_meet_context_group,
session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant);
session.jitsi_meet_tenant_mismatch = true;
end
local auth_domain = string.lower(session.jitsi_meet_domain);
@@ -474,7 +486,7 @@ function Util:verify_room(session, room_address)
-- deny access if option is missing
if not self.muc_domain_base then
module:log("warn", "No 'muc_domain_base' option set, denying access!");
return false;
return false, 'server-missing-config', 'Misconfiguration of server';
end
return room_address_to_verify == jid.join(

View File

@@ -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,

View File

@@ -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();
}
}

View 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%)');
});
});

View File

@@ -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');
});
});

View 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');
});
});

View File

@@ -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}`,