diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..554f2a3b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 100 +} diff --git a/install.sh b/install.sh index 4b24c8c6..940acb1f 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ set -e # Define an array of available versions -available_versions=("2.6.10" "2.6.12" "2.7.0-beta.2") # Add more versions as needed +available_versions=("2.6.10" "2.6.12" "2.7.0-beta.2" "2.7.12") # Add more versions as needed # Check if the user is root if [[ $EUID -eq 0 ]]; then diff --git a/src/2.7.12/.eslintignore b/src/2.7.12/.eslintignore new file mode 100644 index 00000000..0912b790 --- /dev/null +++ b/src/2.7.12/.eslintignore @@ -0,0 +1,2 @@ +Gruntfile.js +public/compatibility/* diff --git a/src/2.7.12/.eslintrc.js b/src/2.7.12/.eslintrc.js new file mode 100644 index 00000000..b93b3575 --- /dev/null +++ b/src/2.7.12/.eslintrc.js @@ -0,0 +1,32 @@ +module.exports = { + extends: 'airbnb', + parserOptions: { + ecmaVersion: 2020, + }, + plugins: [ + 'react', + 'jsx-a11y', + 'import', + ], + env: { + es6: true, + node: true, + browser: true, + meteor: true, + jasmine: true, + }, + rules: { + 'no-underscore-dangle': 0, + 'import/extensions': [2, 'never'], + 'import/no-absolute-path': 0, + 'import/no-unresolved': 0, + 'import/no-extraneous-dependencies': 1, + 'react/prop-types': 1, + 'jsx-a11y/no-access-key': 0, + 'react/jsx-props-no-spreading': 'off', + 'max-classes-per-file': ['error', 2], + }, + globals: { + browser: 'writable', + }, +}; diff --git a/src/2.7.12/.gitignore b/src/2.7.12/.gitignore new file mode 100644 index 00000000..bbb197c9 --- /dev/null +++ b/src/2.7.12/.gitignore @@ -0,0 +1,9 @@ +.dbshell +npm-debug.log +node_modules/ +.meteor/dev_bundle +public/locales/de_DE.json +public/locales/ja_JP.json +public/files + + diff --git a/src/2.7.12/.meteor/.finished-upgraders b/src/2.7.12/.meteor/.finished-upgraders new file mode 100644 index 00000000..c07b6ff7 --- /dev/null +++ b/src/2.7.12/.meteor/.finished-upgraders @@ -0,0 +1,19 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes +1.3.0-split-minifiers-package +1.4.0-remove-old-dev-bundle-link +1.4.1-add-shell-server-package +1.4.3-split-account-service-packages +1.5-add-dynamic-import-package +1.7-split-underscore-from-meteor-base +1.8.3-split-jquery-from-blaze diff --git a/src/2.7.12/.meteor/.gitignore b/src/2.7.12/.meteor/.gitignore new file mode 100644 index 00000000..501f92e4 --- /dev/null +++ b/src/2.7.12/.meteor/.gitignore @@ -0,0 +1,2 @@ +dev_bundle +local diff --git a/src/2.7.12/.meteor/.id b/src/2.7.12/.meteor/.id new file mode 100644 index 00000000..350c3adf --- /dev/null +++ b/src/2.7.12/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +jrnkwdjvicqgy6gtl8 diff --git a/src/2.7.12/.meteor/cordova-plugins b/src/2.7.12/.meteor/cordova-plugins new file mode 100644 index 00000000..e69de29b diff --git a/src/2.7.12/.meteor/packages b/src/2.7.12/.meteor/packages new file mode 100644 index 00000000..ecee23ab --- /dev/null +++ b/src/2.7.12/.meteor/packages @@ -0,0 +1,25 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-base@1.5.1 +mobile-experience@1.1.0 +mongo@1.16.7 +reactive-var@1.0.12 + +standard-minifier-css@1.9.2 +standard-minifier-js@2.8.1 +es5-shim@4.8.0 +ecmascript@0.16.7 +shell-server@0.5.0 + +static-html@1.3.2 +react-meteor-data +session@1.2.1 +tracker@1.3.2 +check@1.3.2 + +rocketchat:streamer +meteortesting:mocha +lmieulet:meteor-coverage diff --git a/src/2.7.12/.meteor/platforms b/src/2.7.12/.meteor/platforms new file mode 100644 index 00000000..efeba1b5 --- /dev/null +++ b/src/2.7.12/.meteor/platforms @@ -0,0 +1,2 @@ +server +browser diff --git a/src/2.7.12/.meteor/release b/src/2.7.12/.meteor/release new file mode 100644 index 00000000..7c40889d --- /dev/null +++ b/src/2.7.12/.meteor/release @@ -0,0 +1 @@ +METEOR@2.13 diff --git a/src/2.7.12/.meteor/versions b/src/2.7.12/.meteor/versions new file mode 100644 index 00000000..4cefbe6d --- /dev/null +++ b/src/2.7.12/.meteor/versions @@ -0,0 +1,78 @@ +allow-deny@1.1.1 +autoupdate@1.8.0 +babel-compiler@7.10.4 +babel-runtime@1.5.1 +base64@1.0.12 +binary-heap@1.0.11 +blaze-tools@1.1.3 +boilerplate-generator@1.7.1 +caching-compiler@1.2.2 +caching-html-compiler@1.2.1 +callback-hook@1.5.1 +check@1.3.2 +ddp@1.4.1 +ddp-client@2.6.1 +ddp-common@1.4.0 +ddp-server@2.6.2 +diff-sequence@1.1.2 +dynamic-import@0.7.3 +ecmascript@0.16.7 +ecmascript-runtime@0.8.1 +ecmascript-runtime-client@0.12.1 +ecmascript-runtime-server@0.11.0 +ejson@1.1.3 +es5-shim@4.8.0 +fetch@0.1.3 +geojson-utils@1.0.11 +hot-code-push@1.0.4 +html-tools@1.1.3 +htmljs@1.1.1 +http@2.0.0 +id-map@1.1.1 +inter-process-messaging@0.1.1 +launch-screen@1.3.0 +lmieulet:meteor-coverage@4.1.0 +logging@1.3.2 +meteor@1.11.3 +meteor-base@1.5.1 +meteortesting:browser-tests@1.3.5 +meteortesting:mocha@2.0.3 +meteortesting:mocha-core@8.1.2 +minifier-css@1.6.4 +minifier-js@2.7.5 +minimongo@1.9.3 +mobile-experience@1.1.0 +mobile-status-bar@1.1.0 +modern-browsers@0.1.9 +modules@0.19.0 +modules-runtime@0.13.1 +mongo@1.16.7 +mongo-decimal@0.1.3 +mongo-dev-server@1.1.0 +mongo-id@1.0.8 +npm-mongo@4.16.0 +ordered-dict@1.1.0 +promise@0.12.2 +random@1.2.1 +react-fast-refresh@0.2.7 +react-meteor-data@2.5.1 +reactive-dict@1.3.1 +reactive-var@1.0.12 +reload@1.3.1 +retry@1.1.0 +rocketchat:streamer@1.1.0 +routepolicy@1.1.1 +session@1.2.1 +shell-server@0.5.0 +socket-stream-client@0.5.1 +spacebars-compiler@1.3.1 +standard-minifier-css@1.9.2 +standard-minifier-js@2.8.1 +static-html@1.3.2 +templating-tools@1.2.2 +tracker@1.3.2 +typescript@4.9.4 +underscore@1.0.13 +url@1.3.2 +webapp@1.13.5 +webapp-hashing@1.1.1 diff --git a/src/2.7.12/client/collection-mirror-initializer.js b/src/2.7.12/client/collection-mirror-initializer.js new file mode 100644 index 00000000..6d214f6c --- /dev/null +++ b/src/2.7.12/client/collection-mirror-initializer.js @@ -0,0 +1,79 @@ +import AbstractCollection from '/imports/ui/services/LocalCollectionSynchronizer/LocalCollectionSynchronizer'; + +// Collections +import Presentations from '/imports/api/presentations'; +import PresentationPods from '/imports/api/presentation-pods'; +import PresentationUploadToken from '/imports/api/presentation-upload-token'; +import Screenshare from '/imports/api/screenshare'; +import UserInfos from '/imports/api/users-infos'; +import Polls, { CurrentPoll } from '/imports/api/polls'; +import UsersPersistentData from '/imports/api/users-persistent-data'; +import UserSettings from '/imports/api/users-settings'; +import VideoStreams from '/imports/api/video-streams'; +import VoiceUsers from '/imports/api/voice-users'; +import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user'; +import GroupChat from '/imports/api/group-chat'; +import ConnectionStatus from '/imports/api/connection-status'; +import Captions from '/imports/api/captions'; +import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads'; +import AuthTokenValidation from '/imports/api/auth-token-validation'; +import Annotations from '/imports/api/annotations'; +import Breakouts from '/imports/api/breakouts'; +import BreakoutsHistory from '/imports/api/breakouts-history'; +import guestUsers from '/imports/api/guest-users'; +import Meetings, { RecordMeetings, ExternalVideoMeetings, MeetingTimeRemaining, Notifications } from '/imports/api/meetings'; +import { UsersTyping } from '/imports/api/group-chat-msg'; +import Users, { CurrentUser } from '/imports/api/users'; +import { Slides, SlidePositions } from '/imports/api/slides'; + +// Custom Publishers +export const localCollectionRegistry = { + localCurrentPollSync: new AbstractCollection(CurrentPoll, CurrentPoll), + localCurrentUserSync: new AbstractCollection(CurrentUser, CurrentUser), + localSlidesSync: new AbstractCollection(Slides, Slides), + localSlidePositionsSync: new AbstractCollection(SlidePositions, SlidePositions), + localPollsSync: new AbstractCollection(Polls, Polls), + localPresentationsSync: new AbstractCollection(Presentations, Presentations), + localPresentationPodsSync: new AbstractCollection(PresentationPods, PresentationPods), + localPresentationUploadTokenSync: new AbstractCollection( + PresentationUploadToken, + PresentationUploadToken, + ), + localScreenshareSync: new AbstractCollection(Screenshare, Screenshare), + localUserInfosSync: new AbstractCollection(UserInfos, UserInfos), + localUsersPersistentDataSync: new AbstractCollection(UsersPersistentData, UsersPersistentData), + localUserSettingsSync: new AbstractCollection(UserSettings, UserSettings), + localVideoStreamsSync: new AbstractCollection(VideoStreams, VideoStreams), + localVoiceUsersSync: new AbstractCollection(VoiceUsers, VoiceUsers), + localWhiteboardMultiUserSync: new AbstractCollection(WhiteboardMultiUser, WhiteboardMultiUser), + localGroupChatSync: new AbstractCollection(GroupChat, GroupChat), + localConnectionStatusSync: new AbstractCollection(ConnectionStatus, ConnectionStatus), + localCaptionsSync: new AbstractCollection(Captions, Captions), + localPadsSync: new AbstractCollection(Pads, Pads), + localPadsSessionsSync: new AbstractCollection(PadsSessions, PadsSessions), + localPadsUpdatesSync: new AbstractCollection(PadsUpdates, PadsUpdates), + localAuthTokenValidationSync: new AbstractCollection(AuthTokenValidation, AuthTokenValidation), + localAnnotationsSync: new AbstractCollection(Annotations, Annotations), + localRecordMeetingsSync: new AbstractCollection(RecordMeetings, RecordMeetings), + localExternalVideoMeetingsSync: new AbstractCollection( + ExternalVideoMeetings, + ExternalVideoMeetings, + ), + localMeetingTimeRemainingSync: new AbstractCollection(MeetingTimeRemaining, MeetingTimeRemaining), + localUsersTypingSync: new AbstractCollection(UsersTyping, UsersTyping), + localBreakoutsSync: new AbstractCollection(Breakouts, Breakouts), + localBreakoutsHistorySync: new AbstractCollection(BreakoutsHistory, BreakoutsHistory), + localGuestUsersSync: new AbstractCollection(guestUsers, guestUsers), + localMeetingsSync: new AbstractCollection(Meetings, Meetings), + localUsersSync: new AbstractCollection(Users, Users), + localNotificationsSync: new AbstractCollection(Notifications, Notifications), +}; + +const collectionMirrorInitializer = () => { + Object.values(localCollectionRegistry).forEach((localCollection) => { + localCollection.setupListeners(); + }); +}; + +export default collectionMirrorInitializer; +// const localUsersSync = new AbstractCollection(CurrentUser, CurrentUser); diff --git a/src/2.7.12/client/legacy.jsx b/src/2.7.12/client/legacy.jsx new file mode 100644 index 00000000..a583afff --- /dev/null +++ b/src/2.7.12/client/legacy.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Meteor } from 'meteor/meteor'; +import { render } from 'react-dom'; +import Legacy from '/imports/ui/components/legacy/component'; + +// This class is the start of the content loaded on legacy (unsupported) browsers. +// What is included here needs to be minimal and carefully considered because some +// things can't be polyfilled. + +Meteor.startup(() => { + render( + , + document.getElementById('app'), + ); +}); diff --git a/src/2.7.12/client/main.html b/src/2.7.12/client/main.html new file mode 100644 index 00000000..ee60144b --- /dev/null +++ b/src/2.7.12/client/main.html @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + +
+ diff --git a/src/2.7.12/client/main.jsx b/src/2.7.12/client/main.jsx new file mode 100644 index 00000000..09884033 --- /dev/null +++ b/src/2.7.12/client/main.jsx @@ -0,0 +1,100 @@ +/* + BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ + + Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below). + + This program is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free Software + Foundation; either version 3.0 of the License, or (at your option) any later + version. + + BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along + with BigBlueButton; if not, see . +*/ +/* eslint no-unused-vars: 0 */ + +import React from 'react'; +import { Meteor } from 'meteor/meteor'; +import { render } from 'react-dom'; +import logger from '/imports/startup/client/logger'; +import '/imports/ui/services/mobile-app'; +import Base from '/imports/startup/client/base'; +import JoinHandler from '/imports/ui/components/join-handler/component'; +import AuthenticatedHandler from '/imports/ui/components/authenticated-handler/component'; +import Subscriptions from '/imports/ui/components/subscriptions/component'; +import IntlStartup from '/imports/startup/client/intl'; +import ContextProviders from '/imports/ui/components/context-providers/component'; +import ChatAdapter from '/imports/ui/components/components-data/chat-context/adapter'; +import UsersAdapter from '/imports/ui/components/components-data/users-context/adapter'; +import GroupChatAdapter from '/imports/ui/components/components-data/group-chat-context/adapter'; +import { liveDataEventBrokerInitializer } from '/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker'; +// The adapter import is "unused" as far as static code is concerned, but it +// needs to here to override global prototypes. So: don't remove it - prlanzarin 25 Apr 2022 +import adapter from 'webrtc-adapter'; + +import collectionMirrorInitializer from './collection-mirror-initializer'; + +import('/imports/api/audio/client/bridge/bridge-whitelist').catch(() => { + // bridge loading +}); + +const { disableWebsocketFallback } = Meteor.settings.public.app; + +if (disableWebsocketFallback) { + Meteor.connection._stream._sockjsProtocolsWhitelist = function () { return ['websocket']; }; + + Meteor.disconnect(); + Meteor.reconnect(); +} + +collectionMirrorInitializer(); +liveDataEventBrokerInitializer(); + +Meteor.startup(() => { + // Logs all uncaught exceptions to the client logger + window.addEventListener('error', (e) => { + let message = e.message || e.error.toString(); + + // Chrome will add on "Uncaught" to the start of the message for some reason. This + // will strip that so the errors can hopefully be grouped better. + if (message) message = message.replace(/^Uncaught/, '').trim(); + + let { stack } = e.error; + + // Checks if stack includes the message, if not add the two together. + if (!stack.includes(message)) { + stack = `${message}\n${stack}`; + } + logger.error({ + logCode: 'startup_error', + extraInfo: { + stackTrace: stack, + }, + }, message); + }); + + // TODO make this a Promise + render( + + <> + + + + + + + + + + + + + + , + document.getElementById('app'), + ); +}); diff --git a/src/2.7.12/client/stylesheets/bbb-icons.css b/src/2.7.12/client/stylesheets/bbb-icons.css new file mode 100644 index 00000000..6c9a8b5a --- /dev/null +++ b/src/2.7.12/client/stylesheets/bbb-icons.css @@ -0,0 +1,356 @@ +[class^="icon-bbb-"], [class*=" icon-bbb-"] { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'bbb-icons' !important; + speak: none; + position: relative; + /*top: 1px;*/ + display: inline-block; + font-style: normal; + font-weight: 400; + line-height: 1; + -webkit-font-smoothing: antialiased; + width: 1em; + text-align: center; + vertical-align: middle; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-bbb-screenshare-fullscreen:before { + content: "\e92a"; +} +.icon-bbb-screenshare-close-fullscreen:before { + content: "\e935"; +} +.icon-bbb-alert:before { + content: "\e958"; +} +.icon-bbb-mute:before { + content: "\e932"; +} +.icon-bbb-unmute:before { + content: "\e931"; +} +.icon-bbb-file:before { + content: "\e92e"; +} +.icon-bbb-upload:before { + content: "\e92f"; +} +.icon-bbb-fullscreen:before { + content: "\e92a"; +} +.icon-bbb-exit_fullscreen:before { + content: "\e935"; +} +.icon-bbb-settings:before { + content: "\e92b"; +} +.icon-bbb-fit_to_screen:before { + content: "\e929"; +} +.icon-bbb-line_tool:before { + content: "\e91c"; +} +.icon-bbb-circle_tool:before { + content: "\e91d"; +} +.icon-bbb-triangle_tool:before { + content: "\e91e"; +} +.icon-bbb-rectangle_tool:before { + content: "\e91f"; +} +.icon-bbb-text_tool:before { + content: "\e920"; +} +.icon-bbb-plus:before { + content: "\e921"; +} +.icon-bbb-fit_to_width:before { + content: "\e922"; +} +.icon-bbb-undo:before { + content: "\e924"; +} +.icon-bbb-pen_tool:before { + content: "\e925"; +} +.icon-bbb-lock:before { + content: "\e926"; +} +.icon-bbb-polling:before { + content: "\e927"; +} +.icon-bbb-desktop:before { + content: "\e928"; +} +.icon-bbb-logout:before { + content: "\e900"; +} +.icon-bbb-video:before { + content: "\e930"; +} +.icon-bbb-elipsis:before { + content: "\e902"; +} +.icon-bbb-more:before { + content: "\e902"; + display: inline-block; + transform: rotate(90deg); +} +.icon-bbb-promote:before { + content: "\e903"; +} +.icon-bbb-application:before { + content: "\e901"; +} +.icon-bbb-video_off:before { + content: "\e904"; +} +.icon-bbb-user:before { + content: "\e905"; +} +.icon-bbb-up_arrow:before { + content: "\e906"; +} +.icon-bbb-right_arrow:before { + content: "\e90a"; +} +.icon-bbb-presentation:before { + content: "\e90b"; +} +.icon-bbb-listen:before { + content: "\e90c"; +} +.icon-bbb-left_arrow:before { + content: "\e90d"; +} +.icon-bbb-group_chat:before { + content: "\e910"; +} +.icon-bbb-close:before { + content: "\e912"; +} +.icon-bbb-clear_status:before { + content: "\e913"; +} +.icon-bbb-circle:before { + content: "\e914"; +} +.icon-bbb-substract:before { + content: "\e915"; +} +.icon-bbb-circle_close:before { + content: "\e916"; +} +.icon-bbb-add:before { + content: "\e917"; +} +.icon-bbb-check:before { + content: "\e918"; +} +.icon-bbb-chat:before { + content: "\e919"; +} +.icon-bbb-audio_on:before { + content: "\e91a"; +} +.icon-bbb-audio_off:before { + content: "\e91b"; +} + +.icon-bbb-volume_down:before { + content: "\e947"; +} + +.icon-bbb-volume_mute:before { + content: "\e947"; +} + +.icon-bbb-volume_off:before { + content: "\e95f"; +} + +.icon-bbb-volume_up:before { + content: "\e947"; +} + +.icon-bbb-volume_level_1:before { + content: "\e960"; +} + +.icon-bbb-volume_level_2:before { + content: "\e961"; +} + +.icon-bbb-volume_level_3:before { + content: "\e962"; +} + +.icon-bbb-no_audio:before { + content: "\e963"; +} + + +/* Aliases for emoji status */ +.icon-bbb-time:before { + content: "\e908"; +} +.icon-bbb-hand:before { + content: "\e90f"; +} +.icon-bbb-undecided:before { + content: "\e907"; +} +.icon-bbb-happy:before { + content: "\e90e"; +} +.icon-bbb-sad:before { + content: "\e909"; +} +.icon-bbb-confused:before { + content: "\e911"; +} +.icon-bbb-applause:before { + content: "\e923"; +} +.icon-bbb-thumbs_up:before { + content: "\e92d"; +} +.icon-bbb-thumbs_down:before { + content: "\e92c"; +} +.icon-bbb-send:before { + content: "\e934"; +} +.icon-bbb-about:before { + content: "\e933"; +} + +.icon-bbb-delete:before { + content: "\e936"; +} +.icon-bbb-unmute_filled:before { + content: "\e937"; +} +.icon-bbb-mute_filled:before { + content: "\e938"; +} +.icon-bbb-listen_filled:before { + content: "\e939"; +} +.icon-bbb-template_upload:before { + content: "\e93a"; +} +.icon-bbb-template_download:before { + content: "\e93b"; +} +.icon-bbb-download:before { + content: "\e93c"; +} +.icon-bbb-multi_whiteboard:before { + content: "\e93d"; +} +.icon-bbb-whiteboard:before { + content: "\e93e"; +} +.icon-bbb-rooms:before { + content: "\e93f"; +} +.icon-bbb-unlock:before { + content: "\e940"; +} +.icon-bbb-record:before { + content: "\e941"; +} +.icon-bbb-network:before { + content: "\e942"; +} +.icon-bbb-redo:before { + content: "\e943"; +} +.icon-bbb-thumbs_down_filled:before { + content: "\e944"; +} +.icon-bbb-thumbs_up_filled:before { + content: "\e945"; +} +.icon-bbb-checkmark:before { + content: "\e946"; +} +.icon-bbb-speak_louder:before { + content: "\e947"; +} +.icon-bbb-help:before { + content: "\e948"; +} +.icon-bbb-refresh:before { + content: "\e949"; +} +.icon-bbb-copy:before { + content: "\e94a"; +} +.icon-bbb-shortcuts:before { + content: "\e94b"; +} +.icon-bbb-warning:before { + content: "\e94c"; +} +.icon-bbb-pointer:before { + content: "\e950"; +} +.icon-bbb-star_filled:before { + content: "\e952"; +} +.icon-bbb-desktop_off:before { + content: "\e953"; +} +.icon-bbb-minus:before { + content: "\e954"; +} +.icon-bbb-download-off:before { + content: "\e955"; +} +.icon-bbb-popout_window:before { + content: "\e956"; +} +.icon-bbb-closed_caption:before { + content: "\e957"; +} +/*\e958 -> icon-bbb-alert*/ +.icon-bbb-palm_rejection:before { + content: "\e959"; +} +.icon-bbb-no_palm_rejection:before { + content: "\e95a"; +} +.icon-bbb-device_list_selector:before { + content: "\e95b"; +} +.icon-bbb-presentation_off:before { + content: "\e95c"; +} +.icon-bbb-external-video:before { + content: "\e95d"; +} +.icon-bbb-external-video_off:before { + content: "\e95e"; +} +.icon-bbb-pin-video_on:before { + content: "\e964"; +} +.icon-bbb-pin-video_off:before { + content: "\e965"; +} +.icon-bbb-closed_caption_stop:before { + content: "\e966"; +} +.icon-bbb-link:before { + content: "\e967"; +} +.icon-bbb-manage_layout:before { + content: "\e968"; +} diff --git a/src/2.7.12/client/stylesheets/fontSizing.css b/src/2.7.12/client/stylesheets/fontSizing.css new file mode 100644 index 00000000..baab39f9 --- /dev/null +++ b/src/2.7.12/client/stylesheets/fontSizing.css @@ -0,0 +1,19 @@ +.extraSmallFont { + font-size: 0.5rem; +} + +.smallFont { + font-size: 1.0rem; +} + +.mediumFont { + font-size: 1.5rem; +} + +.largeFont { + font-size: 2.0rem; +} + +.extraLargeFont { + font-size: 3.0rem; +} diff --git a/src/2.7.12/client/stylesheets/fonts.css b/src/2.7.12/client/stylesheets/fonts.css new file mode 100644 index 00000000..11f9bce5 --- /dev/null +++ b/src/2.7.12/client/stylesheets/fonts.css @@ -0,0 +1,216 @@ +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), + url('fonts/SourceSansPro/SourceSansPro-Light.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), + url('fonts/SourceSansPro/SourceSansPro-Light.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), + url('fonts/SourceSansPro/SourceSansPro-Light.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro'), local('SourceSansPro-Regular'), + url('fonts/SourceSansPro/SourceSansPro-Regular.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro'), local('SourceSansPro-Regular'), + url('fonts/SourceSansPro/SourceSansPro-Regular.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + src: local('Source Sans Pro'), local('SourceSansPro-Regular'), + url('fonts/SourceSansPro/SourceSansPro-Regular.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'), + url('fonts/SourceSansPro/SourceSansPro-Semibold.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'), + url('fonts/SourceSansPro/SourceSansPro-Semibold.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'), + url('fonts/SourceSansPro/SourceSansPro-Semibold.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), + url('fonts/SourceSansPro/SourceSansPro-Bold.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), + url('fonts/SourceSansPro/SourceSansPro-Bold.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), + url('fonts/SourceSansPro/SourceSansPro-Bold.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightIt'), + url('fonts/SourceSansPro/SourceSansPro-LightItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightIt'), + url('fonts/SourceSansPro/SourceSansPro-LightItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightIt'), + url('fonts/SourceSansPro/SourceSansPro-LightItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-It'), + url('fonts/SourceSansPro/SourceSansPro-Italic.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-It'), + url('fonts/SourceSansPro/SourceSansPro-Italic.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 400; + src: local('Source Sans Pro Italic'), local('SourceSansPro-It'), + url('fonts/SourceSansPro/SourceSansPro-Italic.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro Semibold Italic'), local('SourceSansPro-SemiboldIt'), + url('fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro Semibold Italic'), local('SourceSansPro-SemiboldIt'), + url('fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 600; + src: local('Source Sans Pro Semibold Italic'), local('SourceSansPro-SemiboldIt'), + url('fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 700; + src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldIt'), + url('fonts/SourceSansPro/SourceSansPro-BoldItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 700; + src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldIt'), + url('fonts/SourceSansPro/SourceSansPro-BoldItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 700; + src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldIt'), + url('fonts/SourceSansPro/SourceSansPro-BoldItalic.woff?v=VERSION') format('woff'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} diff --git a/src/2.7.12/client/stylesheets/modals.css b/src/2.7.12/client/stylesheets/modals.css new file mode 100644 index 00000000..7c072ec4 --- /dev/null +++ b/src/2.7.12/client/stylesheets/modals.css @@ -0,0 +1,76 @@ +.ReactModal__Overlay { + -webkit-perspective: 600; + perspective: 600; + opacity: 0; + overflow-x: hidden; + overflow-y: auto; + background-color: rgba(0, 0, 0, 0.5); +} + +.ReactModal__Overlay--after-open { + opacity: 1; + transition: opacity calc(var(--enableAnimation) * 150ms) ease-out; +} + +.ReactModal__Content { + -webkit-transform: scale(0.5) rotateX(-30deg); + transform: scale(0.5) rotateX(-30deg); + width: 600px; +} + +.ReactModal__Content--after-open { + -webkit-transform: scale(1) rotateX(0deg); + transform: scale(1) rotateX(0deg); + transition: all calc(var(--enableAnimation) * 150ms) ease-in; +} + +.ReactModal__Overlay--before-close { + opacity: 0; +} + +.ReactModal__Content--before-close { + -webkit-transform: scale(0.5) rotateX(30deg); + transform: scale(0.5) rotateX(30deg); + transition: all calc(var(--enableAnimation) * 150ms) ease-in; +} + +.ReactModal__Content.modal-dialog { + border: none; + background-color: transparent; +} + +/* Prevents that an element within app shows over a modal */ +#app { + position: inherit; + z-index: 1000 !important; +} + +.modal-low { + z-index: 1001; +} + +.modal-medium { + z-index: 1002; +} + +.modal-high { + z-index: 1003; +} + +/* Within a same priority, hide all but first (FIFO) */ +.modal-low ~ .modal-low, +.modal-medium ~ .modal-medium, +.modal-high ~ .modal-high { + display: none; +} + +/* Hide all low priority modals when a medium or high priority modals are displayed */ +#modals-container:has(.modal-medium) .modal-low, +#modals-container:has(.modal-high) .modal-low { + display: none; +} + +/* Hide all medium priority modals when a high priority modal is displayed */ +#modals-container:has(.modal-high) .modal-medium { + display: none; +} diff --git a/src/2.7.12/client/stylesheets/normalize.css b/src/2.7.12/client/stylesheets/normalize.css new file mode 100644 index 00000000..e70ffc56 --- /dev/null +++ b/src/2.7.12/client/stylesheets/normalize.css @@ -0,0 +1,420 @@ +/*! normalize.css v4.1.1 | MIT License | github.com/necolas/normalize.css */ + +/** + * 1. Change the default font family in all browsers (opinionated). + * 2. Prevent adjustments of font size after orientation changes in IE and iOS. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove the margin in all browsers (opinionated). + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + * 1. Add the correct display in Edge, IE, and Firefox. + * 2. Add the correct display in IE. + */ + +article, +aside, +details, /* 1 */ +figcaption, +figure, +footer, +header, +main, /* 2 */ +menu, +nav, +section, +summary { /* 1 */ + display: block; +} + +/** + * Add the correct display in IE 9-. + */ + +audio, +canvas, +progress, +video { + display: inline-block; +} + +/** + * Add the correct display in iOS 4-7. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Add the correct display in IE 10-. + * 1. Add the correct display in IE. + */ + +template, /* 1 */ +[hidden] { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * 1. Remove the gray background on active links in IE 10. + * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. + */ + +a { + background-color: transparent; /* 1 */ + -webkit-text-decoration-skip: objects; /* 2 */ +} + +/** + * Remove the outline on focused links when they are also active or hovered + * in all browsers (opinionated). + */ + +a:active, +a:hover { + outline-width: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * 1. Remove the bottom border in Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Prevent the duplicate application of `bolder` by the next rule in Safari 6. + */ + +b, +strong { + font-weight: inherit; +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * Add the correct font style in Android 4.3-. + */ + +dfn { + font-style: italic; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Add the correct background and color in IE 9-. + */ + +mark { + background-color: #ff0; + color: #000; +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10-. + */ + +img { + border-style: none; +} + +/** + * Hide the overflow in IE. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct margin in IE 8. + */ + +figure { + margin: 1em 40px; +} + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change font properties to `inherit` in all browsers (opinionated). + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font: inherit; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Restore the font weight unset by the previous rule. + */ + +optgroup { + font-weight: bold; +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` + * controls in Android 4. + * 2. Correct the inability to style clickable types in iOS and Safari. + */ + +button, +html [type="button"], /* 1 */ +[type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 2 */ +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Change the border, margin, and padding in all browsers (opinionated). + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Remove the default vertical scrollbar in IE. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10-. + * 2. Remove the padding in IE 10-. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding and cancel buttons in Chrome and Safari on OS X. + */ + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Correct the text style of placeholders in Chrome, Edge, and Safari. + */ + +::-webkit-input-placeholder { + color: inherit; + opacity: 0.54; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} diff --git a/src/2.7.12/client/stylesheets/toastify.css b/src/2.7.12/client/stylesheets/toastify.css new file mode 100644 index 00000000..05ee7747 --- /dev/null +++ b/src/2.7.12/client/stylesheets/toastify.css @@ -0,0 +1,120 @@ +/* TODO: We can remove this file after we update react animation package */ +@keyframes toastify-bounceInRight { + from, 60%, 75%, 90%, to { + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } + from { + opacity: 0; + transform: translate3d(3000px, 0, 0); } + 60% { + opacity: 1; + transform: translate3d(-25px, 0, 0); } + 75% { + transform: translate3d(10px, 0, 0); } + 90% { + transform: translate3d(-5px, 0, 0); } + to { + transform: none; } } +.toastify-bounceInRight, .toast-enter--top-right, .toast-enter--bottom-right { + animation-name: toastify-bounceInRight; } + +@keyframes toastify-bounceOutRight { + 20% { + opacity: 1; + transform: translate3d(-20px, 0, 0); } + to { + opacity: 0; + transform: translate3d(2000px, 0, 0); } } +.toastify-bounceOutRight, .toast-exit--top-right, .toast-exit--bottom-right { + animation-name: toastify-bounceOutRight; } + +@keyframes toastify-bounceInLeft { + from, 60%, 75%, 90%, to { + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } + 0% { + opacity: 0; + transform: translate3d(-3000px, 0, 0); } + 60% { + opacity: 1; + transform: translate3d(25px, 0, 0); } + 75% { + transform: translate3d(-10px, 0, 0); } + 90% { + transform: translate3d(5px, 0, 0); } + to { + transform: none; } } +.toastify-bounceInLeft, .toast-enter--top-left, .toast-enter--bottom-left { + animation-name: toastify-bounceInLeft; } + +@keyframes toastify-bounceOutLeft { + 20% { + opacity: 1; + transform: translate3d(20px, 0, 0); } + to { + opacity: 0; + transform: translate3d(-2000px, 0, 0); } } +.toastify-bounceOutLeft, .toast-exit--top-left, .toast-exit--bottom-left { + animation-name: toastify-bounceOutLeft; } + +@keyframes toastify-bounceInUp { + from, 60%, 75%, 90%, to { + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } + from { + opacity: 0; + transform: translate3d(0, 3000px, 0); } + 60% { + opacity: 1; + transform: translate3d(0, -20px, 0); } + 75% { + transform: translate3d(0, 10px, 0); } + 90% { + transform: translate3d(0, -5px, 0); } + to { + transform: translate3d(0, 0, 0); } } +.toastify-bounceInUp, .toast-enter--bottom-center { + animation-name: toastify-bounceInUp; } + +@keyframes toastify-bounceOutUp { + 20% { + transform: translate3d(0, -10px, 0); } + 40%, 45% { + opacity: 1; + transform: translate3d(0, 20px, 0); } + to { + opacity: 0; + transform: translate3d(0, -2000px, 0); } } +.toastify-bounceOutUp, .toast-exit--top-center { + animation-name: toastify-bounceOutUp; } + +@keyframes toastify-bounceInDown { + from, 60%, 75%, 90%, to { + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } + 0% { + opacity: 0; + transform: translate3d(0, -3000px, 0); } + 60% { + opacity: 1; + transform: translate3d(0, 25px, 0); } + 75% { + transform: translate3d(0, -10px, 0); } + 90% { + transform: translate3d(0, 5px, 0); } + to { + transform: none; } } +.toastify-bounceInDown, .toast-enter--top-center { + animation-name: toastify-bounceInDown; } + +@keyframes toastify-bounceOutDown { + 20% { + transform: translate3d(0, 10px, 0); } + 40%, 45% { + opacity: 1; + transform: translate3d(0, -20px, 0); } + to { + opacity: 0; + transform: translate3d(0, 2000px, 0); } } +.toastify-bounceOutDown, .toast-exit--bottom-center { + animation-name: toastify-bounceOutDown; } + +.toastify-animated { + animation-duration: 0.75s; + animation-fill-mode: both; } diff --git a/src/2.7.12/client/stylesheets/toggleSwitch.css b/src/2.7.12/client/stylesheets/toggleSwitch.css new file mode 100644 index 00000000..db9c7fd1 --- /dev/null +++ b/src/2.7.12/client/stylesheets/toggleSwitch.css @@ -0,0 +1,123 @@ +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + cursor: not-allowed; + opacity: 0.5; + transition: opacity calc(var(--enableAnimation) * 0.25s); +} + +.react-toggle-track { + overflow: hidden; + width: 3.5rem; + height: 1.5rem; + padding: 0; + border-radius: 2rem; + background-color: var(--color-danger); + transition: all calc(var(--enableAnimation) * 0.2s) ease; +} + +.react-toggle-track:dir(rtl) { + width: 4rem; +} + +.react-toggle--checked .react-toggle-track { + background-color: var(--color-success); +} + +.react-toggle-track-check { + position: absolute; + color: white; + width: 1rem; + line-height: 1.5rem; + font-size: 0.8rem; + left: 0.5rem; + opacity: 0; + transition: opacity calc(var(--enableAnimation) * 0.25s) ease; +} + +.react-toggle-track-check:dir(rtl) { + left: 0.8rem; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + transition: opacity calc(var(--enableAnimation) * 0.25s) ease; +} + +.react-toggle-track-x { + position: absolute; + color: white; + width: 1rem; + line-height: 1.5rem; + font-size: 0.8rem; + left: 1.7rem; + opacity: 1; + transition: opacity calc(var(--enableAnimation) * 0.25s) ease; +} + +.react-toggle-track-x:dir(rtl) { + left: 2.2rem; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; + position: absolute; + top: 1px; + left: 1px; + width: 1.35rem; + height: 1.35rem; + border-radius: 50%; + background-color: #FAFAFA; + box-sizing: border-box; + transition: opacity calc(var(--enableAnimation) * 0.25s) ease; + box-shadow: 2px 0px 10px -1px rgba(0,0,0,0.4); +} + +.react-toggle--checked .react-toggle-thumb { + left: 2.1rem; + box-shadow: -2px 0px 10px -1px rgba(0,0,0,0.4); +} + +.react-toggle--checked:dir(rtl) .react-toggle-thumb:dir(rtl) { + left: 2.6rem; +} + +.react-toggle--focus .react-toggle-thumb { + box-shadow: 0px 0px 2px 3px var(--color-primary); +} + +.react-toggle:active:not(.react-toggle--disabled) .react-toggle-thumb { + box-shadow: 0px 0px 5px 5px var(--color-primary); +} diff --git a/src/2.7.12/deploy_to_usr_share.sh b/src/2.7.12/deploy_to_usr_share.sh new file mode 100644 index 00000000..f5d21e1c --- /dev/null +++ b/src/2.7.12/deploy_to_usr_share.sh @@ -0,0 +1,106 @@ +#!/bin/sh -ex +cd "$(dirname "$0")" + +# Please check bigbluebutton/bigbluebutton-html5/dev_local_deployment/README.md + +UPPER_DESTINATION_DIR=/usr/share/meteor +DESTINATION_DIR=$UPPER_DESTINATION_DIR/bundle + +SERVICE_FILES_DIR=/usr/lib/systemd/system +LOCAL_PACKAGING_DIR="$(pwd)/../build/packages-template/bbb-html5" + +if [ ! -d "$LOCAL_PACKAGING_DIR" ]; then + echo "Did not find LOCAL_PACKAGING_DIR=$LOCAL_PACKAGING_DIR" + exit +fi + +sudo rm -rf "$UPPER_DESTINATION_DIR" +sudo mkdir -p "$UPPER_DESTINATION_DIR" +sudo chown -R meteor:meteor "$UPPER_DESTINATION_DIR" + +# the next 5 lines may be temporarily commented out if you are sure you are not tweaking the required node_modules after first use of the script. This will save a minute or two during the run of the script +if [ -d "node_modules" ]; then + rm -r node_modules/ +fi +meteor reset +meteor npm ci --production + +sudo chmod 777 /usr/share/meteor +METEOR_DISABLE_OPTIMISTIC_CACHING=1 meteor build $UPPER_DESTINATION_DIR --architecture os.linux.x86_64 --allow-superuser --directory + +sudo chown -R meteor:meteor "$UPPER_DESTINATION_DIR"/ +echo 'stage3' + + +cd "$DESTINATION_DIR"/programs/server/ || exit +sudo chmod -R 777 . +meteor npm i + +echo "deployed to $DESTINATION_DIR/programs/server\n\n\n" + +echo "writing $DESTINATION_DIR/mongod_start_pre.sh" +sudo cp $LOCAL_PACKAGING_DIR/mongod_start_pre.sh "$DESTINATION_DIR"/mongod_start_pre.sh + +echo "writing $DESTINATION_DIR/mongo-ramdisk.conf" +sudo cp $LOCAL_PACKAGING_DIR/mongo-ramdisk.conf "$DESTINATION_DIR"/mongo-ramdisk.conf + +echo "writing $DESTINATION_DIR/bbb-html5-with-roles.conf" +sudo tee "$DESTINATION_DIR/bbb-html5-with-roles.conf" >/dev/null < { + AnnotationsStreamer(meetingId).emit('removed', { meetingId, whiteboardId, shapeId }); + await removeAnnotation(meetingId, whiteboardId, shapeId); + })); + return result; +} diff --git a/src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js new file mode 100644 index 00000000..d4de6f20 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js @@ -0,0 +1,58 @@ +import { check } from 'meteor/check'; +import AnnotationsStreamer from '/imports/api/annotations/server/streamer'; +import addAnnotation from '../modifiers/addAnnotation'; +import Metrics from '/imports/startup/server/metrics'; + +const { queueMetrics } = Meteor.settings.private.redis.metrics; + +const { + annotationsQueueProcessInterval: ANNOTATION_PROCESS_INTERVAL, +} = Meteor.settings.public.whiteboard; + +let annotationsQueue = {}; +let annotationsRecieverIsRunning = false; + +const process = () => { + if (!Object.keys(annotationsQueue).length) { + annotationsRecieverIsRunning = false; + return; + } + annotationsRecieverIsRunning = true; + Object.keys(annotationsQueue).forEach((meetingId) => { + AnnotationsStreamer(meetingId).emit('added', { meetingId, annotations: annotationsQueue[meetingId] }); + if (queueMetrics) { + Metrics.setAnnotationQueueLength(meetingId, 0); + } + }); + annotationsQueue = {}; + + Meteor.setTimeout(process, ANNOTATION_PROCESS_INTERVAL); +}; + +export default async function handleWhiteboardSend({ envelope, header, body }, meetingId) { + const userId = header.userId; + const whiteboardId = body.whiteboardId; + const annotations = body.annotations; + const instanceIdFromMessage = parseInt(envelope.routing.html5InstanceId, 10) || 1; + const myInstanceId = parseInt(body.myInstanceId, 10) || 1; + + check(userId, String); + check(whiteboardId, String); + check(annotations, Array); + + if (!annotationsQueue.hasOwnProperty(meetingId)) { + annotationsQueue[meetingId] = []; + } + // we use a for loop here instead of a map because we need to guarantee the order of the annotations. + for (const annotation of annotations) { + annotationsQueue[meetingId].push({ meetingId, whiteboardId, userId: annotation.userId, annotation }); + if (instanceIdFromMessage === myInstanceId) { + await addAnnotation(meetingId, whiteboardId, annotation.userId, annotation); + } + } + + if (queueMetrics) { + Metrics.setAnnotationQueueLength(meetingId, annotationsQueue[meetingId].length); + } + if (!annotationsRecieverIsRunning) process(); +} diff --git a/src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js new file mode 100644 index 00000000..87a1d8a0 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js @@ -0,0 +1,16 @@ +import { check } from 'meteor/check'; + +import AnnotationsStreamer from '/imports/api/annotations/server/streamer'; +import removeAnnotation from '../modifiers/removeAnnotation'; + +export default async function handleWhiteboardUndo({ body }, meetingId) { + const { whiteboardId } = body; + const shapeId = body.annotationId; + + check(whiteboardId, String); + check(shapeId, String); + + AnnotationsStreamer(meetingId).emit('removed', { meetingId, whiteboardId, shapeId }); + const result = await removeAnnotation(meetingId, whiteboardId, shapeId); + return result; +} diff --git a/src/2.7.12/imports/api/annotations/server/index.js b/src/2.7.12/imports/api/annotations/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/annotations/server/methods.js b/src/2.7.12/imports/api/annotations/server/methods.js new file mode 100644 index 00000000..6a42fbf0 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/methods.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import clearWhiteboard from './methods/clearWhiteboard'; +import sendAnnotations from './methods/sendAnnotations'; +import sendBulkAnnotations from './methods/sendBulkAnnotations'; +import deleteAnnotations from './methods/deleteAnnotations'; + +Meteor.methods({ + clearWhiteboard, + sendAnnotations, + sendBulkAnnotations, + deleteAnnotations, +}); diff --git a/src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js b/src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js new file mode 100644 index 00000000..a95d775d --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js @@ -0,0 +1,27 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function clearWhiteboard(whiteboardId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ClearWhiteboardPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(whiteboardId, String); + + const payload = { + whiteboardId, + }; + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method clearWhiteboard ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js b/src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js new file mode 100644 index 00000000..d58fa405 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function deleteAnnotations(annotations, whiteboardId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'DeleteWhiteboardAnnotationsPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(whiteboardId, String); + check(annotations, Array); + + const payload = { + whiteboardId, + annotationsIds: annotations, + }; + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method deleteAnnotation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js new file mode 100644 index 00000000..3b39236e --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js @@ -0,0 +1,34 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function sendAnnotationHelper(annotations, meetingId, requesterUserId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SendWhiteboardAnnotationsPubMsg'; + + try { + check(annotations, Array); + // TODO see if really necessary, don't know if it's possible + // to have annotations from different pages + // group annotations by same whiteboardId + const groupedAnnotations = annotations.reduce((r, v, i, a, k = v.wbId) => ((r[k] || (r[k] = [])).push(v), r), {}) //groupBy wbId + + Object.entries(groupedAnnotations).forEach(([_, whiteboardAnnotations]) => { + const whiteboardId = whiteboardAnnotations[0].wbId; + check(whiteboardId, String); + + const payload = { + whiteboardId, + annotations: whiteboardAnnotations, + html5InstanceId: parseInt(process.env.INSTANCE_ID, 10) || 1, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + }); + + } catch (err) { + Logger.error(`Exception while invoking method sendAnnotationHelper ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js new file mode 100644 index 00000000..530515f5 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js @@ -0,0 +1,17 @@ +import { check } from 'meteor/check'; +import sendAnnotationHelper from './sendAnnotationHelper'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function sendAnnotations(annotations) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + sendAnnotationHelper(annotations, meetingId, requesterUserId); + } catch (err) { + Logger.error(`Exception while invoking method sendAnnotation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js b/src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js new file mode 100644 index 00000000..09fcfeff --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js @@ -0,0 +1,20 @@ +import { extractCredentials } from '/imports/api/common/server/helpers'; +import sendAnnotationHelper from './sendAnnotationHelper'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function sendBulkAnnotations(payload) { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + try { + check(meetingId, String); + check(requesterUserId, String); + + sendAnnotationHelper(payload, meetingId, requesterUserId); + //payload.forEach((annotation) => sendAnnotationHelper(annotation, meetingId, requesterUserId)); + return true; + } catch (err) { + Logger.error(`Exception while invoking method sendBulkAnnotations ${err.stack}`); + return false; + } +} diff --git a/src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js b/src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js new file mode 100644 index 00000000..ff3d83f2 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js @@ -0,0 +1,22 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Annotations from '/imports/api/annotations'; +import addAnnotationQuery from '/imports/api/annotations/addAnnotation'; + +export default async function addAnnotation(meetingId, whiteboardId, userId, annotation) { + check(meetingId, String); + check(whiteboardId, String); + check(annotation, Object); + + const query = await addAnnotationQuery(meetingId, whiteboardId, userId, annotation, Annotations); + + try { + const { insertedId } = await Annotations.upsertAsync(query.selector, query.modifier); + + if (insertedId) { + Logger.info(`Added annotation id=${annotation.id} whiteboard=${whiteboardId}`); + } + } catch (err) { + Logger.error(`Adding annotation to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js b/src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js new file mode 100644 index 00000000..b73cb71d --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js @@ -0,0 +1,43 @@ +import Annotations from '/imports/api/annotations'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearAnnotations(meetingId, whiteboardId, userId) { + const selector = {}; + + if (meetingId) { + selector.meetingId = meetingId; + } + + if (whiteboardId) { + selector.whiteboardId = whiteboardId; + } + + if (userId) { + selector.userId = userId; + } + + try { + const numberAffected = await Annotations.removeAsync(selector); + + if (numberAffected) { + if (userId) { + Logger.info(`Cleared Annotations for userId=${userId} where whiteboard=${whiteboardId}`); + return; + } + + if (whiteboardId) { + Logger.info(`Cleared Annotations for whiteboard=${whiteboardId}`); + return; + } + + if (meetingId) { + Logger.info(`Cleared Annotations (${meetingId})`); + return; + } + + Logger.info('Cleared Annotations (all)'); + } + } catch (err) { + Logger.error(`Removing Annotations from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js b/src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js new file mode 100644 index 00000000..7b1586d9 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import Annotations from '/imports/api/annotations'; +import Logger from '/imports/startup/server/logger'; + +export default async function removeAnnotation(meetingId, whiteboardId, shapeId) { + check(meetingId, String); + check(whiteboardId, String); + check(shapeId, String); + + const selector = { + meetingId, + whiteboardId, + id: shapeId, + }; + + try { + const numberAffected = await Annotations.removeAsync(selector); + + if (numberAffected) { + Logger.info(`Removed annotation id=${shapeId} whiteboard=${whiteboardId}`); + } + } catch (err) { + Logger.error(`Removing annotation from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/annotations/server/publishers.js b/src/2.7.12/imports/api/annotations/server/publishers.js new file mode 100644 index 00000000..73017db1 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/publishers.js @@ -0,0 +1,26 @@ +import Annotations from '/imports/api/annotations'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +function annotations() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Annotations was requested by unauth connection ${this.connection.id}`); + return Annotations.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing Annotations', { meetingId, userId }); + + return Annotations.find({ meetingId }); +} + +function publish(...args) { + const boundAnnotations = annotations.bind(this); + return boundAnnotations(...args); +} + +Meteor.publish('annotations', publish); diff --git a/src/2.7.12/imports/api/annotations/server/streamer.js b/src/2.7.12/imports/api/annotations/server/streamer.js new file mode 100644 index 00000000..d283da07 --- /dev/null +++ b/src/2.7.12/imports/api/annotations/server/streamer.js @@ -0,0 +1,24 @@ +import Logger from '/imports/startup/server/logger'; + +export function removeAnnotationsStreamer(meetingId) { + Logger.info(`Removing Annotations streamer object for meeting ${meetingId}`); + delete Meteor.StreamerCentral.instances[`annotations-${meetingId}`]; +} + +export function addAnnotationsStreamer(meetingId) { + const streamer = new Meteor.Streamer(`annotations-${meetingId}`, { retransmit: false }); + + streamer.allowRead(function allowRead() { + if (!this.userId) return false; + + return this.userId && this.userId.includes(meetingId); + }); + + streamer.allowWrite(function allowWrite() { + return false; + }); +} + +export default function get(meetingId) { + return Meteor.StreamerCentral.instances[`annotations-${meetingId}`]; +} diff --git a/src/2.7.12/imports/api/audio-captions/index.js b/src/2.7.12/imports/api/audio-captions/index.js new file mode 100644 index 00000000..cf104702 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/index.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +const AudioCaptions = new Mongo.Collection('audio-captions'); + +if (Meteor.isServer) { + AudioCaptions.createIndexAsync({ meetingId: 1 }); +} + +export default AudioCaptions; diff --git a/src/2.7.12/imports/api/audio-captions/server/eventHandlers.js b/src/2.7.12/imports/api/audio-captions/server/eventHandlers.js new file mode 100644 index 00000000..1ac75830 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/eventHandlers.js @@ -0,0 +1,6 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleTranscriptUpdated from '/imports/api/audio-captions/server/handlers/transcriptUpdated'; +import handleTranscriptionProviderError from '/imports/api/audio-captions/server/handlers/transcriptionProviderError'; + +RedisPubSub.on('TranscriptUpdatedEvtMsg', handleTranscriptUpdated); +RedisPubSub.on('TranscriptionProviderErrorEvtMsg', handleTranscriptionProviderError); diff --git a/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js new file mode 100644 index 00000000..6b1a4652 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js @@ -0,0 +1,38 @@ +import setTranscript from '/imports/api/audio-captions/server/modifiers/setTranscript'; +import updateTranscriptPad from '/imports/api/pads/server/methods/updateTranscriptPad'; +import Users from '/imports/api/users'; + +const TRANSCRIPTION_DEFAULT_PAD = Meteor.settings.public.captions.defaultPad; + +const formatDate = (dStr) => { + return ("00" + dStr).substr(-2,2); +}; + +export default async function transcriptUpdated({ header, body }) { + const { + meetingId, + userId, + } = header; + + const { + transcriptId, + transcript, + locale, + result, + } = body; + + if (result) { + const user = Users.findOne({ userId }, { fields: { name: 1 } }); + const userName = user?.name || '??'; + + const dt = new Date(Date.now()); + const hours = formatDate(dt.getHours()), + minutes = formatDate(dt.getMinutes()), + seconds = formatDate(dt.getSeconds()); + + const userSpoke = `\n ${userName} (${hours}:${minutes}:${seconds}): ${transcript}`; + updateTranscriptPad(meetingId, userId, TRANSCRIPTION_DEFAULT_PAD, userSpoke); + } + + await setTranscript(userId, meetingId, transcriptId, transcript, locale); +} diff --git a/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js new file mode 100644 index 00000000..ab5c5ae9 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js @@ -0,0 +1,38 @@ +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default async function handleTranscriptionProviderError({ header, body }) { + const { + meetingId, + userId, + } = header; + + const selector = { + meetingId, + userId, + }; + + const { + errorCode, + errorMessage, + } = body; + + const modifier = { + $set: { + transcriptionError: { + code: errorCode, + message: errorMessage, + }, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.error(`Transcription error errorCode=${errorCode} errorMessage=${errorMessage} meetingId=${meetingId}`); + } + } catch (err) { + Logger.error(`Problem setting transcription error: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/audio-captions/server/index.js b/src/2.7.12/imports/api/audio-captions/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/audio-captions/server/methods.js b/src/2.7.12/imports/api/audio-captions/server/methods.js new file mode 100644 index 00000000..e0b22b21 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/methods.js @@ -0,0 +1,6 @@ +import { Meteor } from 'meteor/meteor'; +import updateTranscript from '/imports/api/audio-captions/server/methods/updateTranscript'; + +Meteor.methods({ + updateTranscript, +}); diff --git a/src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js b/src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js new file mode 100644 index 00000000..a00802a1 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js @@ -0,0 +1,41 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function updateTranscript(transcriptId, start, end, text, transcript, locale, isFinal) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UpdateTranscriptPubMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(transcriptId, String); + check(start, Number); + check(end, Number); + check(text, String); + check(transcript, String); + check(locale, String); + check(isFinal, Boolean); + + // Ignore irrelevant updates + if (start !== -1 && end !== -1) { + const payload = { + transcriptId, + start, + end, + text, + transcript, + locale, + result: isFinal, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } + } catch (err) { + Logger.error(`Exception while invoking method upadteTranscript ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js b/src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js new file mode 100644 index 00000000..cd97c33f --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js @@ -0,0 +1,26 @@ +import AudioCaptions from '/imports/api/audio-captions'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearAudioCaptions(meetingId) { + if (meetingId) { + try { + const numberAffected = await AudioCaptions.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared AudioCaptions (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing audio captions (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await AudioCaptions.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared AudioCaptions (all)'); + } + } catch (err) { + Logger.error(`Error on clearing audio captions (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js b/src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js new file mode 100644 index 00000000..c21583b7 --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import AudioCaptions from '/imports/api/audio-captions'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default async function setTranscript(userId, meetingId, transcriptId, transcript, locale) { + try { + check(meetingId, String); + check(transcriptId, String); + check(transcript, String); + + const selector = { meetingId, transcriptId }; + + const modifier = { + $set: { + transcript, + lastUpdated: Math.floor(new Date().getTime()/1000), + locale, + }, + }; + + const numberAffected = await AudioCaptions.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.debug(`Set transcriptId=${transcriptId} transcript=${transcript} meeting=${meetingId} locale=${locale}`); + } else { + Logger.debug(`Upserted transcriptId=${transcriptId} transcript=${transcript} meeting=${meetingId} locale=${locale}`); + } + } catch (err) { + Logger.error(`Setting audio captions transcript to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/audio-captions/server/publishers.js b/src/2.7.12/imports/api/audio-captions/server/publishers.js new file mode 100644 index 00000000..17d4632f --- /dev/null +++ b/src/2.7.12/imports/api/audio-captions/server/publishers.js @@ -0,0 +1,26 @@ +import AudioCaptions from '/imports/api/audio-captions'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function audioCaptions() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing AudioCaptions was requested by unauth connection ${this.connection.id}`); + return AudioCaptions.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + Logger.debug('Publishing AudioCaptions', { meetingId, requestedBy: userId }); + + return AudioCaptions.find({ meetingId }); +} + +function publish(...args) { + const boundAudioCaptions = audioCaptions.bind(this); + return boundAudioCaptions(...args); +} + +Meteor.publish('audio-captions', publish); diff --git a/src/2.7.12/imports/api/audio/client/bridge/base.js b/src/2.7.12/imports/api/audio/client/bridge/base.js new file mode 100644 index 00000000..742dcf96 --- /dev/null +++ b/src/2.7.12/imports/api/audio/client/bridge/base.js @@ -0,0 +1,177 @@ +import { Tracker } from 'meteor/tracker'; +import VoiceCallStates from '/imports/api/voice-call-states'; +import CallStateOptions from '/imports/api/voice-call-states/utils/callStates'; +import logger from '/imports/startup/client/logger'; +import Auth from '/imports/ui/services/auth'; +import { + getAudioConstraints, + doGUM, +} from '/imports/api/audio/client/bridge/service'; + +const MEDIA = Meteor.settings.public.media; +const BASE_BRIDGE_NAME = 'base'; +const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout; +const TRANSFER_TONE = '1'; + +export default class BaseAudioBridge { + constructor(userData) { + this.userData = userData; + + this.baseErrorCodes = { + INVALID_TARGET: 'INVALID_TARGET', + CONNECTION_ERROR: 'CONNECTION_ERROR', + REQUEST_TIMEOUT: 'REQUEST_TIMEOUT', + GENERIC_ERROR: 'GENERIC_ERROR', + MEDIA_ERROR: 'MEDIA_ERROR', + WEBRTC_NOT_SUPPORTED: 'WEBRTC_NOT_SUPPORTED', + ICE_NEGOTIATION_FAILED: 'ICE_NEGOTIATION_FAILED', + }; + + this.baseCallStates = { + started: 'started', + ended: 'ended', + failed: 'failed', + reconnecting: 'reconnecting', + autoplayBlocked: 'autoplayBlocked', + }; + + this.bridgeName = BASE_BRIDGE_NAME; + } + + getPeerConnection() { + console.error('The Bridge must implement getPeerConnection'); + } + + exitAudio() { + console.error('The Bridge must implement exitAudio'); + } + + joinAudio() { + console.error('The Bridge must implement joinAudio'); + } + + changeInputDevice() { + console.error('The Bridge must implement changeInputDevice'); + } + + setInputStream() { + console.error('The Bridge must implement setInputStream'); + } + + sendDtmf() { + console.error('The Bridge must implement sendDtmf'); + } + + set inputDeviceId (deviceId) { + this._inputDeviceId = deviceId; + } + + get inputDeviceId () { + return this._inputDeviceId; + + } + + /** + * Change the input device with the given deviceId, without renegotiating + * peer. + * A new MediaStream object is created for the given deviceId. This object + * is returned by the resolved promise. + * @param {String} deviceId The id of the device to be set as input + * @return {Promise} A promise that is resolved with the MediaStream + * object after changing the input device. + */ + async liveChangeInputDevice(deviceId) { + let newStream; + let backupStream; + + try { + const constraints = { + audio: getAudioConstraints({ deviceId }), + }; + + // Backup stream (current one) in case the switch fails + if (this.inputStream && this.inputStream.active) { + backupStream = this.inputStream ? this.inputStream.clone() : null; + this.inputStream.getAudioTracks().forEach((track) => track.stop()); + } + + newStream = await doGUM(constraints); + await this.setInputStream(newStream); + if (backupStream && backupStream.active) { + backupStream.getAudioTracks().forEach((track) => track.stop()); + backupStream = null; + } + + return newStream; + } catch (error) { + // Device change failed. Clean up the tentative new stream to avoid lingering + // stuff, then try to rollback to the previous input stream. + if (newStream && typeof newStream.getAudioTracks === 'function') { + newStream.getAudioTracks().forEach((t) => t.stop()); + newStream = null; + } + + // Rollback to backup stream + if (backupStream && backupStream.active) { + this.setInputStream(backupStream).catch((rollbackError) => { + logger.error({ + logCode: 'audio_changeinputdevice_rollback_failure', + extraInfo: { + bridgeName: this.bridgeName, + deviceId, + errorName: rollbackError.name, + errorMessage: rollbackError.message, + }, + }, 'Microphone device change rollback failed - the device may become silent'); + + backupStream.getAudioTracks().forEach((track) => track.stop()); + backupStream = null; + }); + } + + throw error; + } + } + + trackTransferState(transferCallback) { + return new Promise((resolve, reject) => { + let trackerControl = null; + + const timeout = setTimeout(() => { + trackerControl.stop(); + logger.warn({ logCode: 'audio_transfer_timed_out' }, + 'Timeout on transferring from echo test to conference'); + this.callback({ + status: this.baseCallStates.failed, + error: 1008, + bridgeError: 'Timeout on call transfer', + bridge: this.bridgeName, + }); + + this.exitAudio(); + + reject(this.baseErrorCodes.REQUEST_TIMEOUT); + }, CALL_TRANSFER_TIMEOUT); + + this.sendDtmf(TRANSFER_TONE); + + Tracker.autorun((c) => { + trackerControl = c; + const selector = { meetingId: Auth.meetingID, userId: Auth.userID }; + const query = VoiceCallStates.find(selector); + + query.observeChanges({ + changed: (id, fields) => { + if (fields.callState === CallStateOptions.IN_CONFERENCE) { + clearTimeout(timeout); + transferCallback(); + + c.stop(); + resolve(); + } + }, + }); + }); + }); + } +} diff --git a/src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js b/src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js new file mode 100644 index 00000000..1f2cd0a9 --- /dev/null +++ b/src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js @@ -0,0 +1,17 @@ +/** + * Bridge whitelist, needed for dynamically importing bridges (as modules). + * + * The code is intentionally unreachable, but its trigger Meteor's static + * analysis, which makes bridge module available to build process. + * + * For new bridges, we must append an import statement here. + * + * More information here: + *https://docs.meteor.com/packages/dynamic-import.html + */ + +throw new Error(); + +/* eslint-disable no-unreachable */ +// BRIDGES LIST +import('/imports/api/audio/client/bridge/FullAudioBridge'); // NOSONAR diff --git a/src/2.7.12/imports/api/audio/client/bridge/service.js b/src/2.7.12/imports/api/audio/client/bridge/service.js new file mode 100644 index 00000000..d795db43 --- /dev/null +++ b/src/2.7.12/imports/api/audio/client/bridge/service.js @@ -0,0 +1,134 @@ +import Settings from '/imports/ui/services/settings'; +import logger from '/imports/startup/client/logger'; +import BBBStorage from '/imports/ui/services/storage'; + +const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber'; +const DEFAULT_INPUT_DEVICE_ID = ''; +const DEFAULT_OUTPUT_DEVICE_ID = ''; +const INPUT_DEVICE_ID_KEY = 'audioInputDeviceId'; +const OUTPUT_DEVICE_ID_KEY = 'audioOutputDeviceId'; +const AUDIO_MICROPHONE_CONSTRAINTS = Meteor.settings.public.app.defaultSettings + .application.microphoneConstraints; +const MEDIA_TAG = Meteor.settings.public.media.mediaTag; + +const getAudioSessionNumber = () => { + let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10); + if (!currItem) { + currItem = 0; + } + + currItem += 1; + sessionStorage.setItem(AUDIO_SESSION_NUM_KEY, currItem); + return currItem; +}; + +const getCurrentAudioSessionNumber = () => sessionStorage.getItem(AUDIO_SESSION_NUM_KEY) || '0'; + +const reloadAudioElement = (audioElement) => { + if (audioElement && (audioElement.readyState > 0)) { + audioElement.load(); + return true; + } + + return false; +}; + +const getCurrentAudioSinkId = () => { + const audioElement = document.querySelector(MEDIA_TAG); + return audioElement?.sinkId || DEFAULT_OUTPUT_DEVICE_ID; +}; + +const getStoredAudioInputDeviceId = () => BBBStorage.getItem(INPUT_DEVICE_ID_KEY); +const getStoredAudioOutputDeviceId = () => BBBStorage.getItem(OUTPUT_DEVICE_ID_KEY); +const storeAudioInputDeviceId = (deviceId) => BBBStorage.setItem(INPUT_DEVICE_ID_KEY, deviceId); +const storeAudioOutputDeviceId = (deviceId) => BBBStorage.setItem(OUTPUT_DEVICE_ID_KEY, deviceId); + +/** + * Filter constraints set in audioDeviceConstraints, based on + * constants supported by browser. This avoids setting a constraint + * unsupported by browser. In currently safari version (13+), for example, + * setting an unsupported constraint crashes the audio. + * @param {Object} audioDeviceConstraints Constraints to be set + * see: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints + * @return {Object} A new Object of the same type as + * input, containing only the supported constraints. + */ +const filterSupportedConstraints = (audioDeviceConstraints) => { + try { + const matchConstraints = {}; + const supportedConstraints = navigator + .mediaDevices.getSupportedConstraints() || {}; + Object.entries(audioDeviceConstraints).forEach( + ([constraintName, constraintValue]) => { + if (supportedConstraints[constraintName]) { + matchConstraints[constraintName] = constraintValue; + } + }, + ); + + return matchConstraints; + } catch (error) { + logger.error({ + logCode: 'audio_unsupported_constraint_error', + }, 'Unsupported audio constraints'); + return {}; + } +}; + +const getAudioConstraints = (constraintFields = {}) => { + const { deviceId = '' } = constraintFields; + const userSettingsConstraints = Settings.application.microphoneConstraints; + const audioDeviceConstraints = userSettingsConstraints + || AUDIO_MICROPHONE_CONSTRAINTS || {}; + + const matchConstraints = filterSupportedConstraints( + audioDeviceConstraints, + ); + + if (deviceId) { + matchConstraints.deviceId = { exact: deviceId }; + } + + return matchConstraints; +}; + +const doGUM = async (constraints, retryOnFailure = false) => { + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + return stream; + } catch (error) { + // This is probably a deviceId mistmatch. Retry with base constraints + // without an exact deviceId. + if (error.name === 'OverconstrainedError' && retryOnFailure) { + logger.warn({ + logCode: 'audio_overconstrainederror_rollback', + extraInfo: { + constraints, + }, + }, 'Audio getUserMedia returned OverconstrainedError, rollback'); + + return navigator.mediaDevices.getUserMedia({ audio: getAudioConstraints() }); + } + + // Not OverconstrainedError - bubble up the error. + throw error; + } +}; + +export { + DEFAULT_INPUT_DEVICE_ID, + DEFAULT_OUTPUT_DEVICE_ID, + INPUT_DEVICE_ID_KEY, + OUTPUT_DEVICE_ID_KEY, + getAudioSessionNumber, + getCurrentAudioSessionNumber, + reloadAudioElement, + filterSupportedConstraints, + getAudioConstraints, + getCurrentAudioSinkId, + getStoredAudioInputDeviceId, + storeAudioInputDeviceId, + getStoredAudioOutputDeviceId, + storeAudioOutputDeviceId, + doGUM, +}; diff --git a/src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js b/src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js new file mode 100644 index 00000000..e26fec57 --- /dev/null +++ b/src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js @@ -0,0 +1,483 @@ +import BaseAudioBridge from './base'; +import Auth from '/imports/ui/services/auth'; +import logger from '/imports/startup/client/logger'; +import AudioBroker from '/imports/ui/services/bbb-webrtc-sfu/audio-broker'; +import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play'; +import { + fetchWebRTCMappedStunTurnServers, + getMappedFallbackStun, +} from '/imports/utils/fetchStunTurnServers'; +import getFromMeetingSettings from '/imports/ui/services/meeting-settings'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import browserInfo from '/imports/utils/browserInfo'; +import { + getAudioSessionNumber, + getAudioConstraints, + filterSupportedConstraints, + doGUM, +} from '/imports/api/audio/client/bridge/service'; +import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils'; + +const SFU_URL = Meteor.settings.public.kurento.wsUrl; +const DEFAULT_LISTENONLY_MEDIA_SERVER = Meteor.settings.public.kurento.listenOnlyMediaServer; +const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates; +const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs; +const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout; +const MEDIA = Meteor.settings.public.media; +const DEFAULT_FULLAUDIO_MEDIA_SERVER = MEDIA.audio.fullAudioMediaServer; +const RETRY_THROUGH_RELAY = MEDIA.audio.retryThroughRelay || false; +const LISTEN_ONLY_OFFERING = MEDIA.listenOnlyOffering; +const FULLAUDIO_OFFERING = MEDIA.fullAudioOffering; +const TRANSPARENT_LISTEN_ONLY = MEDIA.transparentListenOnly; +const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, ''); +const CONNECTION_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000; +const { audio: NETWORK_PRIORITY } = MEDIA.networkPriorities || {}; +const SENDRECV_ROLE = 'sendrecv'; +const RECV_ROLE = 'recv'; +const BRIDGE_NAME = 'fullaudio'; +const IS_CHROME = browserInfo.isChrome; + +// SFU's base broker has distinct error codes so that it can be reused by different +// modules. Errors that have a valid, localized counterpart in audio manager are +// mapped so that the user gets a localized error message. +// The ones that haven't (ie SFU's servers-side errors), aren't mapped. +const errorCodeMap = { + 1301: 1001, + 1302: 1002, + 1305: 1005, + 1307: 1007, +}; + +// Error codes that are prone to a retry according to RETRY_THROUGH_RELAY +const RETRYABLE_ERRORS = [1007, 1010]; + +const mapErrorCode = (error) => { + const { errorCode } = error; + const mappedErrorCode = errorCodeMap[errorCode]; + if (errorCode == null || mappedErrorCode == null) return error; + // eslint-disable-next-line no-param-reassign + error.errorCode = mappedErrorCode; + return error; +}; + +const getMediaServerAdapter = (listenOnly = false) => { + if (listenOnly) { + return getFromMeetingSettings( + 'media-server-listenonly', + DEFAULT_LISTENONLY_MEDIA_SERVER, + ); + } + + return getFromMeetingSettings( + 'media-server-fullaudio', + DEFAULT_FULLAUDIO_MEDIA_SERVER, + ); +}; + +const isTransparentListenOnlyEnabled = () => getFromUserSettings( + 'bbb_transparent_listen_only', + TRANSPARENT_LISTEN_ONLY, +); + +export default class SFUAudioBridge extends BaseAudioBridge { + static getOfferingRole(isListenOnly) { + return isListenOnly + ? LISTEN_ONLY_OFFERING + : (!isTransparentListenOnlyEnabled() && FULLAUDIO_OFFERING); + } + + constructor(userData) { + super(); + this.userId = userData.userId; + this.name = userData.username; + this.sessionToken = userData.sessionToken; + this.broker = null; + this.reconnecting = false; + this.iceServers = []; + this.bridgeName = BRIDGE_NAME; + + this.handleTermination = this.handleTermination.bind(this); + } + + get inputStream() { + if (this.broker) { + return this.broker.getLocalStream(); + } + + return null; + } + + get role() { + return this.broker?.role; + } + + setInputStream(stream) { + if (this.broker == null) return null; + + return this.broker.setLocalStream(stream); + } + + getPeerConnection() { + if (!this.broker) return null; + + const { webRtcPeer } = this.broker; + if (webRtcPeer) return webRtcPeer.peerConnection; + return null; + } + + // eslint-disable-next-line class-methods-use-this + mediaStreamFactory(constraints) { + return doGUM(constraints, true); + } + + setConnectionTimeout() { + if (this.connectionTimeout) this.clearConnectionTimeout(); + + this.connectionTimeout = setTimeout(() => { + const error = new Error(`ICE negotiation timeout after ${CONNECTION_TIMEOUT_MS / 1000}s`); + error.errorCode = 1010; + // Duplicating key-vals because I can'decide settle on an error pattern - prlanzarin again + error.errorCause = error.message; + error.errorMessage = error.message; + this.handleBrokerFailure(error); + }, CONNECTION_TIMEOUT_MS); + } + + clearConnectionTimeout() { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + } + + dispatchAutoplayHandlingEvent(mediaElement) { + const tagFailedEvent = new CustomEvent('audioPlayFailed', { + detail: { mediaElement }, + }); + window.dispatchEvent(tagFailedEvent); + this.callback({ status: this.baseCallStates.autoplayBlocked, bridge: this.bridgeName }); + } + + reconnect(options = {}) { + // If broker has already started, fire the reconnecting callback so the user + // knows what's going on + if (this.broker.started) { + this.callback({ status: this.baseCallStates.reconnecting, bridge: this.bridgeName }); + } else { + // Otherwise: override termination handler so the ended callback doesn't get + // triggered - this is a retry attempt and the user shouldn't be notified + // yet. + this.broker.onended = () => {}; + } + + this.broker.stop(); + this.reconnecting = true; + this._startBroker({ isListenOnly: this.isListenOnly, ...options }) + .catch((error) => { + // Error handling is a no-op because it will be "handled" in handleBrokerFailure + logger.debug({ + logCode: 'sfuaudio_reconnect_failed', + extraInfo: { + errorMessage: error.errorMessage, + reconnecting: this.reconnecting, + bridge: this.bridgeName, + role: this.role, + }, + }, 'SFU audio reconnect failed'); + }); + } + + handleBrokerFailure(error) { + return new Promise((resolve, reject) => { + this.clearConnectionTimeout(); + mapErrorCode(error); + const { errorMessage, errorCause, errorCode } = error; + + if (!this.reconnecting) { + if (this.broker.started) { + logger.error({ + logCode: 'sfuaudio_error_try_to_reconnect', + extraInfo: { + errorMessage, + errorCode, + errorCause, + bridge: this.bridgeName, + role: this.role, + }, + }, 'SFU audio failed, try to reconnect'); + this.reconnect(); + return resolve(); + } + + if (RETRYABLE_ERRORS.includes(errorCode) && RETRY_THROUGH_RELAY) { + logger.error({ + logCode: 'sfuaudio_error_retry_through_relay', + extraInfo: { + errorMessage, + errorCode, + errorCause, + bridge: this.bridgeName, + role: this.role, + }, + }, 'SFU audio failed to connect, retry through relay'); + this.reconnect({ forceRelay: true }); + return resolve(); + } + } + + // Already tried reconnecting once OR the user handn't succesfully + // connected firsthand and retrying isn't an option. Finish the session + // and reject with the error + logger.error({ + logCode: 'sfuaudio_error', + extraInfo: { + errorMessage, + errorCode, + errorCause, + reconnecting: this.reconnecting, + bridge: this.bridgeName, + role: this.role, + }, + }, 'SFU audio failed'); + this.clearConnectionTimeout(); + this.broker.stop(); + this.callback({ + status: this.baseCallStates.failed, + error: errorCode, + bridgeError: errorMessage, + bridge: this.bridgeName, + }); + return reject(error); + }); + } + + handleTermination() { + this.clearConnectionTimeout(); + return this.callback({ status: this.baseCallStates.ended, bridge: this.bridgeName }); + } + + handleStart() { + const stream = this.broker.webRtcPeer.getRemoteStream(); + const mediaElement = document.getElementById(MEDIA_TAG); + + return loadAndPlayMediaStream(stream, mediaElement, false).then(() => { + this.callback({ + status: this.baseCallStates.started, + bridge: this.bridgeName, + }); + this.clearConnectionTimeout(); + this.reconnecting = false; + }).catch((error) => { + // NotAllowedError equals autoplay issues, fire autoplay handling event. + // This will be handled in audio-manager. + if (error.name === 'NotAllowedError') { + logger.error({ + logCode: 'sfuaudio_error_autoplay', + extraInfo: { + errorName: error.name, + bridge: this.bridgeName, + role: this.role, + }, + }, 'SFU audio media play failed due to autoplay error'); + this.dispatchAutoplayHandlingEvent(mediaElement); + // For connection purposes, this worked - the autoplay thing is a client + // side soft issue to be handled at the UI/UX level, not WebRTC/negotiation + // So: clear the connection timer + this.clearConnectionTimeout(); + this.reconnecting = false; + } else { + const normalizedError = { + errorCode: 1004, + errorMessage: error.message || 'AUDIO_PLAY_FAILED', + }; + this.callback({ + status: this.baseCallStates.failed, + error: normalizedError.errorCode, + bridgeError: normalizedError.errorMessage, + bridge: this.bridgeName, + }); + throw normalizedError; + } + }); + } + + async _startBroker(options) { + try { + this.iceServers = await fetchWebRTCMappedStunTurnServers(this.sessionToken); + } catch (error) { + logger.error({ logCode: 'sfuaudio_stun-turn_fetch_failed' }, + 'SFU audio bridge failed to fetch STUN/TURN info, using default servers'); + this.iceServers = getMappedFallbackStun(); + } + + return new Promise((resolve, reject) => { + const { + isListenOnly, + extension, + inputStream, + forceRelay: _forceRelay = false, + } = options; + + const handleInitError = (_error) => { + mapErrorCode(_error); + if (RETRYABLE_ERRORS.includes(_error?.errorCode) + || !RETRY_THROUGH_RELAY + || this.reconnecting) { + reject(_error); + } + }; + + try { + this.inEchoTest = !!extension; + this.isListenOnly = isListenOnly; + + const brokerOptions = { + clientSessionNumber: getAudioSessionNumber(), + extension, + iceServers: this.iceServers, + mediaServer: getMediaServerAdapter(isListenOnly), + constraints: getAudioConstraints({ deviceId: this.inputDeviceId }), + forceRelay: _forceRelay || shouldForceRelay(), + stream: (inputStream && inputStream.active) ? inputStream : undefined, + offering: SFUAudioBridge.getOfferingRole(this.isListenOnly), + signalCandidates: SIGNAL_CANDIDATES, + traceLogs: TRACE_LOGS, + networkPriority: NETWORK_PRIORITY, + mediaStreamFactory: this.mediaStreamFactory, + gatheringTimeout: GATHERING_TIMEOUT, + transparentListenOnly: isTransparentListenOnlyEnabled(), + }; + + this.broker = new AudioBroker( + Auth.authenticateURL(SFU_URL), + isListenOnly ? RECV_ROLE : SENDRECV_ROLE, + brokerOptions, + ); + + this.broker.onended = this.handleTermination.bind(this); + this.broker.onerror = (error) => { + this.handleBrokerFailure(error).catch(reject); + }; + this.broker.onstart = () => { + this.handleStart().then(resolve).catch(reject); + }; + + // Set up a connectionTimeout in case the server or network are botching + // negotiation or conn checks. + this.setConnectionTimeout(); + this.broker.joinAudio().catch(handleInitError); + } catch (error) { + handleInitError(error); + } + }); + } + + async joinAudio(options, callback) { + this.callback = callback; + this.reconnecting = false; + + return this._startBroker(options); + } + + sendDtmf(tones) { + if (this.broker) { + this.broker.dtmf(tones); + } + } + + transferCall(onTransferSuccess) { + this.inEchoTest = false; + return this.trackTransferState(onTransferSuccess); + } + + async updateAudioConstraints(constraints) { + try { + if (typeof constraints !== 'object') return; + + const matchConstraints = filterSupportedConstraints(constraints); + + if (IS_CHROME) { + matchConstraints.deviceId = this.inputDeviceId; + const stream = await doGUM({ audio: matchConstraints }); + await this.setInputStream(stream); + } else { + this.inputStream.getAudioTracks() + .forEach((track) => track.applyConstraints(matchConstraints)); + } + } catch (error) { + logger.error({ + logCode: 'sfuaudio_audio_constraint_error', + extraInfo: { + errorCode: error.code, + errorMessage: error.message, + bridgeName: this.bridgeName, + role: this.role, + }, + }, 'Failed to update audio constraint'); + } + } + + trickleIce() { + return new Promise((resolve, reject) => { + try { + fetchWebRTCMappedStunTurnServers(this.sessionToken) + .then((iceServers) => { + const options = { + clientSessionNumber: getAudioSessionNumber(), + iceServers, + offering: LISTEN_ONLY_OFFERING, + traceLogs: TRACE_LOGS, + gatheringTimeout: GATHERING_TIMEOUT, + }; + + this.broker = new AudioBroker( + Auth.authenticateURL(SFU_URL), + RECV_ROLE, + options, + ); + + this.broker.onstart = () => { + const { peerConnection } = this.broker.webRtcPeer; + + if (!peerConnection) return resolve(null); + + const selectedCandidatePair = peerConnection.getReceivers()[0] + .transport.iceTransport.getSelectedCandidatePair(); + + const validIceCandidate = [selectedCandidatePair.local]; + + this.broker.stop(); + return resolve(validIceCandidate); + }; + + this.broker.joinAudio().catch(reject); + }); + } catch (error) { + // Rollback + this.exitAudio(); + reject(error); + } + }); + } + + exitAudio() { + const mediaElement = document.getElementById(MEDIA_TAG); + + this.clearConnectionTimeout(); + this.reconnecting = false; + + if (this.broker) { + this.broker.stop(); + this.broker = null; + } + + if (mediaElement && typeof mediaElement.pause === 'function') { + mediaElement.pause(); + mediaElement.srcObject = null; + } + + return Promise.resolve(); + } +} + +module.exports = SFUAudioBridge; diff --git a/src/2.7.12/imports/api/audio/client/bridge/sip.js b/src/2.7.12/imports/api/audio/client/bridge/sip.js new file mode 100644 index 00000000..e7f013c9 --- /dev/null +++ b/src/2.7.12/imports/api/audio/client/bridge/sip.js @@ -0,0 +1,1331 @@ +import BaseAudioBridge from './base'; +import logger from '/imports/startup/client/logger'; +import { + fetchWebRTCMappedStunTurnServers, + getMappedFallbackStun, +} from '/imports/utils/fetchStunTurnServers'; +import { + isUnifiedPlan, + toUnifiedPlan, + toPlanB, + stripMDnsCandidates, + filterValidIceCandidates, + analyzeSdp, + logSelectedCandidate, + forceDisableStereo, +} from '/imports/utils/sdpUtils'; +import { Tracker } from 'meteor/tracker'; +import VoiceCallStates from '/imports/api/voice-call-states'; +import CallStateOptions from '/imports/api/voice-call-states/utils/callStates'; +import Auth from '/imports/ui/services/auth'; +import browserInfo from '/imports/utils/browserInfo'; +import { + getCurrentAudioSessionNumber, + getAudioSessionNumber, + getAudioConstraints, + filterSupportedConstraints, + doGUM, +} from '/imports/api/audio/client/bridge/service'; +import SpeechService from '/imports/ui/components/audio/captions/speech/service'; + +const MEDIA = Meteor.settings.public.media; +const MEDIA_TAG = MEDIA.mediaTag; +const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout; +const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries; +const SIPJS_HACK_VIA_WS = MEDIA.sipjsHackViaWs; +const SIPJS_ALLOW_MDNS = MEDIA.sipjsAllowMdns || false; +const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain; +const CALL_CONNECT_TIMEOUT = 20000; +const ICE_NEGOTIATION_TIMEOUT = 20000; +const USER_AGENT_RECONNECTION_ATTEMPTS = MEDIA.audioReconnectionAttempts || 3; +const USER_AGENT_RECONNECTION_DELAY_MS = MEDIA.audioReconnectionDelay || 5000; +const USER_AGENT_CONNECTION_TIMEOUT_MS = MEDIA.audioConnectionTimeout || 5000; +const ICE_GATHERING_TIMEOUT = MEDIA.iceGatheringTimeout || 5000; +const BRIDGE_NAME = 'sip'; +const WEBSOCKET_KEEP_ALIVE_INTERVAL = MEDIA.websocketKeepAliveInterval || 0; +const WEBSOCKET_KEEP_ALIVE_DEBOUNCE = MEDIA.websocketKeepAliveDebounce || 10; +const TRACE_SIP = MEDIA.traceSip || false; +const SDP_SEMANTICS = MEDIA.sdpSemantics; +const FORCE_RELAY = MEDIA.forceRelay; + +const UA_SERVER_VERSION = Meteor.settings.public.app.bbbServerVersion; +const UA_CLIENT_VERSION = Meteor.settings.public.app.html5ClientBuild; + +/** + * Get error code from SIP.js websocket messages. + */ +const getErrorCode = (error) => { + try { + if (!error) return error; + + const match = error.message.match(/code: \d+/g); + + const _codeArray = match[0].split(':'); + + return parseInt(_codeArray[1].trim(), 10); + } catch (e) { + return 0; + } +}; + +class SIPSession { + constructor(user, userData, protocol, hostname, + baseCallStates, baseErrorCodes, reconnectAttempt) { + this.user = user; + this.userData = userData; + this.protocol = protocol; + this.hostname = hostname; + this.baseCallStates = baseCallStates; + this.baseErrorCodes = baseErrorCodes; + this.reconnectAttempt = reconnectAttempt; + this.currentSession = null; + this.remoteStream = null; + this.bridgeName = BRIDGE_NAME; + this._inputDeviceId = null; + this._outputDeviceId = null; + this._hangupFlag = false; + this._reconnecting = false; + this._currentSessionState = null; + this._ignoreCallState = false; + + this.mediaStreamFactory = this.mediaStreamFactory.bind(this) + } + + get inputStream() { + if (this.currentSession && this.currentSession.sessionDescriptionHandler) { + return this.currentSession.sessionDescriptionHandler.localMediaStream; + } + return null; + } + + /** + * Set the input stream for the peer that represents the current session. + * Internally, this will call the sender's replaceTrack function. + * @param {MediaStream} stream The MediaStream object to be used as input + * stream + * @return {Promise} A Promise that is resolved with the + * MediaStream object that was set. + */ + setInputStream(stream) { + if (!this.currentSession?.sessionDescriptionHandler) return null; + + return this.currentSession.sessionDescriptionHandler.setLocalMediaStream(stream); + } + + get inputDeviceId() { + if (!this._inputDeviceId) { + const stream = this.inputStream; + + if (stream) { + const track = stream.getAudioTracks().find( + (t) => t.getSettings().deviceId, + ); + + if (track && (typeof track.getSettings === 'function')) { + const { deviceId } = track.getSettings(); + this._inputDeviceId = deviceId; + } + } + } + + return this._inputDeviceId; + } + + set inputDeviceId(deviceId) { + this._inputDeviceId = deviceId; + } + + get outputDeviceId() { + if (!this._outputDeviceId) { + const audioElement = document.querySelector(MEDIA_TAG); + if (audioElement) { + this._outputDeviceId = audioElement.sinkId; + } + } + + return this._outputDeviceId; + } + + set outputDeviceId(deviceId) { + this._outputDeviceId = deviceId; + } + + /** + * This _ignoreCallState flag is set to true when we want to ignore SIP's + * call state retrieved directly from FreeSWITCH ESL, when doing some checks + * (for example , when checking if call stopped). + * We need to ignore this , for example, when moderator is in + * breakout audio transfer ("Join Audio" button in breakout panel): in this + * case , we will monitor moderator's lifecycle in audio conference by + * using the SIP state taken from SIP.js only (ignoring the ESL's call state). + * @param {boolean} value true to ignore call state, false otherwise. + */ + set ignoreCallState(value) { + this._ignoreCallState = value; + } + + get ignoreCallState() { + return this._ignoreCallState; + } + + joinAudio({ + isListenOnly, + extension, + inputDeviceId, + outputDeviceId, + validIceCandidates, + inputStream, + }, managerCallback) { + return new Promise((resolve, reject) => { + const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge; + + this.ignoreCallState = false; + + const callback = (message) => { + // There will sometimes we erroneous errors put out like timeouts and improper shutdowns, + // but only the first error ever matters + if (this.alreadyErrored) { + logger.info({ + logCode: 'sip_js_absorbing_callback_message', + extraInfo: { message }, + }, 'Absorbing a redundant callback message.'); + return; + } + + if (message.status === this.baseCallStates.failed) { + this.alreadyErrored = true; + } + + managerCallback(message).then(resolve); + }; + + this.callback = callback; + + // If there's an extension passed it means that we're joining the echo test first + this.inEchoTest = !!extension; + + this.validIceCandidates = validIceCandidates; + return this.doCall({ + callExtension, + isListenOnly, + inputDeviceId, + outputDeviceId, + inputStream, + }).catch((reason) => { + reject(reason); + }); + }); + } + + async getIceServers(sessionToken) { + try { + const iceServers = await fetchWebRTCMappedStunTurnServers(sessionToken); + return iceServers; + } catch (error) { + logger.error({ + logCode: 'sip_js_fetchstunturninfo_error', + extraInfo: { + errorCode: error.code, + errorMessage: error.message, + callerIdName: this.user.callerIdName, + }, + }, 'Full audio bridge failed to fetch STUN/TURN info'); + return getMappedFallbackStun(); + } + } + + doCall(options) { + const { + isListenOnly, + inputDeviceId, + outputDeviceId, + inputStream, + } = options; + + this.inputDeviceId = inputDeviceId; + this.outputDeviceId = outputDeviceId; + // If a valid MediaStream was provided it means it was preloaded somewhere + // else - let's use it so we don't call gUM needlessly + if (inputStream && inputStream.active) this.preloadedInputStream = inputStream; + + const { + userId, + name, + sessionToken, + } = this.user; + + const callerIdName = [ + `${userId}_${getAudioSessionNumber()}`, + 'bbbID', + isListenOnly ? `LISTENONLY-${name}` : name, + ].join('-').replace(/"/g, "'"); + + this.user.callerIdName = callerIdName; + this.callOptions = options; + + return this.getIceServers(sessionToken) + .then(this.createUserAgent.bind(this)) + .then(this.inviteUserAgent.bind(this)); + } + + /** + * + * sessionSupportRTPPayloadDtmf + * tells if browser support RFC4733 DTMF. + * Safari 13 doens't support it yet + */ + sessionSupportRTPPayloadDtmf(session) { + try { + const sessionDescriptionHandler = session + ? session.sessionDescriptionHandler + : this.currentSession.sessionDescriptionHandler; + + const senders = sessionDescriptionHandler.peerConnection.getSenders(); + return !!(senders[0].dtmf); + } catch (error) { + return false; + } + } + + /** + * sendDtmf - send DTMF Tones using INFO message + * + * same as SimpleUser's dtmf + */ + sendDtmf(tone) { + const dtmf = tone; + const duration = 2000; + const body = { + contentDisposition: 'render', + contentType: 'application/dtmf-relay', + content: `Signal=${dtmf}\r\nDuration=${duration}`, + }; + const requestOptions = { body }; + return this.currentSession.info({ requestOptions }); + } + + exitAudio() { + return new Promise((resolve, reject) => { + let hangupRetries = 0; + this._hangupFlag = false; + + this.userRequestedHangup = true; + + const tryHangup = () => { + if (this._hangupFlag) { + resolve(); + } + + if ((this.currentSession + && (this.currentSession.state === SIP.SessionState.Terminated)) + || (this.userAgent && (!this.userAgent.isConnected()))) { + this._hangupFlag = true; + return resolve(); + } + + if (this.currentSession + && ((this.currentSession.state === SIP.SessionState.Establishing))) { + this.currentSession.cancel().then(() => { + this._hangupFlag = true; + return resolve(); + }); + } + + if (this.currentSession + && ((this.currentSession.state === SIP.SessionState.Established))) { + this.currentSession.bye().then(() => { + this._hangupFlag = true; + return resolve(); + }); + } + + if (this.userAgent && this.userAgent.isConnected()) { + this.userAgent.stop(); + window.removeEventListener('beforeunload', this.onBeforeUnload); + } + + + hangupRetries += 1; + + setTimeout(() => { + if (hangupRetries > CALL_HANGUP_MAX_RETRIES) { + this.callback({ + status: this.baseCallStates.failed, + error: 1006, + bridgeError: 'Timeout on call hangup', + bridge: this.bridgeName, + }); + return reject(this.baseErrorCodes.REQUEST_TIMEOUT); + } + + if (!this._hangupFlag) return tryHangup(); + return resolve(); + }, CALL_HANGUP_TIMEOUT); + }; + + return tryHangup(); + }); + } + + stopUserAgent() { + if (this.userAgent && (typeof this.userAgent.stop === 'function')) { + return this.userAgent.stop(); + } + return Promise.resolve(); + } + + onBeforeUnload() { + this.userRequestedHangup = true; + return this.stopUserAgent(); + } + + mediaStreamFactory(constraints) { + if (this.preloadedInputStream && this.preloadedInputStream.active) { + return Promise.resolve(this.preloadedInputStream); + } + // The rest of this mimicks the default factory behavior. + if (!constraints.audio && !constraints.video) { + return Promise.resolve(new MediaStream()); + } + + return doGUM(constraints, true); + } + + createUserAgent(iceServers) { + return new Promise((resolve, reject) => { + if (this.userRequestedHangup === true) reject(); + + const { + hostname, + protocol, + } = this; + + const { + callerIdName, + sessionToken, + } = this.user; + + logger.debug({ logCode: 'sip_js_creating_user_agent', extraInfo: { callerIdName } }, 'Creating the user agent'); + + if (this.userAgent && this.userAgent.isConnected()) { + if (this.userAgent.configuration.hostPortParams === this.hostname) { + logger.debug({ logCode: 'sip_js_reusing_user_agent', extraInfo: { callerIdName } }, 'Reusing the user agent'); + resolve(this.userAgent); + return; + } + logger.debug({ logCode: 'sip_js_different_host_name', extraInfo: { callerIdName } }, 'Different host name. need to kill'); + } + + const localSdpCallback = (sdp) => { + // For now we just need to call the utils function to parse and log the different pieces. + // In the future we're going to want to be tracking whether there were TURN candidates + // and IPv4 candidates to make informed decisions about what to do on fallbacks/reconnects. + analyzeSdp(sdp); + }; + + const remoteSdpCallback = (sdp) => { + // We have have to find the candidate that FS sends back to us to determine if the client + // is connecting with IPv4 or IPv6 + const sdpInfo = analyzeSdp(sdp, false); + this.protocolIsIpv6 = sdpInfo.v6Info.found; + }; + + let userAgentConnected = false; + const token = `sessionToken=${sessionToken}`; + + // Create session description handler factory + const customSDHFactory = SIP.Web.defaultSessionDescriptionHandlerFactory(this.mediaStreamFactory); + + this.userAgent = new SIP.UserAgent({ + uri: SIP.UserAgent.makeURI(`sip:${encodeURIComponent(callerIdName)}@${hostname}`), + transportOptions: { + server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`, + connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS, + keepAliveInterval: WEBSOCKET_KEEP_ALIVE_INTERVAL, + keepAliveDebounce: WEBSOCKET_KEEP_ALIVE_DEBOUNCE, + traceSip: TRACE_SIP, + }, + sessionDescriptionHandlerFactory: customSDHFactory, + sessionDescriptionHandlerFactoryOptions: { + peerConnectionConfiguration: { + iceServers, + sdpSemantics: SDP_SEMANTICS, + iceTransportPolicy: FORCE_RELAY ? 'relay' : undefined, + }, + }, + displayName: callerIdName, + register: false, + userAgentString: `BigBlueButton/${UA_SERVER_VERSION} (HTML5, rv:${UA_CLIENT_VERSION}) ${window.navigator.userAgent}`, + hackViaWs: SIPJS_HACK_VIA_WS, + }); + + const handleUserAgentConnection = () => { + if (!userAgentConnected) { + userAgentConnected = true; + resolve(this.userAgent); + } + }; + + const handleUserAgentDisconnection = () => { + if (this.userAgent) { + if (this.userRequestedHangup) { + userAgentConnected = false; + return; + } + + let error; + let bridgeError; + + if (!this._reconnecting) { + logger.info({ + logCode: 'sip_js_session_ua_disconnected', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent disconnected: trying to reconnect...' + + ` (userHangup = ${!!this.userRequestedHangup})`); + + logger.info({ + logCode: 'sip_js_session_ua_reconnecting', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent disconnected, reconnecting'); + + this.reconnect().then(() => { + logger.info({ + logCode: 'sip_js_session_ua_reconnected', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent succesfully reconnected'); + }).catch(() => { + if (userAgentConnected) { + error = 1001; + bridgeError = 'Websocket disconnected'; + } else { + error = 1002; + bridgeError = 'Websocket failed to connect'; + } + + this.stopUserAgent(); + + this.callback({ + status: this.baseCallStates.failed, + error, + bridgeError, + bridge: this.bridgeName, + }); + reject(this.baseErrorCodes.CONNECTION_ERROR); + }); + } + } + }; + + this.userAgent.transport.onConnect = handleUserAgentConnection; + this.userAgent.transport.onDisconnect = handleUserAgentDisconnection; + + const preturn = this.userAgent.start().then(() => { + logger.info({ + logCode: 'sip_js_session_ua_connected', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent succesfully connected'); + + window.addEventListener('beforeunload', this.onBeforeUnload.bind(this)); + + resolve(); + }).catch((error) => { + logger.info({ + logCode: 'sip_js_session_ua_reconnecting', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent failed to connect, reconnecting'); + + const code = getErrorCode(error); + + // Websocket's 1006 is currently mapped to BBB's 1002 + if (code === 1006) { + this.stopUserAgent(); + + this.callback({ + status: this.baseCallStates.failed, + error: 1002, + bridgeError: 'Websocket failed to connect', + bridge: this.bridgeName, + }); + return reject({ + type: this.baseErrorCodes.CONNECTION_ERROR, + }); + } + + this.reconnect().then(() => { + logger.info({ + logCode: 'sip_js_session_ua_reconnected', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent succesfully reconnected'); + + resolve(); + }).catch(() => { + this.stopUserAgent(); + + logger.info({ + logCode: 'sip_js_session_ua_disconnected', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'User agent failed to reconnect after' + + ` ${USER_AGENT_RECONNECTION_ATTEMPTS} attemps`); + + this.callback({ + status: this.baseCallStates.failed, + error: 1002, + bridgeError: 'Websocket failed to connect', + bridge: this.bridgeName, + }); + + reject({ + type: this.baseErrorCodes.CONNECTION_ERROR, + }); + }); + }); + + return preturn; + }); + } + + reconnect(attempts = 1) { + return new Promise((resolve, reject) => { + if (this._reconnecting) { + return resolve(); + } + + if (attempts > USER_AGENT_RECONNECTION_ATTEMPTS) { + return reject({ + type: this.baseErrorCodes.CONNECTION_ERROR, + }); + } + + this._reconnecting = true; + + logger.info({ + logCode: 'sip_js_session_ua_reconnection_attempt', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, `User agent reconnection attempt ${attempts}`); + + this.userAgent.reconnect().then(() => { + this._reconnecting = false; + resolve(); + }).catch(() => { + setTimeout(() => { + this._reconnecting = false; + this.reconnect(++attempts).then(() => { + resolve(); + }).catch((error) => { + reject(error); + }); + }, USER_AGENT_RECONNECTION_DELAY_MS); + }); + }); + } + + isValidIceCandidate(event) { + return event.candidate + && this.validIceCandidates + && this.validIceCandidates.find((validCandidate) => ( + (validCandidate.address === event.candidate.address) + || (validCandidate.relatedAddress === event.candidate.address)) + && (validCandidate.protocol === event.candidate.protocol)); + } + + onIceGatheringStateChange(event) { + const iceGatheringState = event.target + ? event.target.iceGatheringState + : null; + + if ((iceGatheringState === 'gathering') && (!this._iceGatheringStartTime)) { + this._iceGatheringStartTime = new Date(); + } + + if (iceGatheringState === 'complete') { + const secondsToGatherIce = (new Date() + - (this._iceGatheringStartTime || this._sessionStartTime)) / 1000; + + logger.info({ + logCode: 'sip_js_ice_gathering_time', + extraInfo: { + callerIdName: this.user.callerIdName, + secondsToGatherIce, + }, + }, `ICE gathering candidates took (s): ${secondsToGatherIce}`); + } + } + + onIceCandidate(sessionDescriptionHandler, event) { + if (this.isValidIceCandidate(event)) { + logger.info({ + logCode: 'sip_js_found_valid_candidate_from_trickle_ice', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'Found a valid candidate from trickle ICE, finishing gathering'); + + if (sessionDescriptionHandler.iceGatheringCompleteResolve) { + sessionDescriptionHandler.iceGatheringCompleteResolve(); + } + } + } + + initSessionDescriptionHandler(sessionDescriptionHandler) { + /* eslint-disable no-param-reassign */ + sessionDescriptionHandler.peerConnectionDelegate = { + onicecandidate: + this.onIceCandidate.bind(this, sessionDescriptionHandler), + onicegatheringstatechange: + this.onIceGatheringStateChange.bind(this), + }; + /* eslint-enable no-param-reassign */ + } + + inviteUserAgent(userAgent) { + return new Promise((resolve, reject) => { + if (this.userRequestedHangup === true) reject(); + const { + hostname, + } = this; + + const { + callExtension, + isListenOnly, + } = this.callOptions; + + this._sessionStartTime = new Date(); + + const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`); + + const matchConstraints = getAudioConstraints({ deviceId: this.inputDeviceId }); + const sessionDescriptionHandlerModifiers = []; + const iceModifiers = [ + filterValidIceCandidates.bind(this, this.validIceCandidates), + ]; + + if (!SIPJS_ALLOW_MDNS) iceModifiers.push(stripMDnsCandidates); + + // The current Vosk provider does not support stereo when transcribing + // microphone streams, so we need to make sure it is forcefully disabled + // via SDP munging. Having it disabled on server side FS _does not suffice_ + // because the stereo parameter is client-mandated (ie replicated in the + // answer) + if (SpeechService.stereoUnsupported()) { + logger.debug({ + logCode: 'sipjs_transcription_disable_stereo', + }, 'Transcription provider does not support stereo, forcing stereo=0'); + sessionDescriptionHandlerModifiers.push(forceDisableStereo); + } + + const inviterOptions = { + sessionDescriptionHandlerOptions: { + constraints: { + audio: isListenOnly + ? false + : matchConstraints, + video: false, + }, + iceGatheringTimeout: ICE_GATHERING_TIMEOUT, + }, + sessionDescriptionHandlerModifiers, + sessionDescriptionHandlerModifiersPostICEGathering: iceModifiers, + delegate: { + onSessionDescriptionHandler: + this.initSessionDescriptionHandler.bind(this), + }, + }; + + if (isListenOnly) { + inviterOptions.sessionDescriptionHandlerOptions.offerOptions = { + offerToReceiveAudio: true, + }; + } + + const inviter = new SIP.Inviter(userAgent, target, inviterOptions); + this.currentSession = inviter; + + this.setupEventHandlers(inviter).then(() => { + inviter.invite().then(() => { + resolve(); + }).catch(e => reject(e)); + }); + }); + } + + setupEventHandlers(currentSession) { + return new Promise((resolve, reject) => { + if (this.userRequestedHangup === true) reject(); + + let iceCompleted = false; + let fsReady = false; + let sessionTerminated = false; + + const setupRemoteMedia = () => { + const mediaElement = document.querySelector(MEDIA_TAG); + const { sdp } = this.currentSession.sessionDescriptionHandler + .peerConnection.remoteDescription; + + logger.info({ + logCode: 'sip_js_session_setup_remote_media', + extraInfo: { + callerIdName: this.user.callerIdName, + sdp, + }, + }, 'Audio call - setup remote media'); + + this.remoteStream = new MediaStream(); + this.currentSession.sessionDescriptionHandler + .peerConnection.getReceivers().forEach((receiver) => { + if (receiver.track) { + this.remoteStream.addTrack(receiver.track); + } + }); + + logger.info({ + logCode: 'sip_js_session_playing_remote_media', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'Audio call - playing remote media'); + + mediaElement.srcObject = this.remoteStream; + mediaElement.play(); + }; + + const checkIfCallReady = () => { + if (this.userRequestedHangup === true) { + this.exitAudio(); + resolve(); + } + + logger.info({ + logCode: 'sip_js_session_check_if_call_ready', + extraInfo: { + iceCompleted, + fsReady, + }, + }, 'Audio call - check if ICE is finished and FreeSWITCH is ready'); + + if (iceCompleted) { + this.webrtcConnected = true; + setupRemoteMedia(); + } + + if (fsReady) { + this.callback({ status: this.baseCallStates.started, bridge: this.bridgeName }); + + resolve(); + } + }; + + // Sometimes FreeSWITCH just won't respond with anything and hangs. This timeout is to + // avoid that issue + const callTimeout = setTimeout(() => { + this.callback({ + status: this.baseCallStates.failed, + error: 1006, + bridgeError: `Call timed out on start after ${CALL_CONNECT_TIMEOUT / 1000}s`, + bridge: this.bridgeName, + }); + + this.exitAudio(); + }, CALL_CONNECT_TIMEOUT); + + let iceNegotiationTimeout; + + const handleSessionAccepted = () => { + logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted'); + clearTimeout(callTimeout); + + // If ICE isn't connected yet then start timeout waiting for ICE to finish + if (!iceCompleted) { + iceNegotiationTimeout = setTimeout(() => { + this.callback({ + status: this.baseCallStates.failed, + error: 1010, + bridgeError: 'ICE negotiation timeout after ' + + `${ICE_NEGOTIATION_TIMEOUT / 1000}s`, + bridge: this.bridgeName, + }); + + this.exitAudio(); + + reject({ + type: this.baseErrorCodes.CONNECTION_ERROR, + }); + }, ICE_NEGOTIATION_TIMEOUT); + } + checkIfCallReady(); + }; + + const handleIceNegotiationFailed = (peer) => { + if (iceCompleted) { + logger.warn({ + logCode: 'sipjs_ice_failed_after', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'ICE connection failed after success'); + } else { + logger.warn({ + logCode: 'sipjs_ice_failed_before', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'ICE connection failed before success'); + } + clearTimeout(callTimeout); + clearTimeout(iceNegotiationTimeout); + this.callback({ + status: this.baseCallStates.failed, + error: 1007, + bridgeError: 'ICE negotiation failed. Current state ' + + `- ${peer.iceConnectionState}`, + bridge: this.bridgeName, + }); + }; + + const handleIceConnectionTerminated = (peer) => { + if (!this.userRequestedHangup) { + logger.warn({ + logCode: 'sipjs_ice_closed', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'ICE connection closed'); + } else return; + + this.callback({ + status: this.baseCallStates.failed, + error: 1012, + bridgeError: 'ICE connection closed. Current state -' + + `${peer.iceConnectionState}`, + bridge: this.bridgeName, + }); + }; + + const handleSessionProgress = (update) => { + logger.info({ + logCode: 'sip_js_session_progress', + extraInfo: { + callerIdName: this.user.callerIdName, + update, + }, + }, 'Audio call session progress update'); + + this.currentSession.sessionDescriptionHandler.peerConnectionDelegate + .onconnectionstatechange = (event) => { + const peer = event.target; + + logger.info({ + logCode: 'sip_js_connection_state_change', + extraInfo: { + connectionStateChange: peer.connectionState, + callerIdName: this.user.callerIdName, + }, + }, 'ICE connection state change - Current connection state - ' + + `${peer.connectionState}`); + + switch (peer.connectionState) { + case 'failed': + // Chrome triggers 'failed' for connectionState event, only + handleIceNegotiationFailed(peer); + break; + default: + break; + } + }; + + this.currentSession.sessionDescriptionHandler.peerConnectionDelegate + .oniceconnectionstatechange = (event) => { + const peer = event.target; + + switch (peer.iceConnectionState) { + case 'completed': + case 'connected': + if (iceCompleted) { + logger.info({ + logCode: 'sip_js_ice_connection_success_after_success', + extraInfo: { + currentState: peer.connectionState, + callerIdName: this.user.callerIdName, + }, + }, 'ICE connection success, but user is already connected, ' + + 'ignoring it...' + + `${peer.iceConnectionState}`); + + return; + } + + logger.info({ + logCode: 'sip_js_ice_connection_success', + extraInfo: { + currentState: peer.connectionState, + callerIdName: this.user.callerIdName, + }, + }, 'ICE connection success. Current ICE Connection state - ' + + `${peer.iceConnectionState}`); + + clearTimeout(callTimeout); + clearTimeout(iceNegotiationTimeout); + + iceCompleted = true; + + logSelectedCandidate(peer, this.protocolIsIpv6); + + checkIfCallReady(); + break; + case 'failed': + handleIceNegotiationFailed(peer); + break; + + case 'closed': + handleIceConnectionTerminated(peer); + break; + default: + break; + } + }; + }; + + const checkIfCallStopped = (message) => { + if ((!this.ignoreCallState && fsReady) || !sessionTerminated) { + return null; + } + + if (!message && !!this.userRequestedHangup) { + return this.callback({ + status: this.baseCallStates.ended, + bridge: this.bridgeName, + }); + } + + // if session hasn't even started, we let audio-modal to handle + // any possile errors + if (!this._currentSessionState) return false; + + + let mappedCause; + let cause; + if (!iceCompleted) { + mappedCause = '1004'; + cause = 'ICE error'; + } else { + cause = 'Audio Conference Error'; + mappedCause = '1005'; + } + + logger.warn({ + logCode: 'sip_js_call_terminated', + extraInfo: { cause, callerIdName: this.user.callerIdName }, + }, `Audio call terminated. cause=${cause}`); + + return this.callback({ + status: this.baseCallStates.failed, + error: mappedCause, + bridgeError: cause, + bridge: this.bridgeName, + }); + } + + const handleSessionTerminated = (message) => { + logger.info({ + logCode: 'sip_js_session_terminated', + extraInfo: { callerIdName: this.user.callerIdName }, + }, 'SIP.js session terminated'); + + clearTimeout(callTimeout); + clearTimeout(iceNegotiationTimeout); + + sessionTerminated = true; + checkIfCallStopped(); + }; + + currentSession.stateChange.addListener((state) => { + switch (state) { + case SIP.SessionState.Initial: + break; + case SIP.SessionState.Establishing: + handleSessionProgress(); + break; + case SIP.SessionState.Established: + handleSessionAccepted(); + break; + case SIP.SessionState.Terminating: + break; + case SIP.SessionState.Terminated: + handleSessionTerminated(); + break; + default: + logger.warn({ + logCode: 'sipjs_ice_session_unknown_state', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'SIP.js unknown session state'); + break; + } + this._currentSessionState = state; + }); + + Tracker.autorun((c) => { + const selector = { + meetingId: Auth.meetingID, + userId: Auth.userID, + clientSession: getCurrentAudioSessionNumber(), + }; + + const query = VoiceCallStates.find(selector); + const callback = (id, fields) => { + if (!fsReady && ((this.inEchoTest && fields.callState === CallStateOptions.IN_ECHO_TEST) + || (!this.inEchoTest && fields.callState === CallStateOptions.IN_CONFERENCE))) { + fsReady = true; + checkIfCallReady(); + } + + if (fields.callState === CallStateOptions.CALL_ENDED) { + fsReady = false; + c.stop(); + checkIfCallStopped(); + } + }; + + query.observeChanges({ + added: (id, fields) => callback(id, fields), + changed: (id, fields) => callback(id, fields), + }); + }); + + resolve(); + }); + } + + /** + * Update audio constraints for current local MediaStream (microphone) + * @param {Object} constraints MediaTrackConstraints object. See: + * https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints + * @return {Promise} A Promise for this process + */ + async updateAudioConstraints(constraints) { + try { + if (typeof constraints !== 'object') return; + + logger.info({ + logCode: 'sipjs_update_audio_constraint', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'SIP.js updating audio constraint'); + + const matchConstraints = filterSupportedConstraints(constraints); + + //Chromium bug - see: https://bugs.chromium.org/p/chromium/issues/detail?id=796964&q=applyConstraints&can=2 + const { isChrome } = browserInfo; + + if (isChrome) { + matchConstraints.deviceId = this.inputDeviceId; + + const stream = await doGUM({ audio: matchConstraints }); + + this.currentSession.sessionDescriptionHandler + .setLocalMediaStream(stream); + } else { + const { localMediaStream } = this.currentSession + .sessionDescriptionHandler; + + localMediaStream.getAudioTracks().forEach( + track => track.applyConstraints(matchConstraints), + ); + } + } catch (error) { + logger.error({ + logCode: 'sipjs_audio_constraint_error', + extraInfo: { + callerIdName: this.user.callerIdName, + }, + }, 'SIP.js failed to update audio constraint'); + } + } +} + +export default class SIPBridge extends BaseAudioBridge { + constructor(userData) { + super(userData); + + const { + userId, + username, + sessionToken, + } = userData; + + this.user = { + userId, + sessionToken, + name: username, + }; + + this.protocol = window.document.location.protocol; + if (MEDIA['sip_ws_host'] != null && MEDIA['sip_ws_host'] != '') { + this.hostname = MEDIA.sip_ws_host; + } else { + this.hostname = window.document.location.hostname; + } + + this.bridgeName = BRIDGE_NAME; + + // SDP conversion utilitary methods to be used inside SIP.js + window.isUnifiedPlan = isUnifiedPlan; + window.toUnifiedPlan = toUnifiedPlan; + window.toPlanB = toPlanB; + window.stripMDnsCandidates = stripMDnsCandidates; + + // No easy way to expose the client logger to sip.js code so we need to attach it globally + window.clientLogger = logger; + } + + get inputStream() { + return this.activeSession ? this.activeSession.inputStream : null; + } + + /** + * Wrapper for SIPSession's ignoreCallState flag + * @param {boolean} value + */ + set ignoreCallState(value) { + if (this.activeSession) { + this.activeSession.ignoreCallState = value; + } + } + + get ignoreCallState() { + return this.activeSession ? this.activeSession.ignoreCallState : false; + } + + joinAudio({ + isListenOnly, + extension, + validIceCandidates, + inputStream, + }, managerCallback) { + const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== ''; + + return new Promise((resolve, reject) => { + let { hostname } = this; + + this.activeSession = new SIPSession(this.user, this.userData, this.protocol, + hostname, this.baseCallStates, this.baseErrorCodes, false); + + const callback = (message) => { + if (message.status === this.baseCallStates.failed) { + let shouldTryReconnect = false; + + // Try and get the call to clean up and end on an error + this.activeSession.exitAudio().catch(() => { }); + + if (this.activeSession.webrtcConnected) { + // webrtc was able to connect so just try again + message.silenceNotifications = true; + callback({ status: this.baseCallStates.reconnecting, bridge: this.bridgeName, }); + shouldTryReconnect = true; + } else if (hasFallbackDomain === true && hostname !== IPV4_FALLBACK_DOMAIN) { + message.silenceNotifications = true; + logger.info({ logCode: 'sip_js_attempt_ipv4_fallback', extraInfo: { callerIdName: this.user.callerIdName } }, 'Attempting to fallback to IPv4 domain for audio'); + hostname = IPV4_FALLBACK_DOMAIN; + shouldTryReconnect = true; + } + + if (shouldTryReconnect) { + const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined; + this.activeSession = new SIPSession(this.user, this.userData, this.protocol, + hostname, this.baseCallStates, this.baseErrorCodes, true); + const { inputDeviceId, outputDeviceId } = this; + this.activeSession.joinAudio({ + isListenOnly, + extension: fallbackExtension, + inputDeviceId, + outputDeviceId, + validIceCandidates, + inputStream, + }, callback) + .then((value) => { + resolve(value); + }).catch((reason) => { + reject(reason); + }); + } + } + + return managerCallback(message); + }; + + const { inputDeviceId, outputDeviceId } = this; + this.activeSession.joinAudio({ + isListenOnly, + extension, + inputDeviceId, + outputDeviceId, + validIceCandidates, + inputStream, + }, callback) + .then((value) => { + resolve(value); + }).catch((reason) => { + reject(reason); + }); + }); + } + + transferCall(onTransferSuccess) { + this.activeSession.inEchoTest = false; + logger.debug({ + logCode: 'sip_js_rtp_payload_send_dtmf', + extraInfo: { + callerIdName: this.activeSession.user.callerIdName, + }, + }, 'Sending DTMF INFO to transfer user'); + + return this.trackTransferState(onTransferSuccess); + } + + sendDtmf(tones) { + this.activeSession.sendDtmf(tones); + } + + getPeerConnection() { + if (!this.activeSession) return null; + + const { currentSession } = this.activeSession; + if (currentSession && currentSession.sessionDescriptionHandler) { + return currentSession.sessionDescriptionHandler.peerConnection; + } + return null; + } + + exitAudio() { + if (this.activeSession == null) return Promise.resolve(); + + return this.activeSession.exitAudio(); + } + + setInputStream(stream) { + return this.activeSession.setInputStream(stream); + } + + async updateAudioConstraints(constraints) { + return this.activeSession.updateAudioConstraints(constraints); + } +} + +module.exports = SIPBridge; diff --git a/src/2.7.12/imports/api/auth-token-validation/index.js b/src/2.7.12/imports/api/auth-token-validation/index.js new file mode 100644 index 00000000..d3fd1e1e --- /dev/null +++ b/src/2.7.12/imports/api/auth-token-validation/index.js @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const AuthTokenValidation = new Mongo.Collection('auth-token-validation', collectionOptions); + +if (Meteor.isServer) { + AuthTokenValidation.createIndexAsync({ connectionId: 1 }); +} + +export const ValidationStates = Object.freeze({ + NOT_VALIDATED: 1, + VALIDATING: 2, + VALIDATED: 3, + INVALID: 4, +}); + +export default AuthTokenValidation; diff --git a/src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js new file mode 100644 index 00000000..5d03738f --- /dev/null +++ b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js @@ -0,0 +1,15 @@ +import AuthTokenValidation from '/imports/api/auth-token-validation'; +import Logger from '/imports/startup/server/logger'; +import ClientConnections from '/imports/startup/server/ClientConnections'; + +export default async function clearAuthTokenValidation(meetingId) { + try { + await AuthTokenValidation.removeAsync({ meetingId }); + if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') { + ClientConnections.removeMeeting(meetingId); + } + Logger.info(`Cleared AuthTokenValidation (${meetingId})`); + } catch (error) { + Logger.info(`Error when removing auth-token-validation for meeting=${meetingId}`); + } +} diff --git a/src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js new file mode 100644 index 00000000..fa233b64 --- /dev/null +++ b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js @@ -0,0 +1,14 @@ +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation from '/imports/api/auth-token-validation'; + +export default async function removeValidationState(meetingId, userId, connectionId) { + const selector = { + meetingId, userId, connectionId, + }; + + try { + await AuthTokenValidation.removeAsync(selector); + } catch (error) { + Logger.error(`Could not remove from collection AuthTokenValidation: ${error}`); + } +} diff --git a/src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js new file mode 100644 index 00000000..e8a7b3e9 --- /dev/null +++ b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js @@ -0,0 +1,36 @@ +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation from '/imports/api/auth-token-validation'; + +export default async function upsertValidationState( + meetingId, + userId, + validationStatus, + connectionId, + reason = null, +) { + const selector = { + meetingId, userId, connectionId, + }; + const modifier = { + $set: { + meetingId, + userId, + connectionId, + validationStatus, + updatedAt: new Date().getTime(), + reason, + }, + }; + + try { + await AuthTokenValidation + .removeAsync({ meetingId, userId, connectionId: { $ne: connectionId } }); + const { numberAffected } = AuthTokenValidation.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Upserted ${JSON.stringify(selector)} ${validationStatus} in AuthTokenValidation`); + } + } catch (err) { + Logger.error(`Could not upsert to collection AuthTokenValidation: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts-history/index.js b/src/2.7.12/imports/api/breakouts-history/index.js new file mode 100644 index 00000000..381eb4f1 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts-history/index.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const BreakoutsHistory = new Mongo.Collection('breakouts-history', collectionOptions); + +if (Meteor.isServer) { + BreakoutsHistory.createIndexAsync({ meetingId: 1 }); +} + +export default BreakoutsHistory; diff --git a/src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js b/src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js new file mode 100644 index 00000000..4cbdc273 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js @@ -0,0 +1,6 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleBreakoutRoomsList from './handlers/breakoutRoomsList'; +import messageToAllSent from '/imports/api/breakouts-history/server/handlers/messageToAllSent'; + +RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList); +RedisPubSub.on('SendMessageToAllBreakoutRoomsEvtMsg', messageToAllSent); diff --git a/src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js b/src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js new file mode 100644 index 00000000..47943e00 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js @@ -0,0 +1,35 @@ +import { check } from 'meteor/check'; +import BreakoutsHistory from '/imports/api/breakouts-history'; +import Logger from '/imports/startup/server/logger'; + +export default async function handleBreakoutRoomsList({ body }) { + const { + meetingId, + rooms, + } = body; + + check(meetingId, String); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + meetingId, + rooms, + }, + }; + + try { + const { insertedId } = await BreakoutsHistory.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added rooms to breakout-history Data: meeting=${meetingId}`); + } else { + Logger.info(`Upserted rooms to breakout-history Data: meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding rooms to the collection breakout-history: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js b/src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js new file mode 100644 index 00000000..0eab16fb --- /dev/null +++ b/src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js @@ -0,0 +1,42 @@ +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import BreakoutsHistory from '/imports/api/breakouts-history'; + +export default async function handleSendMessageToAllBreakoutRoomsEvtMsg({ body }, meetingId) { + const { + senderId, + msg, + totalOfRooms, + } = body; + + check(meetingId, String); + check(senderId, String); + check(msg, String); + check(totalOfRooms, Number); + + const selector = { + meetingId, + }; + + const modifier = { + $push: { + broadcastMsgs: { + senderId, + msg, + totalOfRooms, + }, + }, + }; + + try { + const { insertedId } = await BreakoutsHistory.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added broadCastMsg to breakout-history Data: meeting=${meetingId}`); + } else { + Logger.info(`Upserted broadCastMsg to breakout-history Data: meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding broadCastMsg to the collection breakout-history: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts-history/server/index.js b/src/2.7.12/imports/api/breakouts-history/server/index.js new file mode 100644 index 00000000..f993f38e --- /dev/null +++ b/src/2.7.12/imports/api/breakouts-history/server/index.js @@ -0,0 +1,2 @@ +import './eventHandlers'; +import './publishers'; diff --git a/src/2.7.12/imports/api/breakouts-history/server/publishers.js b/src/2.7.12/imports/api/breakouts-history/server/publishers.js new file mode 100644 index 00000000..4026acee --- /dev/null +++ b/src/2.7.12/imports/api/breakouts-history/server/publishers.js @@ -0,0 +1,58 @@ +import BreakoutsHistory from '/imports/api/breakouts-history'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; +import Logger from '/imports/startup/server/logger'; +import Meetings from '/imports/api/meetings'; +import Users from '/imports/api/users'; +import { publicationSafeGuard } from '/imports/api/common/server/helpers'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +async function breakoutsHistory() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Meetings-history was requested by unauth connection ${this.connection.id}`); + return Meetings.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + Logger.debug('Publishing Breakouts-History', { meetingId, userId }); + + const User = await Users.findOneAsync({ userId, meetingId }, { fields: { userId: 1, role: 1 } }); + if (!User || User.role !== ROLE_MODERATOR) { + return BreakoutsHistory.find({ meetingId: '' }); + } + + check(meetingId, String); + + const selector = { + meetingId, + }; + + // Monitor this publication and stop it when user is not a moderator anymore + const comparisonFunc = async () => { + const user = await Users + .findOneAsync({ userId, meetingId }, { fields: { role: 1, userId: 1 } }); + const condition = user.role === ROLE_MODERATOR; + + if (!condition) { + Logger.info(`conditions aren't filled anymore in publication ${this._name}: + user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`); + } + + return condition; + }; + publicationSafeGuard(comparisonFunc, this); + + return BreakoutsHistory.find(selector); +} + +function publish(...args) { + const boundUsers = breakoutsHistory.bind(this); + return boundUsers(...args); +} + +Meteor.publish('breakouts-history', publish); diff --git a/src/2.7.12/imports/api/breakouts/index.js b/src/2.7.12/imports/api/breakouts/index.js new file mode 100644 index 00000000..ce89c17b --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/index.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Breakouts = new Mongo.Collection('breakouts', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the breakouts: + // 1. breakoutId ( handleJoinUrl, roomStarted, clearBreakouts ) + // 2. parentMeetingId ( updateTimeRemaining ) + + Breakouts.createIndexAsync({ breakoutId: 1 }); + Breakouts.createIndexAsync({ parentMeetingId: 1 }); +} + +export default Breakouts; diff --git a/src/2.7.12/imports/api/breakouts/server/eventHandlers.js b/src/2.7.12/imports/api/breakouts/server/eventHandlers.js new file mode 100644 index 00000000..db0dc7d1 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/eventHandlers.js @@ -0,0 +1,14 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleBreakoutJoinURL from './handlers/breakoutJoinURL'; +import handleBreakoutRoomsList from './handlers/breakoutList'; +import handleUpdateTimeRemaining from './handlers/updateTimeRemaining'; +import handleBreakoutClosed from './handlers/breakoutClosed'; +import joinedUsersChanged from './handlers/joinedUsersChanged'; +import userBreakoutChanged from '/imports/api/breakouts/server/handlers/userBreakoutChanged'; + +RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList); +RedisPubSub.on('BreakoutRoomJoinURLEvtMsg', handleBreakoutJoinURL); +RedisPubSub.on('BreakoutRoomsTimeRemainingUpdateEvtMsg', handleUpdateTimeRemaining); +RedisPubSub.on('BreakoutRoomEndedEvtMsg', handleBreakoutClosed); +RedisPubSub.on('UpdateBreakoutUsersEvtMsg', joinedUsersChanged); +RedisPubSub.on('ChangeUserBreakoutEvtMsg', userBreakoutChanged); diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js new file mode 100644 index 00000000..5a85235d --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js @@ -0,0 +1,9 @@ +import { check } from 'meteor/check'; +import clearBreakouts from '../modifiers/clearBreakouts'; + +export default async function handleBreakoutClosed({ body }) { + const { breakoutId } = body; + check(breakoutId, String); + const result = await clearBreakouts(breakoutId); + return result; +} diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js new file mode 100644 index 00000000..091ad33b --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js @@ -0,0 +1,47 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Breakouts from '/imports/api/breakouts'; + +export default async function handleBreakoutJoinURL({ body }) { + const { + redirectToHtml5JoinURL, + userId, + breakoutId, + } = body; + + check(redirectToHtml5JoinURL, String); + + const selector = { + breakoutId, + }; + + const modifier = { + $set: { + [`url_${userId}`]: { + redirectToHtml5JoinURL, + insertedTime: new Date().getTime(), + }, + }, + }; + + try { + const ATTEMPT_EVERY_MS = 1000; + + let numberAffected = 0; + const updateBreakout = async () => { + numberAffected = await Breakouts.updateAsync(selector, modifier); + }; + + await new Promise((resolve) => { + const updateBreakoutInterval = setInterval(async () => { + await updateBreakout(); + if (numberAffected) { + resolve(clearInterval(updateBreakoutInterval)); + } + }, ATTEMPT_EVERY_MS); + }); + Logger.info(`Upserted breakout id=${breakoutId}`); + } catch (err) { + Logger.error(`Adding breakout to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js new file mode 100644 index 00000000..9ff5d5cd --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js @@ -0,0 +1,57 @@ +import Breakouts from '/imports/api/breakouts'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import flat from 'flat'; +import handleBreakoutRoomsListHist from '/imports/api/breakouts-history/server/handlers/breakoutRoomsList'; + +export default async function handleBreakoutRoomsList({ body }, meetingId) { + // 0 seconds default breakout time, forces use of real expiration time + const DEFAULT_TIME_REMAINING = 0; + + const { + meetingId: parentMeetingId, + rooms, + sendInviteToModerators, + } = body; + + // set firstly the last seq, then client will know when receive all + await rooms.sort((a, b) => ((a.sequence < b.sequence) ? 1 : -1)).forEach(async (breakout) => { + const { breakoutId, html5JoinUrls, ...breakoutWithoutUrls } = breakout; + + check(meetingId, String); + + const selector = { + breakoutId, + }; + + const urls = {}; + if (typeof html5JoinUrls === 'object' && Object.keys(html5JoinUrls).length > 0) { + Object.keys(html5JoinUrls).forEach((userId) => { + urls[`url_${userId}`] = { + redirectToHtml5JoinURL: html5JoinUrls[userId], + insertedTime: new Date().getTime(), + }; + }); + } + + const modifier = { + $set: { + breakoutId, + joinedUsers: [], + timeRemaining: DEFAULT_TIME_REMAINING, + parentMeetingId, + sendInviteToModerators, + ...flat(breakoutWithoutUrls), + ...urls, + }, + }; + const numberAffected = await Breakouts.upsertAsync(selector, modifier); + if (numberAffected) { + Logger.info('Updated timeRemaining and externalMeetingId ' + + `for breakout id=${breakoutId}`); + } else { + Logger.error(`updating breakout: ${numberAffected}`); + } + handleBreakoutRoomsListHist({ body }); + }); +} diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js b/src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js new file mode 100644 index 00000000..029077a8 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js @@ -0,0 +1,53 @@ +import Breakouts from '/imports/api/breakouts'; +import updateUserBreakoutRoom from '/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import { lowercaseTrim } from '/imports/utils/string-utils'; + +export default async function joinedUsersChanged({ body }) { + check(body, Object); + + const { + parentId, + breakoutId, + users, + } = body; + + check(parentId, String); + check(breakoutId, String); + check(users, Array); + + const selector = { + parentMeetingId: parentId, + breakoutId, + }; + + const usersMapped = users + .map((user) => ({ userId: user.id, name: user.name, sortName: lowercaseTrim(user.name) })); + const modifier = { + $set: { + joinedUsers: usersMapped, + }, + }; + + try { + const numberAffected = await Breakouts.updateAsync(selector, modifier); + + if (numberAffected) { + await updateUserBreakoutRoom(parentId, breakoutId, users); + + Logger.info(`Updated joined users in breakout id=${breakoutId}`); + } + } catch (err) { + Logger.error(`updating joined users in breakout: ${err}`); + } + // .then((res) => { + // if (res.numberAffected) { + // updateUserBreakoutRoom(parentId, breakoutId, users); + + // Logger.info(`Updated joined users in breakout id=${breakoutId}`); + // } + // }).catch((err) => { + // Logger.error(`updating joined users in breakout: ${err}`); + // }); +} diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js b/src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js new file mode 100644 index 00000000..b6abcc5c --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Breakouts from '/imports/api/breakouts'; + +export default async function handleUpdateTimeRemaining({ body }, meetingId) { + const { + timeRemaining, + } = body; + + check(meetingId, String); + check(timeRemaining, Number); + + const selector = { + parentMeetingId: meetingId, + }; + + const modifier = { + $set: { + timeRemaining, + }, + }; + + const options = { + multi: true, + }; + const numberAffected = Breakouts.updateAsync(selector, modifier, options); + + if (numberAffected) { + Logger.info(`Updated breakout time remaining for breakouts where parentMeetingId=${meetingId}`); + } else { + Logger.error(`Updating breakouts: ${numberAffected}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js b/src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js new file mode 100644 index 00000000..63329de9 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js @@ -0,0 +1,65 @@ +import Breakouts from '/imports/api/breakouts'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default async function userBreakoutChanged({ body }) { + check(body, Object); + + const { + meetingId, + userId, + fromBreakoutId, + toBreakoutId, + redirectToHtml5JoinURL, + } = body; + + check(meetingId, String); + check(userId, String); + check(fromBreakoutId, String); + check(toBreakoutId, String); + check(redirectToHtml5JoinURL, String); + + const oldBreakoutSelector = { + parentMeetingId: meetingId, + breakoutId: fromBreakoutId, + freeJoin: false, + }; + + const newBreakoutSelector = { + parentMeetingId: meetingId, + breakoutId: toBreakoutId, + }; + + const oldModifier = { + $unset: { + [`url_${userId}`]: '', + }, + }; + + const newModifier = { + $set: { + [`url_${userId}`]: { + redirectToHtml5JoinURL, + insertedTime: new Date().getTime(), + }, + }, + }; + + try { + let numberAffectedRows = 0; + + if (oldBreakoutSelector.breakoutId !== '') { + numberAffectedRows += await Breakouts.updateAsync(oldBreakoutSelector, oldModifier); + } + + if (newBreakoutSelector.breakoutId !== '') { + numberAffectedRows += await Breakouts.updateAsync(newBreakoutSelector, newModifier); + } + + if (numberAffectedRows > 0) { + Logger.info(`Updated user breakout for userId=${userId}`); + } + } catch (err) { + Logger.error(`Updating user breakout: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/index.js b/src/2.7.12/imports/api/breakouts/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/breakouts/server/methods.js b/src/2.7.12/imports/api/breakouts/server/methods.js new file mode 100644 index 00000000..27b26c4d --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; +import createBreakoutRoom from '/imports/api/breakouts/server/methods/createBreakout'; +import requestJoinURL from './methods/requestJoinURL'; +import endAllBreakouts from './methods/endAllBreakouts'; +import setBreakoutsTime from '/imports/api/breakouts/server/methods/setBreakoutsTime'; +import sendMessageToAllBreakouts from './methods/sendMessageToAllBreakouts'; +import moveUser from '/imports/api/breakouts/server/methods/moveUser'; + +Meteor.methods({ + requestJoinURL, + createBreakoutRoom, + endAllBreakouts, + setBreakoutsTime, + sendMessageToAllBreakouts, + moveUser, +}); diff --git a/src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js b/src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js new file mode 100644 index 00000000..2c3c80f9 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +export default function createBreakoutRoom(rooms, durationInMinutes, record = false, captureNotes = false, captureSlides = false, sendInviteToModerators = false) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit; + const MIN_BREAKOUT_ROOMS = 2; + const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS; + const EVENT_NAME = 'CreateBreakoutRoomsCmdMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + if (rooms.length > MAX_BREAKOUT_ROOMS) { + Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`); + return; + } + const payload = { + record, + captureNotes, + captureSlides, + durationInMinutes, + rooms, + meetingId, + sendInviteToModerators, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method createBreakoutRoom ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js b/src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js new file mode 100644 index 00000000..fb0ae194 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function endAllBreakouts() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'EndAllBreakoutRoomsMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, null); + } catch (err) { + Logger.error(`Exception while invoking method endAllBreakouts ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/methods/moveUser.js b/src/2.7.12/imports/api/breakouts/server/methods/moveUser.js new file mode 100644 index 00000000..e017cee7 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods/moveUser.js @@ -0,0 +1,32 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function moveUser(fromBreakoutId, toBreakoutId, userIdToMove) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserBreakoutReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const userId = userIdToMove || requesterUserId; + + return RedisPubSub.publishUserMessage( + CHANNEL, EVENT_NAME, meetingId, requesterUserId, + { + meetingId, + fromBreakoutId, + toBreakoutId, + userId, + }, + ); + } catch (err) { + Logger.error(`Exception while invoking method moveUser ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js b/src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js new file mode 100644 index 00000000..ce5e3b9d --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function requestJoinURL({ breakoutId, userId: userIdToInvite }) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'RequestBreakoutJoinURLReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const userId = userIdToInvite || requesterUserId; + + return RedisPubSub.publishUserMessage( + CHANNEL, EVENT_NAME, meetingId, requesterUserId, + { + meetingId, + breakoutId, + userId, + }, + ); + } catch (err) { + Logger.error(`Exception while invoking method requestJoinURL ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js b/src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js new file mode 100644 index 00000000..3aa9fe3a --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function sendMessageToAllBreakouts({ msg }) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SendMessageToAllBreakoutRoomsReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + RedisPubSub.publishUserMessage( + CHANNEL, EVENT_NAME, meetingId, requesterUserId, + { + meetingId, + msg, + }, + ); + } catch (err) { + Logger.error(`Exception while invoking method sendMessageToAllBreakouts ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js b/src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js new file mode 100644 index 00000000..00016928 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function setBreakoutsTime({ timeInMinutes }) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UpdateBreakoutRoomsTimeReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + RedisPubSub.publishUserMessage( + CHANNEL, EVENT_NAME, meetingId, requesterUserId, + { + meetingId, + timeInMinutes, + }, + ); + } catch (err) { + Logger.error(`Exception while invoking method setBreakoutsTime ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js b/src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js new file mode 100644 index 00000000..2ae0685d --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js @@ -0,0 +1,29 @@ +import Logger from '/imports/startup/server/logger'; +import Breakouts from '/imports/api/breakouts'; + +export default async function clearBreakouts(breakoutId) { + if (breakoutId) { + const selector = { + breakoutId, + }; + + try { + const numberAffected = await Breakouts.removeAsync(selector); + + if (numberAffected) { + Logger.info(`Cleared Breakouts (${breakoutId})`); + } + } catch (err) { + Logger.error(`Error on clearing Breakouts (${breakoutId})`); + } + } else { + try { + const numberAffected = await Breakouts.removeAsync({}); + if (numberAffected) { + Logger.info('Cleared Breakouts (all)'); + } + } catch (err) { + Logger.error('Error on clearing Breakouts (all)'); + } + } +} diff --git a/src/2.7.12/imports/api/breakouts/server/publishers.js b/src/2.7.12/imports/api/breakouts/server/publishers.js new file mode 100644 index 00000000..85605129 --- /dev/null +++ b/src/2.7.12/imports/api/breakouts/server/publishers.js @@ -0,0 +1,89 @@ +import { Meteor } from 'meteor/meteor'; +import Breakouts from '/imports/api/breakouts'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; +import { publicationSafeGuard } from '/imports/api/common/server/helpers'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +async function breakouts() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Breakouts was requested by unauth connection ${this.connection.id}`); + return Breakouts.find({ meetingId: '' }); + } + const { meetingId, userId } = tokenValidation; + + const User = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1 } }); + Logger.debug('Publishing Breakouts', { meetingId, userId }); + + const fields = { + fields: { + [`url_${userId}`]: 1, + breakoutId: 1, + externalId: 1, + freeJoin: 1, + isDefaultName: 1, + joinedUsers: 1, + name: 1, + parentMeetingId: 1, + sequence: 1, + shortName: 1, + timeRemaining: 1, + captureNotes: 1, + captureSlides: 1, + sendInviteToModerators: 1, + }, + }; + + if (!!User && User.role === ROLE_MODERATOR) { + const presenterSelector = { + $or: [ + { parentMeetingId: meetingId }, + { breakoutId: meetingId }, + ], + }; + // Monitor this publication and stop it when user is not a moderator anymore + const comparisonFunc = async () => { + const user = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1, userId: 1 } }); + const condition = user.role === ROLE_MODERATOR; + + if (!condition) { + Logger.info(`conditions aren't filled anymore in publication ${this._name}: + user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`); + } + + return condition; + }; + publicationSafeGuard(comparisonFunc, this); + return Breakouts.find(presenterSelector, fields); + } + + const selector = { + $or: [ + { + parentMeetingId: meetingId, + freeJoin: true, + }, + { + parentMeetingId: meetingId, + [`url_${userId}`]: { $exists: true }, + }, + { + breakoutId: meetingId, + }, + ], + }; + + return Breakouts.find(selector, fields); +} + +function publish(...args) { + const boundBreakouts = breakouts.bind(this); + return boundBreakouts(...args); +} + +Meteor.publish('breakouts', publish); diff --git a/src/2.7.12/imports/api/captions/index.js b/src/2.7.12/imports/api/captions/index.js new file mode 100644 index 00000000..9e1790b5 --- /dev/null +++ b/src/2.7.12/imports/api/captions/index.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Captions = new Mongo.Collection('captions', collectionOptions); + +if (Meteor.isServer) { + Captions.createIndexAsync({ meetingId: 1, locale: 1 }); +} + +export default Captions; diff --git a/src/2.7.12/imports/api/captions/server/eventHandlers.js b/src/2.7.12/imports/api/captions/server/eventHandlers.js new file mode 100644 index 00000000..02297a9d --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/eventHandlers.js @@ -0,0 +1,4 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import captionsOwnerUpdated from './handlers/captionsOwnerUpdated'; + +RedisPubSub.on('UpdateCaptionOwnerEvtMsg', captionsOwnerUpdated); diff --git a/src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js b/src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js new file mode 100644 index 00000000..b9c56f1a --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js @@ -0,0 +1,11 @@ +import updateCaptionsOwner from '/imports/api/captions/server/modifiers/updateCaptionsOwner'; + +export default async function captionsOwnerUpdated({ header, body }) { + const { meetingId } = header; + const { + locale, + ownerId, + } = body; + + await updateCaptionsOwner(meetingId, locale, ownerId); +} diff --git a/src/2.7.12/imports/api/captions/server/helpers.js b/src/2.7.12/imports/api/captions/server/helpers.js new file mode 100644 index 00000000..eef4c9ad --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/helpers.js @@ -0,0 +1,35 @@ +import axios from 'axios'; +import { Meteor } from 'meteor/meteor'; +import createCaptions from '/imports/api/captions/server/modifiers/createCaptions'; +import Logger from '/imports/startup/server/logger'; + +const CAPTIONS_CONFIG = Meteor.settings.public.captions; +const BASENAME = Meteor.settings.public.app.basename; +const HOST = Meteor.settings.private.app.host; +const LOCALES = Meteor.settings.private.app.localesUrl; +const LOCALES_URL = `http://${HOST}:${process.env.PORT}${BASENAME}${LOCALES}`; + +const init = (meetingId) => { + axios({ + method: 'get', + url: LOCALES_URL, + responseType: 'json', + }).then(async (response) => { + const { status } = response; + if (status !== 200) return; + + const locales = response.data; + await Promise.all(locales.map(async (locale) => { + const caption = await createCaptions(meetingId, locale.locale, locale.name); + return caption; + })); + }).catch((error) => Logger.error(`Could not create captions for ${meetingId}: ${error}`)); +}; + +const initCaptions = (meetingId) => { + if (CAPTIONS_CONFIG.enabled) init(meetingId); +}; + +export { + initCaptions, +}; diff --git a/src/2.7.12/imports/api/captions/server/index.js b/src/2.7.12/imports/api/captions/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/captions/server/methods.js b/src/2.7.12/imports/api/captions/server/methods.js new file mode 100644 index 00000000..c1cb1767 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/methods.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import updateCaptionsOwner from '/imports/api/captions/server/methods/updateCaptionsOwner'; +import startDictation from '/imports/api/captions/server/methods/startDictation'; +import stopDictation from '/imports/api/captions/server/methods/stopDictation'; +import pushSpeechTranscript from '/imports/api/captions/server/methods/pushSpeechTranscript'; + +Meteor.methods({ + updateCaptionsOwner, + startDictation, + stopDictation, + pushSpeechTranscript, +}); diff --git a/src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js b/src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js new file mode 100644 index 00000000..e8dab1a2 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js @@ -0,0 +1,36 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; +import setTranscript from '/imports/api/captions/server/modifiers/setTranscript'; +import updatePad from '/imports/api/pads/server/methods/updatePad'; + +export default async function pushSpeechTranscript(locale, transcript, type) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(locale, String); + check(transcript, String); + check(type, String); + + const captions = await Captions.findOneAsync({ + meetingId, + ownerId: requesterUserId, + locale, + dictating: true, + }); + + if (captions) { + if (type === 'final') { + const text = `\n${transcript}`; + updatePad(meetingId, requesterUserId, locale, text); + } + + await setTranscript(meetingId, locale, transcript); + } + } catch (err) { + Logger.error(`Exception while invoking method pushSpeechTranscript ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/methods/startDictation.js b/src/2.7.12/imports/api/captions/server/methods/startDictation.js new file mode 100644 index 00000000..5d744519 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/methods/startDictation.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; +import setDictation from '/imports/api/captions/server/modifiers/setDictation'; + +export default async function startDictation(locale) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(locale, String); + + const captions = await Captions.findOneAsync({ + meetingId, + ownerId: requesterUserId, + locale, + }); + + if (captions) await setDictation(meetingId, locale, true); + } catch (err) { + Logger.error(`Exception while invoking method startDictation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/methods/stopDictation.js b/src/2.7.12/imports/api/captions/server/methods/stopDictation.js new file mode 100644 index 00000000..e96cd3c4 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/methods/stopDictation.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; +import setDictation from '/imports/api/captions/server/modifiers/setDictation'; + +export default async function stopDictation(locale) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(locale, String); + + const captions = await Captions.findOne({ + meetingId, + ownerId: requesterUserId, + locale, + }); + + if (captions) await setDictation(meetingId, locale, false); + } catch (err) { + Logger.error(`Exception while invoking method stopDictation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js b/src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js new file mode 100644 index 00000000..cebda2b7 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js @@ -0,0 +1,30 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function updateCaptionsOwner(locale, name) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UpdateCaptionOwnerPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(locale, String); + check(name, String); + + const payload = { + ownerId: requesterUserId, + locale, + name, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method updateCaptionsOwner ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js b/src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js new file mode 100644 index 00000000..fc40b08a --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js @@ -0,0 +1,26 @@ +import Captions from '/imports/api/captions'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearCaptions(meetingId) { + if (meetingId) { + try { + const numberAffected = await Captions.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared Captions (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing captions (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await Captions.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared Captions (all)'); + } + } catch (err) { + Logger.error(`Error on clearing captions (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js b/src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js new file mode 100644 index 00000000..180c598c --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import Logger from '/imports/startup/server/logger'; + +export default async function createCaptions(meetingId, locale, name) { + try { + check(meetingId, String); + check(locale, String); + check(name, String); + + const selector = { + meetingId, + locale, + }; + + const modifier = { + meetingId, + locale, + name, + ownerId: '', + dictating: false, + transcript: '', + }; + + const { numberAffected } = await Captions.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.verbose(`Created captions=${locale} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Creating captions owner to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/modifiers/setDictation.js b/src/2.7.12/imports/api/captions/server/modifiers/setDictation.js new file mode 100644 index 00000000..5a7e7645 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/modifiers/setDictation.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import Logger from '/imports/startup/server/logger'; + +export default async function setDictation(meetingId, locale, dictating) { + try { + check(meetingId, String); + check(locale, String); + check(dictating, Boolean); + + const selector = { + meetingId, + locale, + }; + + const modifier = { + $set: { + dictating, + transcript: '', + }, + }; + + const { numberAffected } = Captions.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Set captions=${locale} dictating=${dictating} meeting=${meetingId}`); + } else { + Logger.info(`Upserted captions=${locale} dictating=${dictating} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Setting captions dictation to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js b/src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js new file mode 100644 index 00000000..8bcaf9b7 --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import Logger from '/imports/startup/server/logger'; + +export default async function setTranscript(meetingId, locale, transcript) { + try { + check(meetingId, String); + check(locale, String); + check(transcript, String); + + const selector = { + meetingId, + locale, + }; + + const modifier = { + $set: { + transcript, + }, + }; + + const numberAffected = await Captions.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.debug(`Set captions=${locale} transcript=${transcript} meeting=${meetingId}`); + } else { + Logger.debug(`Upserted captions=${locale} transcript=${transcript} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Setting captions transcript to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js b/src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js new file mode 100644 index 00000000..714d850e --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Captions from '/imports/api/captions'; +import Logger from '/imports/startup/server/logger'; + +export default async function updateCaptionsOwner(meetingId, locale, ownerId) { + try { + check(meetingId, String); + check(locale, String); + check(ownerId, String); + + const selector = { + meetingId, + locale, + }; + + const modifier = { + $set: { + ownerId, + dictating: false, // Refresh dictation mode + }, + }; + + const numberAffected = await Captions.upsert(selector, modifier); + + if (numberAffected) { + Logger.info(`Added captions=${locale} owner=${ownerId} meeting=${meetingId}`); + } else { + Logger.info(`Upserted captions=${locale} owner=${ownerId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding captions owner to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/captions/server/publishers.js b/src/2.7.12/imports/api/captions/server/publishers.js new file mode 100644 index 00000000..e47dbedd --- /dev/null +++ b/src/2.7.12/imports/api/captions/server/publishers.js @@ -0,0 +1,26 @@ +import Captions from '/imports/api/captions'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function captions() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Captions was requested by unauth connection ${this.connection.id}`); + return Captions.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + Logger.debug('Publishing Captions', { meetingId, requestedBy: userId }); + + return Captions.find({ meetingId }); +} + +function publish(...args) { + const boundCaptions = captions.bind(this); + return boundCaptions(...args); +} + +Meteor.publish('captions', publish); diff --git a/src/2.7.12/imports/api/common/server/helpers.js b/src/2.7.12/imports/api/common/server/helpers.js new file mode 100644 index 00000000..56a41423 --- /dev/null +++ b/src/2.7.12/imports/api/common/server/helpers.js @@ -0,0 +1,94 @@ +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; +import RegexWebUrl from '/imports/utils/regex-weburl'; + +const MSG_DIRECT_TYPE = 'DIRECT'; +const NODE_USER = 'nodeJSapp'; + +const HTML_SAFE_MAP = { + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export const parseMessage = (message) => { + let parsedMessage = message || ''; + parsedMessage = parsedMessage.trim(); + + // Replace
with \n\r + parsedMessage = parsedMessage.replace(//gi, '\n\r'); + + // Sanitize. See: http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/ + parsedMessage = parsedMessage.replace(/[<>'"]/g, (c) => HTML_SAFE_MAP[c]); + + // Replace flash links to flash valid ones + parsedMessage = parsedMessage.replace(RegexWebUrl, "$&"); + + return parsedMessage; +}; + + +export const spokeTimeoutHandles = {}; +export const clearSpokeTimeout = (meetingId, userId) => { + if (spokeTimeoutHandles[`${meetingId}-${userId}`]) { + Meteor.clearTimeout(spokeTimeoutHandles[`${meetingId}-${userId}`]); + delete spokeTimeoutHandles[`${meetingId}-${userId}`]; + } +}; + +export const indexOf = [].indexOf || function (item) { + for (let i = 0, l = this.length; i < l; i += 1) { + if (i in this && this[i] === item) { + return i; + } + } + + return -1; +}; + +export const processForHTML5ServerOnly = (fn) => async (message, ...args) => { + const { envelope } = message; + const { routing } = envelope; + const { msgType, meetingId, userId } = routing; + + const selector = { + userId, + meetingId, + }; + + const user = await Users.findOneAsync(selector); + + const shouldSkip = user && msgType === MSG_DIRECT_TYPE && userId !== NODE_USER && user.clientType !== 'HTML5'; + if (shouldSkip) return () => { }; + return fn(message, ...args); +}; + +export const extractCredentials = (credentials) => { + if (!credentials) return {}; + const credentialsArray = credentials.split('--'); + const meetingId = credentialsArray[0]; + const requesterUserId = credentialsArray[1]; + return { meetingId, requesterUserId }; +}; + +// Creates a background job to periodically check the result of the provided function. +// The provided function is publication-specific and must check the "survival condition" of the publication. +export const publicationSafeGuard = function (fn, self) { + let stopped = false; + const periodicCheck = async function () { + if (stopped) return; + const result = await fn(); + if (!result) { + self.added(self._name, 'publication-stop-marker', { id: 'publication-stop-marker', stopped: true }); + self.stop(); + } else Meteor.setTimeout(periodicCheck, 1000); + }; + + self.onStop(() => { + stopped = true; + Logger.info(`Publication ${self._name} has stopped in server side`); + }); + + periodicCheck(); +}; diff --git a/src/2.7.12/imports/api/connection-status/index.js b/src/2.7.12/imports/api/connection-status/index.js new file mode 100644 index 00000000..4fd97fd3 --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/index.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const ConnectionStatus = new Mongo.Collection('connection-status', collectionOptions); + +if (Meteor.isServer) { + ConnectionStatus.createIndexAsync({ meetingId: 1, userId: 1 }); +} + +export default ConnectionStatus; diff --git a/src/2.7.12/imports/api/connection-status/server/index.js b/src/2.7.12/imports/api/connection-status/server/index.js new file mode 100644 index 00000000..cb2e6664 --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/index.js @@ -0,0 +1,2 @@ +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/connection-status/server/methods.js b/src/2.7.12/imports/api/connection-status/server/methods.js new file mode 100644 index 00000000..c5ae82db --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/methods.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; +import addConnectionStatus from './methods/addConnectionStatus'; +import voidConnection from './methods/voidConnection'; + +Meteor.methods({ + addConnectionStatus, + voidConnection, +}); diff --git a/src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js b/src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js new file mode 100644 index 00000000..59fb268c --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js @@ -0,0 +1,56 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import updateConnectionStatus from '/imports/api/connection-status/server/modifiers/updateConnectionStatus'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +const STATS = Meteor.settings.public.stats; + +const logConnectionStatus = (meetingId, userId, status, type, value) => { + switch (status) { + case 'normal': + Logger.info(`Connection status updated: meetingId=${meetingId} userId=${userId} status=${status} type=${type}`); + break; + case 'warning': + case 'danger': + case 'critical': + switch (type) { + case 'audio': { + const { + jitter, + loss, + } = value; + Logger.info(`Connection status updated: meetingId=${meetingId} userId=${userId} status=${status} type=${type} jitter=${jitter} loss=${loss}`); + break; + } + case 'socket': { + const { rtt } = value; + Logger.info(`Connection status updated: meetingId=${meetingId} userId=${userId} status=${status} type=${type} rtt=${rtt}`); + break; + } + default: + } + break; + default: + } +}; + +export default function addConnectionStatus(status, type, value) { + try { + check(status, String); + check(type, String); + check(value, Object); + + if (!this.userId) return; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + if (STATS.log) logConnectionStatus(meetingId, requesterUserId, status, type, value); + + updateConnectionStatus(meetingId, requesterUserId, status); + } catch (err) { + Logger.error(`Exception while invoking method addConnectionStatus ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js b/src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js new file mode 100644 index 00000000..7f369c5a --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js @@ -0,0 +1,9 @@ +import { PrometheusAgent, METRIC_NAMES } from '/imports/startup/server/prom-metrics/index.js' + +// Round-trip time helper +export default function voidConnection(previousRtt) { + if (previousRtt) { + PrometheusAgent.observe(METRIC_NAMES.METEOR_RTT, previousRtt/1000); + } + return 0; +} diff --git a/src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js b/src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js new file mode 100644 index 00000000..0b0974ce --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js @@ -0,0 +1,26 @@ +import ConnectionStatus from '/imports/api/connection-status'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearConnectionStatus(meetingId) { + const selector = {}; + + if (meetingId) { + selector.meetingId = meetingId; + } + + try { + const numberAffected = await ConnectionStatus.removeAsync(selector); + + if (numberAffected) { + if (meetingId) { + Logger.info(`Removed ConnectionStatus (${meetingId})`); + } else { + Logger.info('Removed ConnectionStatus (all)'); + } + } else { + Logger.warn('Removing ConnectionStatus nonaffected'); + } + } catch (err) { + Logger.error(`Removing ConnectionStatus: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js b/src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js new file mode 100644 index 00000000..ada8f1aa --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js @@ -0,0 +1,64 @@ +import ConnectionStatus from '/imports/api/connection-status'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import changeHasConnectionStatus from '/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus'; + +const STATS = Meteor.settings.public.stats; +const STATS_INTERVAL = STATS.interval; +const STATS_CRITICAL_RTT = STATS.rtt[STATS.rtt.length - 1]; + +export default async function updateConnectionStatus(meetingId, userId, status) { + check(meetingId, String); + check(userId, String); + + const now = new Date().getTime(); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + meetingId, + userId, + connectionAliveAt: now, + clientNotResponding: false, + }; + + // Store last not-normal status + if (status !== 'normal') { + modifier.status = status; + modifier.statusUpdatedAt = now; + } + + try { + const { numberAffected } = await ConnectionStatus.upsertAsync(selector, { $set: modifier }); + if (numberAffected && status !== 'normal') { + await changeHasConnectionStatus(true, userId, meetingId); + Logger.verbose(`Updated connection status meetingId=${meetingId} userId=${userId} status=${status}`); + } + + Meteor.setTimeout(async () => { + const connectionLossTimeThreshold = new Date() + .getTime() - (STATS_INTERVAL + STATS_CRITICAL_RTT); + + const selectorNotResponding = { + meetingId, + userId, + connectionAliveAt: { $lte: connectionLossTimeThreshold }, + clientNotResponding: false, + }; + + const numberAffectedNotResponding = await ConnectionStatus + .updateAsync(selectorNotResponding, { + $set: { clientNotResponding: true }, + }); + + if (numberAffectedNotResponding) { + Logger.info(`Updated clientNotResponding=true meetingId=${meetingId} userId=${userId}`); + } + }, STATS_INTERVAL + STATS_CRITICAL_RTT); + } catch (err) { + Logger.error(`Updating connection status meetingId=${meetingId} userId=${userId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/connection-status/server/publishers.js b/src/2.7.12/imports/api/connection-status/server/publishers.js new file mode 100644 index 00000000..c482de3f --- /dev/null +++ b/src/2.7.12/imports/api/connection-status/server/publishers.js @@ -0,0 +1,62 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import ConnectionStatus from '/imports/api/connection-status'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; +import Users from '/imports/api/users'; +import { publicationSafeGuard } from '/imports/api/common/server/helpers'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +async function connectionStatus() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing ConnectionStatus was requested by unauth connection ${this.connection.id}`); + return ConnectionStatus.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + check(meetingId, String); + check(userId, String); + + const fields = { + meetingId: 1, + userId: 1, + status: 1, + statusUpdatedAt: 1, + clientNotResponding: 1, + }; + + const User = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1 } }); + Logger.info(`Publishing connection status for ${meetingId} ${userId}`); + + if (!!User && User.role === ROLE_MODERATOR) { + // Monitor this publication and stop it when user is not a moderator anymore + const comparisonFunc = async () => { + const user = await Users + .findOneAsync({ userId, meetingId }, { fields: { role: 1, userId: 1 } }); + const condition = user.role === ROLE_MODERATOR; + + if (!condition) { + Logger.info(`conditions aren't filled anymore in publication ${this._name}: + user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`); + } + + return condition; + }; + publicationSafeGuard(comparisonFunc, this); + return ConnectionStatus.find({ meetingId }, { fields }); + } + + return ConnectionStatus.find({ meetingId, userId }, { fields }); +} + +function publish(...args) { + const boundNote = connectionStatus.bind(this); + return boundNote(...args); +} + +Meteor.publish('connection-status', publish); diff --git a/src/2.7.12/imports/api/cursor/index.js b/src/2.7.12/imports/api/cursor/index.js new file mode 100644 index 00000000..e69de29b diff --git a/src/2.7.12/imports/api/cursor/server/eventHandlers.js b/src/2.7.12/imports/api/cursor/server/eventHandlers.js new file mode 100644 index 00000000..4f4dca34 --- /dev/null +++ b/src/2.7.12/imports/api/cursor/server/eventHandlers.js @@ -0,0 +1,4 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleCursorUpdate from './handlers/cursorUpdate'; + +RedisPubSub.on('SendCursorPositionEvtMsg', handleCursorUpdate); diff --git a/src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js b/src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js new file mode 100644 index 00000000..e9377f98 --- /dev/null +++ b/src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js @@ -0,0 +1,45 @@ +import { check } from 'meteor/check'; +import CursorStreamer from '/imports/api/cursor/server/streamer'; +import Logger from '/imports/startup/server/logger'; +import { throttle } from '/imports/utils/throttle'; + +const CURSOR_PROCCESS_INTERVAL = 30; + +const cursorQueue = {}; + +const proccess = throttle(() => { + try { + Object.keys(cursorQueue).forEach((meetingId) => { + try { + const cursors = []; + for (let userId in cursorQueue[meetingId]){ + cursorQueue[meetingId][userId].userId = userId; + cursors.push(cursorQueue[meetingId][userId]); + } + delete cursorQueue[meetingId]; + CursorStreamer(meetingId).emit('message', { meetingId, cursors }); + } catch (error) { + Logger.error(`Error while trying to send cursor streamer data for meeting ${meetingId}. ${error}`); + } + }); + } catch (error) { + Logger.error(`Error while processing cursor queue. ${error}`); + } +}, CURSOR_PROCCESS_INTERVAL); + +export default function handleCursorUpdate({ header, body }, meetingId) { + const { userId } = header; + check(body, Object); + + check(meetingId, String); + check(userId, String); + + if (!cursorQueue[meetingId]) { + cursorQueue[meetingId] = {}; + } + + // overwrite since we dont care about the other positions + cursorQueue[meetingId][userId] = body; + + proccess(); +} diff --git a/src/2.7.12/imports/api/cursor/server/index.js b/src/2.7.12/imports/api/cursor/server/index.js new file mode 100644 index 00000000..9a7510e2 --- /dev/null +++ b/src/2.7.12/imports/api/cursor/server/index.js @@ -0,0 +1,2 @@ +import './eventHandlers'; +import './methods'; diff --git a/src/2.7.12/imports/api/cursor/server/methods.js b/src/2.7.12/imports/api/cursor/server/methods.js new file mode 100644 index 00000000..f64fb967 --- /dev/null +++ b/src/2.7.12/imports/api/cursor/server/methods.js @@ -0,0 +1,6 @@ +import { Meteor } from 'meteor/meteor'; +import publishCursorUpdate from './methods/publishCursorUpdate'; + +Meteor.methods({ + publishCursorUpdate, +}); diff --git a/src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js b/src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js new file mode 100644 index 00000000..0ee0dbc4 --- /dev/null +++ b/src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js @@ -0,0 +1,10 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; + +export default function publishCursorUpdate(meetingId, requesterUserId, payload) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SendCursorPositionPubMsg'; + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); +} diff --git a/src/2.7.12/imports/api/cursor/server/streamer.js b/src/2.7.12/imports/api/cursor/server/streamer.js new file mode 100644 index 00000000..a61126e1 --- /dev/null +++ b/src/2.7.12/imports/api/cursor/server/streamer.js @@ -0,0 +1,37 @@ +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import publishCursorUpdate from './methods/publishCursorUpdate'; + +const { streamerLog } = Meteor.settings.private.serverLog; + +export function removeCursorStreamer(meetingId) { + Logger.info(`Removing Cursor streamer object for meeting ${meetingId}`); + delete Meteor.StreamerCentral.instances[`cursor-${meetingId}`]; +} + +export function addCursorStreamer(meetingId) { + const streamer = new Meteor.Streamer(`cursor-${meetingId}`, { retransmit: false }); + if (streamerLog) { + Logger.debug('Cursor streamer created', { meetingId }); + } + + streamer.allowRead(function allowRead() { + if (streamerLog) { + Logger.debug('Cursor streamer called allowRead', { userId: this.userId, meetingId }); + } + return this.userId && this.userId.includes(meetingId); + }); + + streamer.allowWrite(function allowWrite() { + return this.userId && this.userId.includes(meetingId); + }); + + streamer.on('publish', function (message) { + const { requesterUserId } = extractCredentials(this.userId); + publishCursorUpdate(meetingId, requesterUserId, message); + }); +} + +export default function get(meetingId) { + return Meteor.StreamerCentral.instances[`cursor-${meetingId}`]; +} diff --git a/src/2.7.12/imports/api/external-videos/index.js b/src/2.7.12/imports/api/external-videos/index.js new file mode 100644 index 00000000..64ffab0d --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/index.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; + +let streamer = null; +const getStreamer = (meetingID) => { + if (!streamer) { + streamer = new Meteor.Streamer(`external-videos-${meetingID}`); + } + return streamer; +}; + +export { getStreamer }; diff --git a/src/2.7.12/imports/api/external-videos/server/eventHandlers.js b/src/2.7.12/imports/api/external-videos/server/eventHandlers.js new file mode 100644 index 00000000..2cd40778 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/eventHandlers.js @@ -0,0 +1,8 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleStartExternalVideo from './handlers/startExternalVideo'; +import handleStopExternalVideo from './handlers/stopExternalVideo'; +import handleUpdateExternalVideo from './handlers/updateExternalVideo'; + +RedisPubSub.on('StartExternalVideoEvtMsg', handleStartExternalVideo); +RedisPubSub.on('StopExternalVideoEvtMsg', handleStopExternalVideo); +RedisPubSub.on('UpdateExternalVideoEvtMsg', handleUpdateExternalVideo); diff --git a/src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js new file mode 100644 index 00000000..e8cd8f4e --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import startExternalVideo from '../modifiers/startExternalVideo'; + +export default async function handleStartExternalVideo({ header, body }, meetingId) { + check(header, Object); + check(body, Object); + check(meetingId, String); + + const { userId } = header; + const { externalVideoUrl } = body; + + await startExternalVideo(meetingId, userId, externalVideoUrl); +} diff --git a/src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js new file mode 100644 index 00000000..ac4a4017 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js @@ -0,0 +1,11 @@ +import { check } from 'meteor/check'; +import stopExternalVideo from '../modifiers/stopExternalVideo'; + +export default async function handleStopExternalVideo({ header }, meetingId) { + check(header, Object); + check(meetingId, String); + + const { userId } = header; + + await stopExternalVideo(userId, meetingId); +} diff --git a/src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js new file mode 100644 index 00000000..9e061026 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js @@ -0,0 +1,19 @@ +import { check } from 'meteor/check'; +import updateExternalVideo from '../modifiers/updateExternalVideo'; + +export default async function handleUpdateExternalVideo({ header, body }, meetingId) { + check(header, Object); + check(body, Object); + check(meetingId, String); + + const { userId } = header; + + const { + status, + rate, + time, + state, + } = body; + + await updateExternalVideo(meetingId, userId, status, rate, time, state); +} diff --git a/src/2.7.12/imports/api/external-videos/server/index.js b/src/2.7.12/imports/api/external-videos/server/index.js new file mode 100644 index 00000000..b0d5d329 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/index.js @@ -0,0 +1,2 @@ +import './methods'; +import './eventHandlers'; diff --git a/src/2.7.12/imports/api/external-videos/server/methods.js b/src/2.7.12/imports/api/external-videos/server/methods.js new file mode 100644 index 00000000..10c056a2 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/methods.js @@ -0,0 +1,10 @@ +import { Meteor } from 'meteor/meteor'; +import startWatchingExternalVideo from './methods/startWatchingExternalVideo'; +import stopWatchingExternalVideo from './methods/stopWatchingExternalVideo'; +import emitExternalVideoEvent from './methods/emitExternalVideoEvent'; + +Meteor.methods({ + startWatchingExternalVideo, + stopWatchingExternalVideo, + emitExternalVideoEvent, +}); diff --git a/src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js b/src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js new file mode 100644 index 00000000..3169c967 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js @@ -0,0 +1,40 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function emitExternalVideoEvent(options) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UpdateExternalVideoPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const { status, playerStatus } = options; + + check(status, String); + check(playerStatus, { + rate: Match.Maybe(Number), + time: Match.Maybe(Number), + state: Match.Maybe(Number), + }); + + const state = playerStatus.state || 0; + + const payload = { + status, + rate: playerStatus.rate || 0, + time: playerStatus.time || 0, + state, + }; + + Logger.debug(`User id=${requesterUserId} sending ${EVENT_NAME} event:${state} for meeting ${meetingId}`); + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method emitExternalVideoEvent ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js new file mode 100644 index 00000000..cd579dab --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js @@ -0,0 +1,26 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function startWatchingExternalVideo(externalVideoUrl) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StartExternalVideoPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(externalVideoUrl, String); + + const payload = { externalVideoUrl }; + + Logger.info(`User ${requesterUserId} sharing an external video ${externalVideoUrl} for meeting ${meetingId}`); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (error) { + Logger.error(`Error on sharing an external video for meeting ${meetingId}: ${error}`); + } +} diff --git a/src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js new file mode 100644 index 00000000..59bc829d --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import RedisPubSub from '/imports/startup/server/redis'; + +export default function stopWatchingExternalVideo() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StopExternalVideoPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = {}; + + Logger.info(`User ${requesterUserId} stoping an external video for meeting ${meetingId}`); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (error) { + Logger.error(`Error on stoping an external video for meeting ${meetingId}: ${error}`); + } +} diff --git a/src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js new file mode 100644 index 00000000..335eb7ce --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js @@ -0,0 +1,19 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { ExternalVideoMeetings } from '/imports/api/meetings'; + +export default async function startExternalVideo(meetingId, userId, externalVideoUrl) { + try { + check(meetingId, String); + check(userId, String); + check(externalVideoUrl, String); + + const selector = { meetingId }; + const modifier = { $set: { externalVideoUrl } }; + + Logger.info(`User id=${userId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`); + await ExternalVideoMeetings.updateAsync(selector, modifier); + } catch (err) { + Logger.error(`Error on setting shared external video start in Meetings collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js new file mode 100644 index 00000000..cab986bd --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js @@ -0,0 +1,18 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { ExternalVideoMeetings } from '/imports/api/meetings'; + +export default async function stopExternalVideo(userId, meetingId) { + try { + check(meetingId, String); + check(userId, String); + + const selector = { meetingId }; + const modifier = { $set: { externalVideoUrl: null } }; + + Logger.info(`External video stop sharing was initiated by:[${userId}] for meeting ${meetingId}`); + await ExternalVideoMeetings.updateAsync(selector, modifier); + } catch (err) { + Logger.error(`Error on setting shared external video stop in Meetings collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js new file mode 100644 index 00000000..25492955 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js @@ -0,0 +1,27 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import ExternalVideoStreamer from '/imports/api/external-videos/server/streamer'; + +export default async function updateExternalVideo(meetingId, userId, status, rate, time, state) { + try { + check(meetingId, String); + check(userId, String); + check(status, String); + check(rate, Number); + check(time, Number); + check(state, Number); + + const modifier = { + meetingId, + userId, + rate, + time, + state, + }; + + Logger.debug(`UpdateExternalVideoEvtMsg received for user ${userId} and meeting ${meetingId} event:${status}`); + ExternalVideoStreamer(meetingId).emit(status, modifier); + } catch (err) { + Logger.error(`Error on setting shared external video update in Meetings collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/external-videos/server/streamer.js b/src/2.7.12/imports/api/external-videos/server/streamer.js new file mode 100644 index 00000000..de5f2828 --- /dev/null +++ b/src/2.7.12/imports/api/external-videos/server/streamer.js @@ -0,0 +1,49 @@ +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; + +const allowRecentMessages = (eventName, message) => { + + const { + userId, + meetingId, + time, + rate, + state, + } = message; + + Logger.debug(`ExternalVideo Streamer auth allowed userId: ${userId}, meetingId: ${meetingId}, event: ${eventName}, time: ${time} rate: ${rate}, state: ${state}`); + return true; +}; + +export function removeExternalVideoStreamer(meetingId) { + const streamName = `external-videos-${meetingId}`; + + if (Meteor.StreamerCentral.instances[streamName]) { + Logger.info(`Destroying External Video streamer object for ${streamName}`); + delete Meteor.StreamerCentral.instances[streamName]; + } +} + +export function addExternalVideoStreamer(meetingId) { + + const streamName = `external-videos-${meetingId}`; + if (!Meteor.StreamerCentral.instances[streamName]) { + + const streamer = new Meteor.Streamer(streamName); + streamer.allowRead(function allowRead() { + if (!this.userId) return false; + + return this.userId && this.userId.includes(meetingId); + }); + streamer.allowWrite('none'); + streamer.allowEmit(allowRecentMessages); + Logger.info(`Created External Video streamer for ${streamName}`); + } else { + Logger.debug(`External Video streamer is already created for ${streamName}`); + } +} + +export default function get(meetingId) { + const streamName = `external-videos-${meetingId}`; + return Meteor.StreamerCentral.instances[streamName]; +} diff --git a/src/2.7.12/imports/api/group-chat-msg/index.js b/src/2.7.12/imports/api/group-chat-msg/index.js new file mode 100644 index 00000000..aaca255e --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/index.js @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const GroupChatMsg = new Mongo.Collection('group-chat-msg'); +const UsersTyping = new Mongo.Collection('users-typing', collectionOptions); + +if (Meteor.isServer) { + GroupChatMsg.createIndexAsync({ meetingId: 1, chatId: 1 }); + UsersTyping.createIndexAsync({ meetingId: 1, isTypingTo: 1 }); +} + +// As we store chat in context, skip adding to mini mongo +if (Meteor.isClient) { + GroupChatMsg.onAdded = () => false; +} + +export { GroupChatMsg, UsersTyping }; diff --git a/src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js b/src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js new file mode 100644 index 00000000..912afcac --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js @@ -0,0 +1,12 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleGroupChatMsgBroadcast from './handlers/groupChatMsgBroadcast'; +import handleClearPublicGroupChat from './handlers/clearPublicGroupChat'; +import handleUserTyping from './handlers/userTyping'; +import handleSyncGroupChatMsg from './handlers/syncGroupsChat'; +import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers'; + +RedisPubSub.on('GetGroupChatMsgsRespMsg', processForHTML5ServerOnly(handleSyncGroupChatMsg)); +RedisPubSub.on('GroupChatMessageBroadcastEvtMsg', handleGroupChatMsgBroadcast); +RedisPubSub.on('ClearPublicChatHistoryEvtMsg', handleClearPublicGroupChat); +RedisPubSub.on('SyncGetGroupChatMsgsRespMsg', handleSyncGroupChatMsg); +RedisPubSub.on('UserTypingEvtMsg', handleUserTyping); diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js new file mode 100644 index 00000000..7fe9749d --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import clearGroupChatMsg from '../modifiers/clearGroupChatMsg'; + +export default async function clearPublicChatHistory({ header, body }) { + const { meetingId } = header; + const { chatId } = body; + + check(meetingId, String); + check(chatId, String); + + const result = clearGroupChatMsg(meetingId, chatId); + return result; +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js new file mode 100644 index 00000000..6c0a0dd6 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import { throttle } from '/imports/utils/throttle'; +import addGroupChatMsg from '../modifiers/addGroupChatMsg'; +import addBulkGroupChatMsgs from '../modifiers/addBulkGroupChatMsgs'; + +const { bufferChatInsertsMs } = Meteor.settings.public.chat; + +const msgBuffer = []; + +const bulkFn = throttle(addBulkGroupChatMsgs, bufferChatInsertsMs); + +export default async function handleGroupChatMsgBroadcast({ body }, meetingId) { + const { chatId, msg } = body; + + check(meetingId, String); + check(chatId, String); + check(msg, Object); + + if (bufferChatInsertsMs) { + msgBuffer.push({ meetingId, chatId, msg }); + bulkFn(msgBuffer); + } else { + await addGroupChatMsg(meetingId, chatId, msg); + } +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js new file mode 100644 index 00000000..3db44bde --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js @@ -0,0 +1,12 @@ +import { Match, check } from 'meteor/check'; +import syncMeetingChatMsgs from '../modifiers/syncMeetingChatMsgs'; + +export default function handleSyncGroupChat({ body }, meetingId) { + const { chatId, msgs } = body; + + check(meetingId, String); + check(chatId, String); + check(msgs, Match.Maybe(Array)); + + syncMeetingChatMsgs(meetingId, chatId, msgs); +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js new file mode 100644 index 00000000..893197e4 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js @@ -0,0 +1,12 @@ +import { check } from 'meteor/check'; +import startTyping from '../modifiers/startTyping'; + +export default async function handleUserTyping({ body }, meetingId) { + const { chatId, userId } = body; + + check(meetingId, String); + check(userId, String); + check(chatId, String); + + await startTyping(meetingId, userId, chatId); +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/index.js b/src/2.7.12/imports/api/group-chat-msg/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods.js b/src/2.7.12/imports/api/group-chat-msg/server/methods.js new file mode 100644 index 00000000..5103e484 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; +import sendGroupChatMsg from './methods/sendGroupChatMsg'; +import clearPublicChatHistory from './methods/clearPublicChatHistory'; +import startUserTyping from './methods/startUserTyping'; +import stopUserTyping from './methods/stopUserTyping'; +import chatMessageBeforeJoinCounter from './methods/chatMessageBeforeJoinCounter'; +import fetchMessagePerPage from './methods/fetchMessagePerPage'; + +Meteor.methods({ + fetchMessagePerPage, + chatMessageBeforeJoinCounter, + sendGroupChatMsg, + clearPublicChatHistory, + startUserTyping, + stopUserTyping, +}); diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js new file mode 100644 index 00000000..435a0ec1 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js @@ -0,0 +1,45 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import GroupChat from '/imports/api/group-chat'; +import { GroupChatMsg } from '/imports/api/group-chat-msg'; +import Users from '/imports/api/users'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_CHAT_TYPE = CHAT_CONFIG.type_public; + +export default async function chatMessageBeforeJoinCounter() { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const groupChats = GroupChat.find({ + $or: [ + { meetingId, access: PUBLIC_CHAT_TYPE }, + { meetingId, users: { $all: [requesterUserId] } }, + ], + }).fetch(); + + const User = await Users.findOneAsync({ userId: requesterUserId, meetingId }); + + const chatIdWithCounter = groupChats.map((groupChat) => { + const msgCount = GroupChatMsg.find({ + meetingId, + chatId: groupChat.chatId, + timestamp: { $lt: User.authTokenValidatedTime }, + }).count(); + return { + chatId: groupChat.chatId, + count: msgCount, + }; + }).filter((chat) => chat.count); + return chatIdWithCounter; + } catch (err) { + Logger.error(`Exception while invoking method chatMessageBeforeJoinCounter ${err.stack}`); + } + //True returned because the function requires a return. + return true; +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js new file mode 100644 index 00000000..c09f9975 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function clearPublicChatHistory() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ClearPublicChatHistoryPubMsg'; + const CHAT_CONFIG = Meteor.settings.public.chat; + const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + chatId: PUBLIC_GROUP_CHAT_ID, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method clearPublicChatHistory ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js new file mode 100644 index 00000000..ffaf5abf --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import { GroupChatMsg } from '/imports/api/group-chat-msg'; +import Users from '/imports/api/users'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const ITENS_PER_PAGE = CHAT_CONFIG.itemsPerPage; + +export default async function fetchMessagePerPage(chatId, page) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(chatId, String); + check(page, Number); + + const User = await Users.findOneAsync({ userId: requesterUserId, meetingId }); + + const messages = await GroupChatMsg.find( + { chatId, meetingId, timestamp: { $lt: User.authTokenValidatedTime } }, + { + sort: { timestamp: 1 }, + skip: page > 0 ? ((page - 1) * ITENS_PER_PAGE) : 0, + limit: ITENS_PER_PAGE, + }, + ) + .fetchAsync(); + return messages; + } catch (err) { + Logger.error(`Exception while invoking method fetchMessagePerPage ${err.stack}`); + } + //True returned because the function requires a return. + return true; +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js new file mode 100644 index 00000000..f4caf78d --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js @@ -0,0 +1,32 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; + +import { extractCredentials, parseMessage } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function sendGroupChatMsg(chatId, message) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SendGroupChatMessageMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(chatId, String); + check(message, Object); + const parsedMessage = parseMessage(message.message); + message.message = parsedMessage; + + const payload = { + msg: message, + chatId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method sendGroupChatMsg ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js new file mode 100644 index 00000000..10b97e6c --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function startUserTyping(chatId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserTypingPubMsg'; + const CHAT_CONFIG = Meteor.settings.public.chat; + const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(chatId, String); + + const payload = { + chatId: chatId || PUBLIC_GROUP_CHAT_ID, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method startUserTyping ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js new file mode 100644 index 00000000..98e19b8b --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js @@ -0,0 +1,25 @@ +import { UsersTyping } from '/imports/api/group-chat-msg'; +import stopTyping from '../modifiers/stopTyping'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default async function stopUserTyping() { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const userTyping = await UsersTyping.findOneAsync({ + meetingId, + userId: requesterUserId, + }); + + if (userTyping && meetingId && requesterUserId) { + stopTyping(meetingId, requesterUserId, true); + } + } catch (err) { + Logger.error(`Exception while invoking method stopUserTyping ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js new file mode 100644 index 00000000..372bfa00 --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js @@ -0,0 +1,47 @@ +import { GroupChatMsg } from '/imports/api/group-chat-msg'; +import GroupChat from '/imports/api/group-chat'; +import Logger from '/imports/startup/server/logger'; +import flat from 'flat'; +import { parseMessage } from './addGroupChatMsg'; + +export default async function addBulkGroupChatMsgs(msgs) { + if (!msgs.length) return; + + const mappedMsgs = msgs + .map(({ chatId, meetingId, msg }) => { + const { + sender, + ...restMsg + } = msg; + + return { + _id: new Mongo.ObjectID()._str, + ...restMsg, + meetingId, + chatId, + message: parseMessage(msg.message), + sender: sender.id, + senderName: sender.name, + senderRole: sender.role, + }; + }) + .map((el) => flat(el, { safe: true })) + .map((msg)=>{ + const groupChat = GroupChat.findOne({ meetingId: msg.meetingId, chatId: msg.chatId }); + return { + ...msg, + participants: [...groupChat.users], + }; + }); + + try { + const { insertedCount } = await GroupChatMsg.rawCollection().insertMany(mappedMsgs); + msgs.length = 0; + + if (insertedCount) { + Logger.info(`Inserted ${insertedCount} messages`); + } + } catch (err) { + Logger.error(`Error on bulk insert. ${err}`); + } +} diff --git a/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js new file mode 100644 index 00000000..8d78c33c --- /dev/null +++ b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js @@ -0,0 +1,61 @@ +import { Match, check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { GroupChatMsg } from '/imports/api/group-chat-msg'; +import { BREAK_LINE } from '/imports/utils/lineEndings'; +import changeHasMessages from '/imports/api/users-persistent-data/server/modifiers/changeHasMessages'; +import GroupChat from '/imports/api/group-chat'; + +export function parseMessage(message) { + let parsedMessage = message || ''; + + // Replace \r and \n to
+ parsedMessage = parsedMessage.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, `$1${BREAK_LINE}$2`); + + // Replace flash links to html valid ones + parsedMessage = parsedMessage.split('', 'g'); + + do { + linkWithoutTarget.test(welcomeMsg); + + if (linkWithoutTarget.lastIndex > 0) { + welcomeMsg = insertBlankTarget( + welcomeMsg, + linkWithoutTarget.lastIndex - 1, + ); + linkWithoutTarget.lastIndex -= 1; + } + } while (linkWithoutTarget.lastIndex > 0); + + newMeeting.welcomeProp.welcomeMsg = welcomeMsg; + + // note: as of July 2020 `modOnlyMessage` is not published to the client side. + // We are sanitizing this data simply to prevent future potential usage + // At the moment `modOnlyMessage` is obtained from client side as a response to Enter API + newMeeting.welcomeProp.modOnlyMessage = sanitizeTextInChat(newMeeting.welcomeProp.modOnlyMessage); + + const { meetingLayout } = meeting.usersProp; + + const modifier = { + $set: { + meetingId, + meetingEnded, + layout: LAYOUT_TYPE[meetingLayout] || 'smart', + publishedPoll: false, + guestLobbyMessage: '', + randomlySelectedUser: [], + ...flat(newMeeting, { + safe: true, + }), + }, + }; + + if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') { + addAnnotationsStreamer(meetingId); + addCursorStreamer(meetingId); + addExternalVideoStreamer(meetingId); + + // we don't want to fully process the create meeting message + // in frontend since it can lead to duplication of meetings in mongo. + if (process.env.BBB_HTML5_ROLE === 'frontend') { + return; + } + } + + try { + const { + insertedId, + numberAffected, + } = await RecordMeetings.upsertAsync(selector, { meetingId, ...recordProp }); + + if (insertedId) { + Logger.info(`Added record prop id=${meetingId}`); + } else if (numberAffected) { + Logger.info(`Upserted record prop id=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding record prop to collection: ${err}`); + } + + await addExternalVideo(meetingId); + await addLayout(meetingId, LAYOUT_TYPE[meetingLayout] || 'smart'); + + try { + const { insertedId, numberAffected } = await Meetings.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added meeting id=${meetingId}`); + // Init Timer collection + createTimer(meetingId); + if (newMeeting.meetingProp.disabledFeatures.indexOf('sharedNotes') === -1) { + initPads(meetingId); + } + if (newMeeting.meetingProp.disabledFeatures.indexOf('captions') === -1) { + await initCaptions(meetingId); + } + if (newMeeting.meetingProp.disabledFeatures.indexOf('reactions') === -1) { + await addUserReactionsObserver(meetingId); + } + } else if (numberAffected) { + Logger.info(`Upserted meeting id=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding meeting to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js new file mode 100644 index 00000000..7c29156e --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js @@ -0,0 +1,48 @@ +import Logger from '/imports/startup/server/logger'; +import { LayoutMeetings } from '/imports/api/meetings'; +import { check } from 'meteor/check'; +import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums'; + +export default async function changeLayout( + meetingId, + layout, + presentationIsOpen, + isResizing, + cameraPosition, + focusedCamera, + presentationVideoRate, + pushLayout, + requesterUserId, +) { + try { + check(meetingId, String); + check(requesterUserId, String); + check(isResizing, Boolean); + check(layout, String); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + layout: LAYOUT_TYPE[layout] || LAYOUT_TYPE.SMART_LAYOUT, + layoutUpdatedAt: new Date().getTime(), + presentationIsOpen, + isResizing, + cameraPosition, + focusedCamera, + presentationVideoRate, + pushLayout, + }, + }; + + const numberAffected = LayoutMeetings.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`MeetingLayout changed to ${layout} for meeting=${meetingId} requested by user=${requesterUserId}`); + } + } catch (err) { + Logger.error(`Exception while invoking method changeLayout ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js new file mode 100644 index 00000000..1b1e084b --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js @@ -0,0 +1,67 @@ +import Logger from '/imports/startup/server/logger'; +import Meetings from '/imports/api/meetings'; +import { check } from 'meteor/check'; + +export default async function changeLockSettings(meetingId, payload) { + check(meetingId, String); + check(payload, { + disableCam: Boolean, + disableMic: Boolean, + disablePrivChat: Boolean, + disablePubChat: Boolean, + disableNotes: Boolean, + hideUserList: Boolean, + lockOnJoin: Boolean, + lockOnJoinConfigurable: Boolean, + hideViewersCursor: Boolean, + hideViewersAnnotation: Boolean, + setBy: Match.Maybe(String), + }); + + const { + disableCam, + disableMic, + disablePrivChat, + disablePubChat, + disableNotes, + hideUserList, + lockOnJoin, + lockOnJoinConfigurable, + hideViewersCursor, + hideViewersAnnotation, + setBy, + } = payload; + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + lockSettingsProps: { + disableCam, + disableMic, + disablePrivateChat: disablePrivChat, + disablePublicChat: disablePubChat, + disableNotes, + hideUserList, + lockOnJoin, + lockOnJoinConfigurable, + hideViewersCursor, + hideViewersAnnotation, + setBy, + }, + }, + }; + + try { + const { numberAffected } = await Meetings.upsertAsync(selector, modifier); + if (numberAffected) { + Logger.info(`Changed meeting={${meetingId}} updated lock settings`); + } else { + Logger.info(`meeting={${meetingId}} lock settings were not updated`); + } + } catch (err) { + Logger.error(`Changing meeting={${meetingId}} lock settings: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js new file mode 100644 index 00000000..e5a5f7ef --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js @@ -0,0 +1,37 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import Meetings from '/imports/api/meetings'; +import { check } from 'meteor/check'; + +export default async function changeUserChatLock(meetingId, payload) { + check(meetingId, String); + check(payload, { + userId: String, + isLocked: Boolean, + }); + + const { userId, isLocked } = payload; + + const userSelector = { + meetingId, + userId, + }; + + const userModifier = { + $set: { + chatLocked: isLocked, + }, + }; + + try { + const { numberAffected } = await Users.upsertAsync(userSelector, userModifier); + + if ( numberAffected ) { + Logger.info(`Updated lock settings in meeting ${meetingId}: disablePublicChat=${isLocked}`); + } else { + Logger.info(`Kept lock settings in meeting ${meetingId}`); + } + } catch (err) { + Logger.error(`Changing user chat lock setting: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js new file mode 100644 index 00000000..c079ed9e --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js @@ -0,0 +1,37 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import { check } from 'meteor/check'; + +export default async function changeUserLock(meetingId, payload) { + check(meetingId, String); + check(payload, { + userId: String, + locked: Boolean, + lockedBy: String, + }); + + const { userId, locked, lockedBy } = payload; + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + locked, + }, + }; + + try { + const { numberAffected } = Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`User's userId=${userId} lock status was changed to: ${locked} by user userId=${lockedBy}`); + } else { + Logger.info(`User's userId=${userId} lock status wasn't updated`); + } + } catch (err) { + Logger.error(`Changing user lock setting: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js b/src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js new file mode 100644 index 00000000..d9f4bc4e --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js @@ -0,0 +1,26 @@ +import { ExternalVideoMeetings } from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearExternalVideoMeeting(meetingId) { + if (meetingId) { + try { + const numberAffected = await ExternalVideoMeetings.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared ExternalVideoMeetings in (${meetingId})`); + } + } catch (err) { + Logger.info(`Error on clearing ExternalVideoMeetings in (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await ExternalVideoMeetings.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared ExternalVideoMeetings in all meetings'); + } + } catch (err) { + Logger.error(`Error on clearing ExternalVideoMeetings in all meetings. ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js b/src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js new file mode 100644 index 00000000..e8f9cc2d --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js @@ -0,0 +1,26 @@ +import { MeetingTimeRemaining } from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearMeetingTimeRemaining(meetingId) { + if (meetingId) { + try { + const numberAffected = await MeetingTimeRemaining.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared MeetingTimeRemaining in (${meetingId})`); + } + } catch (err) { + Logger.info(`Error on clearing MeetingTimeRemaining in (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await MeetingTimeRemaining.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared MeetingTimeRemaining in all meetings'); + } + } catch (err) { + Logger.error(`Error on clearing MeetingTimeRemaining in all meetings. ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js b/src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js new file mode 100644 index 00000000..0b21a3f2 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js @@ -0,0 +1,14 @@ +import { RecordMeetings } from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; + +export default async function meetingHasEnded(meetingId) { + try { + const numberAffected = RecordMeetings.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared record prop from meeting with id ${meetingId}`); + } + } catch (err) { + Logger.error(`Error on clearing record prop from meeting with id ${meetingId}. ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js b/src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js new file mode 100644 index 00000000..b1e401f7 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js @@ -0,0 +1,10 @@ +import notificationEmitter from '../../notificationEmitter'; + +export default function emitNotification(body, type) { + if (!process.env.BBB_HTML5_ROLE || (process.env.BBB_HTML5_ROLE === 'frontend')) { + notificationEmitter.emit('notification', { + type, + ...body, + }); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js b/src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js new file mode 100644 index 00000000..0ecbff86 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js @@ -0,0 +1,77 @@ +import Meetings from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; + +import { removeAnnotationsStreamer } from '/imports/api/annotations/server/streamer'; +import { removeCursorStreamer } from '/imports/api/cursor/server/streamer'; +import { removeExternalVideoStreamer } from '/imports/api/external-videos/server/streamer'; + +import clearUsers from '/imports/api/users/server/modifiers/clearUsers'; +import clearUsersSettings from '/imports/api/users-settings/server/modifiers/clearUsersSettings'; +import clearGroupChat from '/imports/api/group-chat/server/modifiers/clearGroupChat'; +import clearGuestUsers from '/imports/api/guest-users/server/modifiers/clearGuestUsers'; +import clearBreakouts from '/imports/api/breakouts/server/modifiers/clearBreakouts'; +import clearAnnotations from '/imports/api/annotations/server/modifiers/clearAnnotations'; +import clearSlides from '/imports/api/slides/server/modifiers/clearSlides'; +import clearPolls from '/imports/api/polls/server/modifiers/clearPolls'; +import clearCaptions from '/imports/api/captions/server/modifiers/clearCaptions'; +import clearPads from '/imports/api/pads/server/modifiers/clearPads'; +import clearPresentationPods from '/imports/api/presentation-pods/server/modifiers/clearPresentationPods'; +import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoiceUsers'; +import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo'; +import clearConnectionStatus from '/imports/api/connection-status/server/modifiers/clearConnectionStatus'; +import clearScreenshare from '/imports/api/screenshare/server/modifiers/clearScreenshare'; +import clearTimer from '/imports/api/timer/server/modifiers/clearTimer'; +import clearAudioCaptions from '/imports/api/audio-captions/server/modifiers/clearAudioCaptions'; +import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining'; +import clearLocalSettings from '/imports/api/local-settings/server/modifiers/clearLocalSettings'; +import clearRecordMeeting from './clearRecordMeeting'; +import clearExternalVideoMeeting from './clearExternalVideoMeeting'; +import clearVoiceCallStates from '/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates'; +import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams'; +import clearAuthTokenValidation from '/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation'; +import clearUsersPersistentData from '/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData'; +import clearReactions from '/imports/api/user-reaction/server/modifiers/clearReactions'; + +import clearWhiteboardMultiUser from '/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser'; +import Metrics from '/imports/startup/server/metrics'; + +export default async function meetingHasEnded(meetingId) { + if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') { + removeAnnotationsStreamer(meetingId); + removeCursorStreamer(meetingId); + removeExternalVideoStreamer(meetingId); + } + + await Meetings.removeAsync({ meetingId }); + await Promise.all([ + clearCaptions(meetingId), + clearPads(meetingId), + clearGroupChat(meetingId), + clearGuestUsers(meetingId), + clearPresentationPods(meetingId), + clearBreakouts(meetingId), + clearPolls(meetingId), + clearAnnotations(meetingId), + clearSlides(meetingId), + clearUsers(meetingId), + clearUsersSettings(meetingId), + clearVoiceUsers(meetingId), + clearUserInfo(meetingId), + clearConnectionStatus(meetingId), + clearTimer(meetingId), + clearAudioCaptions(meetingId), + clearLocalSettings(meetingId), + clearMeetingTimeRemaining(meetingId), + clearRecordMeeting(meetingId), + clearExternalVideoMeeting(meetingId), + clearVoiceCallStates(meetingId), + clearVideoStreams(meetingId), + clearAuthTokenValidation(meetingId), + clearWhiteboardMultiUser(meetingId), + clearScreenshare(meetingId), + clearUsersPersistentData(meetingId), + clearReactions(meetingId), + ]); + await Metrics.removeMeeting(meetingId); + return Logger.info(`Cleared Meetings with id ${meetingId}`); +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js new file mode 100644 index 00000000..57e1977c --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js @@ -0,0 +1,28 @@ +import Meetings from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default async function setGuestLobbyMessage(meetingId, guestLobbyMessage) { + check(meetingId, String); + check(guestLobbyMessage, String); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + guestLobbyMessage, + }, + }; + + try { + const { numberAffected } = await Meetings.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.verbose(`Set guest lobby message meetingId=${meetingId} guestLobbyMessage=${guestLobbyMessage}`); + } + } catch (err) { + Logger.error(`Setting guest lobby message: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js new file mode 100644 index 00000000..d70ad06e --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js @@ -0,0 +1,28 @@ +import Meetings from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default async function setGuestPolicy(meetingId, guestPolicy) { + check(meetingId, String); + check(guestPolicy, String); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + 'usersProp.guestPolicy': guestPolicy, + }, + }; + + try { + const { numberAffected } = await Meetings.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.verbose(`Set guest policy meetingId=${meetingId} guestPolicy=${guestPolicy}`); + } + } catch (err) { + Logger.error(`Setting guest policy: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js b/src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js new file mode 100644 index 00000000..e110741e --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js @@ -0,0 +1,28 @@ +import Meetings from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default async function setPublishedPoll(meetingId, isPublished) { + check(meetingId, String); + check(isPublished, Boolean); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + publishedPoll: isPublished, + }, + }; + + try { + const { numberAffected } = await Meetings.upsert(selector, modifier); + + if (numberAffected) { + Logger.info(`Set publishedPoll=${isPublished} in meeitingId=${meetingId}`); + } + } catch (err) { + Logger.error(`Setting publishedPoll=${isPublished} for meetingId=${meetingId}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js b/src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js new file mode 100644 index 00000000..9b1ea036 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js @@ -0,0 +1,29 @@ +import Logger from '/imports/startup/server/logger'; +import { LayoutMeetings } from '/imports/api/meetings'; +import { check } from 'meteor/check'; + +export default async function setPushLayout(meetingId, pushLayout, requesterUserId) { + try { + check(meetingId, String); + check(requesterUserId, String); + + const selector = { + meetingId, + }; + + // TODO: create a exclusive collection for layout changes + const modifier = { + $set: { + pushLayout, + }, + }; + + const numberAffected = await LayoutMeetings.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`MeetingLayout pushLayout changed to ${pushLayout} for meeting=${meetingId} requested by user=${requesterUserId}`); + } + } catch (err) { + Logger.error(`Exception while invoking method setPushLayout ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js b/src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js new file mode 100644 index 00000000..2336b201 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js @@ -0,0 +1,143 @@ +import Meetings from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +const SELECT_RANDOM_USER_COUNTDOWN = Meteor.settings.public.selectRandomUser.countdown; + +// Time intervals in milliseconds +// for iteration in animation. +const intervals = [0, 200, 450, 750, 1100, 1500]; + +// Used to togle to the first value of intervals to +// differenciare whether this function has been called +let updateIndicator = true; + +// A finction that toggles +// the first interval on each call +function toggleIndicator() { + if (updateIndicator) { + intervals[0] = 1; + } else { + intervals[0] = 0; + } + updateIndicator = !updateIndicator; +} + +function getFiveRandom(userList, userIds) { + let IDs = userIds.slice(); + for (let i = 0; i < intervals.length - 1; i += 1) { + if (IDs.length === 0) { // we used up all the options + IDs = userIds.slice(); // start over + let userId = IDs.splice(0, 1); + if (userList[userList.length] === [userId, intervals[i]]) { + // If we start over with the one we finnished, change it + IDs.push(userId); + userId = IDs.splice(0, 1); + } + userList.push([userId, intervals[i]]); + } else { + const userId = IDs.splice(Math.floor(Math.random() * IDs.length), 1); + userList.push([userId, intervals[i]]); + } + } +} + +// All possible combinations of 3 elements +// to speed up randomizing +const optionsFor3 = [ + [0, 1, 2], + [0, 2, 1], + [1, 2, 0], + [1, 0, 2], + [2, 0, 1], + [2, 1, 0], +]; + +export default async function updateRandomUser(meetingId, userIds, choice, requesterId) { + check(meetingId, String); + check(userIds, Array); + check(choice, String); + check(requesterId, String); + + let userList = []; + + const selector = { + meetingId, + }; + + toggleIndicator(); + + const numberOfUsers = userIds.length; + + if (choice === '') { + // no viewer + userList = [ + [requesterId, intervals[0]], + [requesterId, 0], + [requesterId, 0], + [requesterId, 0], + [requesterId, 0], + [requesterId, 0], + ]; + } else if (numberOfUsers === 1) { // If user is only one, obviously it is the chosen one + userList = [ + [userIds[0], intervals[0]], + [userIds[0], 0], + [userIds[0], 0], + [userIds[0], 0], + [userIds[0], 0], + [userIds[0], 0], + ]; + } else if (!SELECT_RANDOM_USER_COUNTDOWN) { + // If animation is disabled, we only care about the chosen one + userList = [ + [choice, intervals[0]], + [choice, 0], + [choice, 0], + [choice, 0], + [choice, 0], + [choice, 0], + ]; + } else if (numberOfUsers === 2) { // If there are only two users, we can just chow them in turns + const IDs = userIds.slice(); + IDs.splice(choice, 1); + userList = [ + [IDs[0], intervals[0]], + [choice, intervals[1]], + [IDs[0], intervals[2]], + [choice, intervals[3]], + [IDs[0], intervals[4]], + [choice, intervals[5]], + ]; + } else if (numberOfUsers === 3) { + // If there are 3 users, the number of combinations is small, so we'll use that + const option = Math.floor(Math.random() * 6); + const order = optionsFor3[option]; + userList = [ + [userIds[order[0]], intervals[0]], + [userIds[order[1]], intervals[1]], + [userIds[order[2]], intervals[2]], + [userIds[order[0]], intervals[3]], + [userIds[order[1]], intervals[4]], + [choice, intervals[5]], + ]; + } else { // We generate 5 users randomly, just for animation, and last one is the chosen one + getFiveRandom(userList, userIds); + userList.push([choice, intervals[intervals.length]]); + } + + const modifier = { + $set: { + randomlySelectedUser: userList, + }, + }; + + try { + const { insertedId } = await Meetings.upsertAsync(selector, modifier); + if (insertedId) { + Logger.info(`Set randomly selected userId and interval = ${userList} by requesterId=${requesterId} in meeitingId=${meetingId}`); + } + } catch (err) { + Logger.error(`Setting randomly selected userId and interval = ${userList} by requesterId=${requesterId} in meetingId=${meetingId}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js b/src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js new file mode 100644 index 00000000..5c4db1a8 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js @@ -0,0 +1,34 @@ +import Logger from '/imports/startup/server/logger'; +import Meetings from '/imports/api/meetings'; +import { check } from 'meteor/check'; + +export default async function changeWebcamOnlyModerator(meetingId, payload) { + check(meetingId, String); + check(payload, { + webcamsOnlyForModerator: Boolean, + setBy: String, + }); + const { webcamsOnlyForModerator } = payload; + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + 'usersProp.webcamsOnlyForModerator': webcamsOnlyForModerator, + }, + }; + + try { + const { numberAffected } = await Meetings.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Changed meeting={${meetingId}} updated webcam Only for Moderator`); + } else { + Logger.info(`meeting={${meetingId}} webcam Only for Moderator were not updated`); + } + } catch (err) { + Logger.error(`Changwing meeting={${meetingId}} webcam Only for Moderator: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/meetings/server/publishers.js b/src/2.7.12/imports/api/meetings/server/publishers.js new file mode 100644 index 00000000..ab7b8240 --- /dev/null +++ b/src/2.7.12/imports/api/meetings/server/publishers.js @@ -0,0 +1,193 @@ +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import Meetings, { + RecordMeetings, + MeetingTimeRemaining, + ExternalVideoMeetings, + LayoutMeetings, +} from '/imports/api/meetings'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; +import { publicationSafeGuard } from '/imports/api/common/server/helpers'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; +import notificationEmitter from '../notificationEmitter'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +function meetings() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Meetings was requested by unauth connection ${this.connection.id}`); + return Meetings.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing meeting', { meetingId, userId }); + + const selector = { + $or: [ + { meetingId }, + ], + }; + + const User = Users.findOne({ userId, meetingId }, { fields: { userId: 1, role: 1 } }); + if (!!User && User.role === ROLE_MODERATOR) { + selector.$or.push({ + 'meetingProp.isBreakout': true, + 'breakoutProps.parentId': meetingId, + }); + // Monitor this publication and stop it when user is not a moderator anymore + const comparisonFunc = () => { + const user = Users.findOne({ userId, meetingId }, { fields: { role: 1, userId: 1 } }); + const condition = user.role === ROLE_MODERATOR; + + if (!condition) { + Logger.info(`conditions aren't filled anymore in publication ${this._name}: + user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`); + } + + return condition; + }; + publicationSafeGuard(comparisonFunc, this); + } + const options = { + fields: { + password: false, + 'welcomeProp.modOnlyMessage': false, + }, + }; + + if (User.role !== ROLE_MODERATOR) { + options.fields.learningDashboardAccessToken = false; + } + + return Meetings.find(selector, options); +} + +function publish(...args) { + const boundMeetings = meetings.bind(this); + return boundMeetings(...args); +} + +Meteor.publish('meetings', publish); + +function recordMeetings() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing RecordMeetings was requested by unauth connection ${this.connection.id}`); + return RecordMeetings.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug(`Publishing RecordMeetings for ${meetingId} ${userId}`); + + return RecordMeetings.find({ meetingId }); +} +function recordPublish(...args) { + const boundRecordMeetings = recordMeetings.bind(this); + return boundRecordMeetings(...args); +} + +Meteor.publish('record-meetings', recordPublish); + +function layoutMeetings() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing LayoutMeetings was requested by unauth connection ${this.connection.id}`); + return LayoutMeetings.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug(`Publishing LayoutMeetings for ${meetingId} ${userId}`); + + return LayoutMeetings.find({ meetingId }); +} + +function layoutPublish(...args) { + const boundLayoutMeetings = layoutMeetings.bind(this); + return boundLayoutMeetings(...args); +} + +Meteor.publish('layout-meetings', layoutPublish); + +function externalVideoMeetings() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing ExternalVideoMeetings was requested by unauth connection ${this.connection.id}`); + return ExternalVideoMeetings.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug(`Publishing ExternalVideoMeetings for ${meetingId} ${userId}`); + + return ExternalVideoMeetings.find({ meetingId }); +} + +function externalVideoPublish(...args) { + const boundExternalVideoMeetings = externalVideoMeetings.bind(this); + return boundExternalVideoMeetings(...args); +} + +Meteor.publish('external-video-meetings', externalVideoPublish); + +function meetingTimeRemaining() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing MeetingTimeRemaining was requested by unauth connection ${this.connection.id}`); + return MeetingTimeRemaining.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + Logger.debug(`Publishing MeetingTimeRemaining for ${meetingId} ${userId}`); + + return MeetingTimeRemaining.find({ meetingId }); +} +function timeRemainingPublish(...args) { + const boundtimeRemaining = meetingTimeRemaining.bind(this); + return boundtimeRemaining(...args); +} + +Meteor.publish('meeting-time-remaining', timeRemainingPublish); + +function notifications() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + if (tokenValidation && tokenValidation.validationStatus === ValidationStates.VALIDATED) { + notificationEmitter.on('notification', (notification) => { + const { meetingId, userId } = tokenValidation; + switch (notification.type) { + case 'notifyAllInMeeting': + if (notification.meetingId === meetingId) this.added('notifications', Random.id(), notification); + break; + case 'NotifyUserInMeeting': + if (notification.meetingId === meetingId && notification.userId === userId) this.added('notifications', Random.id(), notification); + break; + case 'NotifyRoleInMeeting': { + const user = Users.findOne({ userId, meetingId }, { fields: { role: 1, userId: 1 } }); + if (notification.meetingId === meetingId && notification.role === user.role) this.added('notifications', Random.id(), notification); + break; + } + default: Logger.warn(`wrong type: ${notification.type} userId: ${userId}`); + } + }); + + this.ready(); + } else { + Logger.warn(`Publishing notification was requested by unauth connection ${this.connection.id}`); + } +} + +function notificationsPublish(...args) { + const boundNotifications = notifications.bind(this); + return boundNotifications(...args); +} + +Meteor.publish('notifications', notificationsPublish); diff --git a/src/2.7.12/imports/api/pads/index.js b/src/2.7.12/imports/api/pads/index.js new file mode 100644 index 00000000..61a4f03e --- /dev/null +++ b/src/2.7.12/imports/api/pads/index.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Pads = new Mongo.Collection('pads', collectionOptions); +const PadsSessions = new Mongo.Collection('pads-sessions', collectionOptions); +const PadsUpdates = new Mongo.Collection('pads-updates', collectionOptions); + +if (Meteor.isServer) { + Pads.createIndexAsync({ meetingId: 1, externalId: 1 }); + PadsSessions.createIndexAsync({ meetingId: 1, userId: 1 }); + PadsUpdates.createIndexAsync({ meetingId: 1, externalId: 1 }); +} + +export { + PadsSessions, + PadsUpdates, +}; + +export default Pads; diff --git a/src/2.7.12/imports/api/pads/server/eventHandlers.js b/src/2.7.12/imports/api/pads/server/eventHandlers.js new file mode 100644 index 00000000..58a65eab --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/eventHandlers.js @@ -0,0 +1,20 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import groupCreated from './handlers/groupCreated'; +import padCreated from './handlers/padCreated'; +import sessionCreated from './handlers/sessionCreated'; +import padUpdated from './handlers/padUpdated'; +import padContent from './handlers/padContent'; +import padTail from './handlers/padTail'; +import sessionDeleted from './handlers/sessionDeleted'; +import captureSharedNotes from './handlers/captureSharedNotes'; +import padPinned from './handlers/padPinned'; + +RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated); +RedisPubSub.on('PadCreatedRespMsg', padCreated); +RedisPubSub.on('PadSessionCreatedRespMsg', sessionCreated); +RedisPubSub.on('PadUpdatedEvtMsg', padUpdated); +RedisPubSub.on('PadContentEvtMsg', padContent); +RedisPubSub.on('PadTailEvtMsg', padTail); +RedisPubSub.on('PadSessionDeletedEvtMsg', sessionDeleted); +RedisPubSub.on('CaptureSharedNotesReqEvtMsg', captureSharedNotes); +RedisPubSub.on('PadPinnedEvtMsg', padPinned); diff --git a/src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js b/src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js new file mode 100644 index 00000000..25354bae --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js @@ -0,0 +1,22 @@ +import { check } from 'meteor/check'; +import padCapture from '../methods/padCapture'; + +export default async function captureSharedNotes({ header, body }) { + check(header, Object); + check(body, Object); + + const { + meetingId: parentMeetingId, + } = header; + + const { + breakoutId, + filename, + } = body; + + check(breakoutId, String); + check(parentMeetingId, String); + check(filename, String); + + await padCapture(breakoutId, parentMeetingId, filename); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/groupCreated.js b/src/2.7.12/imports/api/pads/server/handlers/groupCreated.js new file mode 100644 index 00000000..06fd8feb --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/groupCreated.js @@ -0,0 +1,16 @@ +import createGroup from '/imports/api/pads/server/modifiers/createGroup'; + +export default async function groupCreated({ header, body }) { + const { + meetingId, + userId, + } = header; + + const { + externalId, + model, + name, + } = body; + + await createGroup(meetingId, userId, externalId, model, name); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/padContent.js b/src/2.7.12/imports/api/pads/server/handlers/padContent.js new file mode 100644 index 00000000..bacb784a --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/padContent.js @@ -0,0 +1,16 @@ +import contentPad from '/imports/api/pads/server/modifiers/contentPad'; + +export default async function padContent({ header, body }) { + const { + meetingId, + } = header; + + const { + externalId, + start, + end, + text, + } = body; + + await contentPad(meetingId, externalId, start, end, text); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/padCreated.js b/src/2.7.12/imports/api/pads/server/handlers/padCreated.js new file mode 100644 index 00000000..236f500a --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/padCreated.js @@ -0,0 +1,11 @@ +import createPad from '/imports/api/pads/server/modifiers/createPad'; + +export default async function padCreated({ header, body }) { + const { meetingId } = header; + const { + externalId, + padId, + } = body; + + await createPad(meetingId, externalId, padId); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/padPinned.js b/src/2.7.12/imports/api/pads/server/handlers/padPinned.js new file mode 100644 index 00000000..f3239131 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/padPinned.js @@ -0,0 +1,14 @@ +import pinPad from '/imports/api/pads/server/modifiers/pinPad'; + +export default async function padPinned({ header, body }) { + const { + meetingId, + } = header; + + const { + externalId, + pinned, + } = body; + + await pinPad(meetingId, externalId, pinned); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/padTail.js b/src/2.7.12/imports/api/pads/server/handlers/padTail.js new file mode 100644 index 00000000..18290f8e --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/padTail.js @@ -0,0 +1,14 @@ +import tailPad from '/imports/api/pads/server/modifiers/tailPad'; + +export default async function padTail({ header, body }) { + const { + meetingId, + } = header; + + const { + externalId, + tail, + } = body; + + await tailPad(meetingId, externalId, tail); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/padUpdated.js b/src/2.7.12/imports/api/pads/server/handlers/padUpdated.js new file mode 100644 index 00000000..ba8dadd7 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/padUpdated.js @@ -0,0 +1,17 @@ +import updatePad from '/imports/api/pads/server/modifiers/updatePad'; + +export default async function padUpdated({ header, body }) { + const { + meetingId, + } = header; + + const { + externalId, + padId, + userId, + rev, + changeset, + } = body; + + await updatePad(meetingId, externalId, padId, userId, rev, changeset); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js b/src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js new file mode 100644 index 00000000..af7bf922 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js @@ -0,0 +1,15 @@ +import createSession from '/imports/api/pads/server/modifiers/createSession'; + +export default async function sessionCreated({ header, body }) { + const { + meetingId, + userId, + } = header; + + const { + externalId, + sessionId, + } = body; + + await createSession(meetingId, userId, externalId, sessionId); +} diff --git a/src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js b/src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js new file mode 100644 index 00000000..3d56db23 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js @@ -0,0 +1,15 @@ +import deleteSession from '/imports/api/pads/server/modifiers/deleteSession'; + +export default async function sessionDeleted({ header, body }) { + const { + meetingId, + } = header; + + const { + externalId, + userId, + sessionId, + } = body; + + await deleteSession(meetingId, externalId, userId, sessionId); +} diff --git a/src/2.7.12/imports/api/pads/server/helpers.js b/src/2.7.12/imports/api/pads/server/helpers.js new file mode 100644 index 00000000..5b33328a --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/helpers.js @@ -0,0 +1,51 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; + +const NOTES_CONFIG = Meteor.settings.public.notes; +const CAPTIONS_CONFIG = Meteor.settings.public.captions; +const REDIS_CONFIG = Meteor.settings.private.redis; +const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; +const TOKEN = '$'; + +const models = { + CAPTIONS: CAPTIONS_CONFIG.id, + NOTES: NOTES_CONFIG.id, +}; + +const getDataFromChangeset = (changeset) => { + const splitChangeset = changeset.split(TOKEN); + if (splitChangeset.length > 1) { + splitChangeset.shift(); + + return splitChangeset.join(TOKEN); + } + + return ''; +}; + +const createGroup = (meetingId, externalId, model, name) => { + const EVENT_NAME = 'PadCreateGroupReqMsg'; + + try { + const payload = { + externalId, + model, + name, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, 'nodeJSapp', payload); + } catch (err) { + Logger.error(`Exception while invoking method createGroup ${err.stack}`); + } +}; + +const initPads = (meetingId) => { + if (NOTES_CONFIG.enabled) createGroup(meetingId, NOTES_CONFIG.id, models.NOTES, NOTES_CONFIG.id); +}; + +export { + getDataFromChangeset, + initPads, + models, +}; diff --git a/src/2.7.12/imports/api/pads/server/index.js b/src/2.7.12/imports/api/pads/server/index.js new file mode 100644 index 00000000..760855cd --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/index.js @@ -0,0 +1,3 @@ +import './publishers'; +import './methods'; +import './eventHandlers'; diff --git a/src/2.7.12/imports/api/pads/server/methods.js b/src/2.7.12/imports/api/pads/server/methods.js new file mode 100644 index 00000000..535aba02 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import createGroup from './methods/createGroup'; +import createSession from './methods/createSession'; +import getPadId from './methods/getPadId'; +import pinPad from './methods/pinPad'; + +Meteor.methods({ + createGroup, + createSession, + getPadId, + pinPad, +}); diff --git a/src/2.7.12/imports/api/pads/server/methods/createGroup.js b/src/2.7.12/imports/api/pads/server/methods/createGroup.js new file mode 100644 index 00000000..81b7d24d --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/createGroup.js @@ -0,0 +1,31 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function createGroup(externalId, model, name) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadCreateGroupReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(externalId, String); + check(model, String); + check(name, String); + + const payload = { + externalId, + model, + name, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method createGroup ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/createPad.js b/src/2.7.12/imports/api/pads/server/methods/createPad.js new file mode 100644 index 00000000..2ee94d03 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/createPad.js @@ -0,0 +1,26 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function createPad(meetingId, userId, externalId, name) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadCreateReqMsg'; + + try { + check(meetingId, String); + check(userId, String); + check(externalId, String); + check(name, String); + + const payload = { + externalId, + name, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); + } catch (err) { + Logger.error(`Exception while invoking method createPad ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/createSession.js b/src/2.7.12/imports/api/pads/server/methods/createSession.js new file mode 100644 index 00000000..e2ce8fd7 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/createSession.js @@ -0,0 +1,27 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function createSession(externalId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadCreateSessionReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(externalId, String); + + const payload = { + externalId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method createSession ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/getPadId.js b/src/2.7.12/imports/api/pads/server/methods/getPadId.js new file mode 100644 index 00000000..2b69d616 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/getPadId.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import Pads from '/imports/api/pads'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default async function getPadId(externalId) { + try { + const { meetingId } = extractCredentials(this.userId); + + check(meetingId, String); + check(externalId, String); + + const pad = await Pads.findOneAsync( + { + meetingId, + externalId, + }, + { + fields: { + padId: 1, + }, + }, + ); + + if (pad && pad.padId) { + return pad.padId; + } + + return null; + } catch (err) { + return null; + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/padCapture.js b/src/2.7.12/imports/api/pads/server/methods/padCapture.js new file mode 100644 index 00000000..a57fc1bd --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/padCapture.js @@ -0,0 +1,64 @@ +import Pads, { PadsUpdates } from '/imports/api/pads'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; + +export default async function padCapture(breakoutId, parentMeetingId, filename) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadCapturePubMsg'; + const EVENT_NAME_ERROR = 'PresentationConversionUpdateSysPubMsg'; + const EXTERNAL_ID = Meteor.settings.public.notes.id; + + try { + const pad = await Pads.findOneAsync( + { + meetingId: breakoutId, + externalId: EXTERNAL_ID, + }, + { + fields: { + padId: 1, + }, + }, + ); + + const update = await PadsUpdates.findOneAsync( + { + meetingId: breakoutId, + externalId: EXTERNAL_ID, + }, { + fields: { + rev: 1, + }, + }, + ); + + if (pad?.padId && update?.rev > 0) { + const payload = { + parentMeetingId, + breakoutId, + padId: pad.padId, + filename, + }; + Logger.info(`Sending PadCapturePubMsg for meetingId=${breakoutId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`); + return RedisPubSub.publishMeetingMessage(CHANNEL, EVENT_NAME, parentMeetingId, payload); + } + + // Notify that no content is available + const temporaryPresentationId = `${breakoutId}-notes`; + const payload = { + podId: 'DEFAULT_PRESENTATION_POD', + messageKey: '204', + code: 'not-used', + presentationId: temporaryPresentationId, + presName: filename, + temporaryPresentationId, + }; + + Logger.info(`No notes available for capture in meetingId=${breakoutId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`); + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME_ERROR, parentMeetingId, 'system', payload); + } catch (err) { + Logger.error(`Exception while invoking method padCapture ${err.stack}`); + return null; + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/pinPad.js b/src/2.7.12/imports/api/pads/server/methods/pinPad.js new file mode 100644 index 00000000..630cd281 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/pinPad.js @@ -0,0 +1,29 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function pinPad(externalId, pinned) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadPinnedReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(externalId, String); + check(pinned, Boolean); + + const payload = { + externalId, + pinned, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method pinPad ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/updatePad.js b/src/2.7.12/imports/api/pads/server/methods/updatePad.js new file mode 100644 index 00000000..19a39800 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/updatePad.js @@ -0,0 +1,27 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function updatePad(meetingId, userId, externalId, text) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadUpdatePubMsg'; + + try { + check(meetingId, String); + check(userId, String); + check(externalId, String); + check(text, String); + + const payload = { + externalId, + text, + transcript: false, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); + } catch (err) { + Logger.error(`Exception while invoking method updatePad ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js b/src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js new file mode 100644 index 00000000..45a363ed --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js @@ -0,0 +1,29 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function updateTranscriptPad(meetingId, userId, externalId, text) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PadUpdatePubMsg'; + + try { + check(meetingId, String); + check(userId, String); + check(externalId, String); + check(text, String); + + // Send a special boolean denoting this was updated by the transcript system + // this way we can write it in the 'presenter' pad and still block manual updates by viewers + const payload = { + externalId, + text, + transcript: true, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); + } catch (err) { + Logger.error(`Exception while invoking method updateTranscriptPad ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/clearPads.js b/src/2.7.12/imports/api/pads/server/modifiers/clearPads.js new file mode 100644 index 00000000..40d31018 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/clearPads.js @@ -0,0 +1,30 @@ +import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +const clear = async (meetingId, name, collection) => { + if (meetingId) { + try { + const result = await collection.removeAsync({ meetingId }); + if (result) { + Logger.info(`Cleared ${name} (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing ${name} (${meetingId}). ${err}`); + } + } else { + try { + const result = await collection.removeAsync({}); + if (result) { + Logger.info(`Cleared ${name} (all)`); + } + } catch (err) { + Logger.error(`Error on clearing ${name} (all). ${err}`); + } + } +}; + +export default async function clearPads(meetingId) { + await clear(meetingId, 'Pads', Pads); + await clear(meetingId, 'PadsSessions', PadsSessions); + await clear(meetingId, 'PadsUpdates', PadsUpdates); +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/contentPad.js b/src/2.7.12/imports/api/pads/server/modifiers/contentPad.js new file mode 100644 index 00000000..c42642ca --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/contentPad.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import { patch } from '@mconf/bbb-diff'; +import { PadsUpdates } from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +export default async function contentPad(meetingId, externalId, start, end, text) { + try { + check(meetingId, String); + check(externalId, String); + check(start, Number); + check(end, Number); + check(text, String); + + const selector = { + meetingId, + externalId, + }; + + const pad = await PadsUpdates.findOneAsync(selector); + const content = (pad && pad.content) ? pad.content : ''; + + const modifier = { + $set: { + content: patch(content, { start, end, text }), + }, + }; + + await PadsUpdates.upsertAsync(selector, modifier); + Logger.debug(`Added pad content external=${externalId} meeting=${meetingId}`); + } catch (err) { + Logger.error(`Adding pad content to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/createGroup.js b/src/2.7.12/imports/api/pads/server/modifiers/createGroup.js new file mode 100644 index 00000000..c1c7cc6b --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/createGroup.js @@ -0,0 +1,39 @@ +import { check } from 'meteor/check'; +import Pads from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; +import createPad from '/imports/api/pads/server/methods/createPad'; + +export default async function createGroup(meetingId, userId, externalId, model, name) { + try { + check(meetingId, String); + check(userId, String); + check(externalId, String); + check(model, String); + check(name, String); + + const selector = { + meetingId, + externalId, + }; + + const modifier = { + meetingId, + externalId, + model, + name, + }; + + const { insertedId } = await Pads.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added pad group external=${externalId} meeting=${meetingId}`); + // Each group will get only one pad so we can control access per pad. The pad's name + // will be the group's externalId + createPad(meetingId, userId, externalId, externalId); + } else { + Logger.info(`Upserted pad group external=${externalId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding pad group to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/createPad.js b/src/2.7.12/imports/api/pads/server/modifiers/createPad.js new file mode 100644 index 00000000..f6882193 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/createPad.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Pads from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +export default async function createPad(meetingId, externalId, padId) { + try { + check(meetingId, String); + check(externalId, String); + check(padId, String); + + const selector = { + meetingId, + externalId, + }; + + const modifier = { + $set: { + padId, + pinned: false, + }, + }; + + const { insertedId } = await Pads.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added pad=${padId} external=${externalId} meeting=${meetingId}`); + } else { + Logger.info(`Upserted pad=${padId} external=${externalId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding pad to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/createSession.js b/src/2.7.12/imports/api/pads/server/modifiers/createSession.js new file mode 100644 index 00000000..615011c7 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/createSession.js @@ -0,0 +1,35 @@ +import { check } from 'meteor/check'; +import { PadsSessions } from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +export default async function createSession(meetingId, userId, externalId, sessionId) { + try { + check(meetingId, String); + check(userId, String); + check(externalId, String); + check(sessionId, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $push: { + sessions: { + [externalId]: sessionId, + }, + }, + }; + + const { insertedId } = await PadsSessions.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.debug(`Added pad session=${sessionId} external=${externalId} user=${userId} meeting=${meetingId}`); + } else { + Logger.debug(`Upserted pad session=${sessionId} external=${externalId} user=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding pad session to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js b/src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js new file mode 100644 index 00000000..1b8071e2 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import { PadsSessions } from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +export default async function deleteSession(meetingId, externalId, userId, sessionId) { + try { + check(meetingId, String); + check(externalId, String); + check(userId, String); + check(sessionId, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $pull: { + sessions: { + [externalId] : sessionId, + }, + }, + }; + + await PadsSessions.upsertAsync(selector, modifier); + Logger.debug(`Removed pad session=${sessionId} external=${externalId} user=${userId} meeting=${meetingId}`); + } catch (err) { + Logger.error(`Removing pad session to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/pinPad.js b/src/2.7.12/imports/api/pads/server/modifiers/pinPad.js new file mode 100644 index 00000000..ddf12069 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/pinPad.js @@ -0,0 +1,35 @@ +import { check } from 'meteor/check'; +import { default as Pads } from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +export default async function pinPad(meetingId, externalId, pinned) { + try { + check(meetingId, String); + check(externalId, String); + check(pinned, Boolean); + + if (pinned) { + await Pads.updateAsync({ meetingId, pinned: true }, { $set: { pinned: false } }); + } + + const selector = { + meetingId, + externalId, + }; + + const modifier = { + $set: { + pinned, + }, + }; + + const numberAffected = await Pads.updateAsync(selector, modifier); + + if (numberAffected) { + const prefix = pinned ? '' : 'un'; + Logger.debug(`Pad ${prefix}pinned external=${externalId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Pinning pad: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/tailPad.js b/src/2.7.12/imports/api/pads/server/modifiers/tailPad.js new file mode 100644 index 00000000..d8d0c80e --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/tailPad.js @@ -0,0 +1,27 @@ +import { check } from 'meteor/check'; +import { PadsUpdates } from '/imports/api/pads'; +import Logger from '/imports/startup/server/logger'; + +export default async function tailPad(meetingId, externalId, tail) { + try { + check(meetingId, String); + check(externalId, String); + check(tail, String); + + const selector = { + meetingId, + externalId, + }; + + const modifier = { + $set: { + tail, + }, + }; + + await PadsUpdates.upsertAsync(selector, modifier); + Logger.debug(`Added pad tail external=${externalId} meeting=${meetingId}`); + } catch (err) { + Logger.error(`Adding pad tail to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/modifiers/updatePad.js b/src/2.7.12/imports/api/pads/server/modifiers/updatePad.js new file mode 100644 index 00000000..f50edcd4 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/modifiers/updatePad.js @@ -0,0 +1,35 @@ +import { check } from 'meteor/check'; +import { PadsUpdates } from '/imports/api/pads'; +import { getDataFromChangeset } from '/imports/api/pads/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function updatePad(meetingId, externalId, padId, userId, rev, changeset) { + try { + check(meetingId, String); + check(externalId, String); + check(padId, String); + check(userId, String); + check(rev, Number); + check(changeset, String); + + const selector = { + meetingId, + externalId, + }; + + const modifier = { + $set: { + padId, + userId, + rev, + changeset, + data: getDataFromChangeset(changeset), + }, + }; + + await PadsUpdates.upsertAsync(selector, modifier); + Logger.debug(`Added pad update external=${externalId} user=${userId} rev=${rev} meeting=${meetingId}`); + } catch (err) { + Logger.error(`Adding pad update to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/pads/server/publishers.js b/src/2.7.12/imports/api/pads/server/publishers.js new file mode 100644 index 00000000..bf55bc60 --- /dev/null +++ b/src/2.7.12/imports/api/pads/server/publishers.js @@ -0,0 +1,79 @@ +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function pads() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Pads was requested by unauth connection ${this.connection.id}`); + return Pads.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.info(`Publishing Pads for ${meetingId} ${userId}`); + + const options = { + fields: { + padId: 0, + }, + }; + + return Pads.find({ meetingId }, options); +} + +async function padsSessions() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing PadsSessions was requested by unauth connection ${this.connection.id}`); + return PadsSessions.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.info(`Publishing PadsSessions for ${meetingId} ${userId}`); + + return PadsSessions.find({ meetingId, userId }); +} + +async function padsUpdates() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing PadsUpdates was requested by unauth connection ${this.connection.id}`); + return PadsUpdates.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.info(`Publishing PadsUpdates for ${meetingId} ${userId}`); + + return PadsUpdates.find({ meetingId }); +} + +function publishPads(...args) { + const boundPads = pads.bind(this); + return boundPads(...args); +} + +function publishPadsSessions(...args) { + const boundPadsSessions = padsSessions.bind(this); + return boundPadsSessions(...args); +} + +function publishPadsUpdates(...args) { + const boundPadsUpdates = padsUpdates.bind(this); + return boundPadsUpdates(...args); +} + +Meteor.publish('pads', publishPads); + +Meteor.publish('pads-sessions', publishPadsSessions); + +Meteor.publish('pads-updates', publishPadsUpdates); diff --git a/src/2.7.12/imports/api/polls/index.js b/src/2.7.12/imports/api/polls/index.js new file mode 100644 index 00000000..a9646875 --- /dev/null +++ b/src/2.7.12/imports/api/polls/index.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Polls = new Mongo.Collection('polls', collectionOptions); +export const CurrentPoll = new Mongo.Collection('current-poll', { connection: null }); + +if (Meteor.isServer) { + // We can have just one active poll per meeting + // makes no sense to index it by anything other than meetingId + + Polls.createIndexAsync({ meetingId: 1 }); +} + +export default Polls; diff --git a/src/2.7.12/imports/api/polls/index.test.js b/src/2.7.12/imports/api/polls/index.test.js new file mode 100644 index 00000000..14452714 --- /dev/null +++ b/src/2.7.12/imports/api/polls/index.test.js @@ -0,0 +1,103 @@ +import { Meteor } from 'meteor/meteor'; +import Polls from '/imports/api/polls'; +import { expect } from 'chai'; +// Modifiers +import clearPolls from './server/modifiers/clearPolls'; +import removePoll from './server/modifiers/removePoll'; +import addPoll from './server/modifiers/addPoll'; +import updateVotes from './server/modifiers/updateVotes'; +// Handlers +import pollStarted from './server/handlers/pollStarted'; +import pollStopped from './server/handlers/pollStopped'; + +// mock test data +const _id = 'sJt6JaJMsTgy64TZG'; +const meetingId = '183f0bf3a0982a127bdb8161e0c44eb696b3e75c-1623285094106'; +const requester = 'w_iotqesmfrtqj'; +const pollType = 'TF'; +const id = 'd2d9a672040fbde2a47a10bf6c37b6a4b5ae187f-1623285094108/1/1623285145173'; +const answers = [{ id: 0, key: 'True' }, { id: 1, key: 'False' }]; +const users = []; +const questionText = ''; +const pollObj = { + _id, + meetingId, + requester, + pollType, + answers, + id, + users, + questionText, +}; + +Polls.insert(pollObj); +const poll = Polls.findOne(pollObj); + +if (Meteor.isServer) { + describe('Polls Collection', () => { + describe('Modifiers :', () => { + it('Validate (#_id, #meetingId, #requester, #pollType, #users, #answers)', () => { + expect(poll?._id).to.be.a('string').equal(_id); + expect(poll?.meetingId).to.be.a('string').equal(meetingId); + expect(poll?.requester).to.be.a('string').equal(requester); + expect(poll?.pollType).to.be.a('string').equal(pollType); + expect(poll?.users).to.be.an('array'); + expect(poll?.answers).to.be.an('array'); + }); + + it('addPoll(): Should have added a poll', () => { + addPoll(meetingId, requester, { id, answers }, pollType, questionText); + expect(Polls.findOne({ id, meetingId })?.id).to.be.a('string').equal(pollObj.id); + }); + + it('updateVotes(): Should update vote for a poll', () => { + answers[0].numVotes = 1; + answers[1].numVotes = 0; + + updateVotes({ + id, + answers, + numResponders: 1, + numRespondents: 1, + }, meetingId); + + expect(Polls.findOne({ meetingId, id })?.answers[0]?.numVotes).to.be.a('number').equal(1); + expect(Polls.findOne({ meetingId, id })?.answers[1]?.numVotes).to.be.a('number').equal(0); + }); + + it('removePoll(): Should have removed specified poll', () => { + removePoll(meetingId, id); + expect(Polls.findOne({ meetingId, id })).to.be.an('undefined'); + }); + + it('clearPolls(): Should have cleared all polls', () => { + Polls.insert(pollObj); + clearPolls(); + expect(Polls.findOne({ meetingId })).to.be.an('undefined'); + }); + }); + + describe('Handlers :', () => { + it('pollStarted(): should add a poll and reset publishedPoll flag', () => { + delete answers[0].numVotes; + delete answers[1].numVotes; + + pollStarted({ + body: { + userId: requester, + poll: { id, answers }, + pollType, + question: '', + }, + }, meetingId); + + expect(Polls.findOne({ meetingId, id })?.id).to.be.a('string').equal(id); + }); + + it('pollStopped(): Should have removed poll', () => { + pollStopped({ body: { poll: { pollId: id } } }, meetingId); + expect(Polls.findOne({ meetingId, id })?.id).to.be.an('undefined'); + }); + }); + }); +} diff --git a/src/2.7.12/imports/api/polls/server/eventHandlers.js b/src/2.7.12/imports/api/polls/server/eventHandlers.js new file mode 100644 index 00000000..13f8802f --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/eventHandlers.js @@ -0,0 +1,14 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handlePollStarted from './handlers/pollStarted'; +import handlePollStopped from './handlers/pollStopped'; +import handlePollPublished from './handlers/pollPublished'; +import handleUserVoted from './handlers/userVoted'; +import handleUserResponded from './handlers/userResponded'; +import handleUserTypedResponse from './handlers/userTypedResponse'; + +RedisPubSub.on('PollShowResultEvtMsg', handlePollPublished); +RedisPubSub.on('PollStartedEvtMsg', handlePollStarted); +RedisPubSub.on('PollStoppedEvtMsg', handlePollStopped); +RedisPubSub.on('PollUpdatedEvtMsg', handleUserVoted); +RedisPubSub.on('UserRespondedToPollRespMsg', handleUserResponded); +RedisPubSub.on('UserRespondedToTypedPollRespMsg', handleUserTypedResponse); diff --git a/src/2.7.12/imports/api/polls/server/handlers/pollPublished.js b/src/2.7.12/imports/api/polls/server/handlers/pollPublished.js new file mode 100644 index 00000000..3bf181b9 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/pollPublished.js @@ -0,0 +1,18 @@ +import { check } from 'meteor/check'; +import setPublishedPoll from '../../../meetings/server/modifiers/setPublishedPoll'; +import handleSendSystemChatForPublishedPoll from './sendPollChatMsg'; + +const POLL_CHAT_MESSAGE = Meteor.settings.public.poll.chatMessage; + +export default function pollPublished({ body }, meetingId) { + const { pollId } = body; + + check(meetingId, String); + check(pollId, String); + + setPublishedPoll(meetingId, true); + + if (POLL_CHAT_MESSAGE) { + handleSendSystemChatForPublishedPoll({ body }, meetingId); + } +} diff --git a/src/2.7.12/imports/api/polls/server/handlers/pollStarted.js b/src/2.7.12/imports/api/polls/server/handlers/pollStarted.js new file mode 100644 index 00000000..70d10150 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/pollStarted.js @@ -0,0 +1,22 @@ +import { check } from 'meteor/check'; +import addPoll from '../modifiers/addPoll'; +import setPublishedPoll from '../../../meetings/server/modifiers/setPublishedPoll'; + +export default async function pollStarted({ body }, meetingId) { + const { + userId, poll, pollType, secretPoll, question, + } = body; + + check(meetingId, String); + check(userId, String); + check(poll, Object); + check(pollType, String); + check(secretPoll, Boolean); + check(question, String); + + setPublishedPoll(meetingId, false); + + const result = await addPoll(meetingId, userId, poll, pollType, secretPoll, question); + + return result; +} diff --git a/src/2.7.12/imports/api/polls/server/handlers/pollStopped.js b/src/2.7.12/imports/api/polls/server/handlers/pollStopped.js new file mode 100644 index 00000000..82f33e4c --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/pollStopped.js @@ -0,0 +1,23 @@ +import { check } from 'meteor/check'; +import removePoll from '../modifiers/removePoll'; +import clearPolls from '../modifiers/clearPolls'; + +export default async function pollStopped({ body }, meetingId) { + const { poll } = body; + + check(meetingId, String); + + if (poll) { + const { pollId } = poll; + + check(pollId, String); + + const result = await removePoll(meetingId, pollId); + + return result; + } + + const result = await clearPolls(meetingId); + + return result; +} diff --git a/src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js b/src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js new file mode 100644 index 00000000..b246b9c3 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js @@ -0,0 +1,37 @@ +import addSystemMsg from '../../../group-chat-msg/server/modifiers/addSystemMsg'; +import caseInsensitiveReducer from '/imports/utils/caseInsensitiveReducer'; + +export default function sendPollChatMsg({ body }, meetingId) { + const { poll } = body; + + const CHAT_CONFIG = Meteor.settings.public.chat; + const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; + const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid; + const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result; + const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system; + + const pollResultData = poll; + const answers = pollResultData.answers.reduce(caseInsensitiveReducer, []); + + const extra = { + type: 'poll', + pollResultData: { + ...pollResultData, + answers, + }, + }; + + const payload = { + id: `${SYSTEM_CHAT_TYPE}-${CHAT_POLL_RESULTS_MESSAGE}`, + timestamp: Date.now(), + correlationId: `${PUBLIC_CHAT_SYSTEM_ID}-${Date.now()}`, + sender: { + id: PUBLIC_CHAT_SYSTEM_ID, + name: '', + }, + message: '', + extra, + }; + + return addSystemMsg(meetingId, PUBLIC_GROUP_CHAT_ID, payload); +} diff --git a/src/2.7.12/imports/api/polls/server/handlers/userResponded.js b/src/2.7.12/imports/api/polls/server/handlers/userResponded.js new file mode 100644 index 00000000..3b37ad2c --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/userResponded.js @@ -0,0 +1,34 @@ +import { check } from 'meteor/check'; +import Polls from '/imports/api/polls'; +import Logger from '/imports/startup/server/logger'; + +export default async function userResponded({ body }) { + const { pollId, userId, answerIds } = body; + + check(pollId, String); + check(userId, String); + check(answerIds, Array); + + const selector = { + id: pollId, + }; + + const modifier = { + $pull: { + users: userId, + }, + $push: { + responses: { userId, answerIds }, + }, + }; + + try { + const numberAffected = await Polls.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Updating Poll response (userId: ${userId}, response: ${JSON.stringify(answerIds)}, pollId: ${pollId})`); + } + } catch (err) { + Logger.error(`Updating Poll responses: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js b/src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js new file mode 100644 index 00000000..84c15354 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js @@ -0,0 +1,34 @@ +import { check } from 'meteor/check'; +import Polls from '/imports/api/polls'; +import RedisPubSub from '/imports/startup/server/redis'; + +export default async function userTypedResponse({ header, body }) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'RespondToPollReqMsg'; + + const { pollId, userId, answer } = body; + const { meetingId } = header; + + check(pollId, String); + check(meetingId, String); + check(userId, String); + check(answer, String); + + const poll = await Polls.findOneAsync({ meetingId, id: pollId }); + + let answerId = 0; + poll.answers.forEach((a) => { + const { id, key } = a; + if (key === answer) answerId = id; + }); + + const payload = { + requesterId: userId, + pollId, + questionId: 0, + answerIds: [answerId], + }; + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); +} diff --git a/src/2.7.12/imports/api/polls/server/handlers/userVoted.js b/src/2.7.12/imports/api/polls/server/handlers/userVoted.js new file mode 100644 index 00000000..7c9b7397 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/handlers/userVoted.js @@ -0,0 +1,26 @@ +import { check } from 'meteor/check'; +import updateVotes from '../modifiers/updateVotes'; + +export default async function userVoted({ body }, meetingId) { + const { poll } = body; + + check(meetingId, String); + check(poll, { + id: String, + questionType: String, + questionText: String, + answers: [ + { + id: Number, + key: String, + numVotes: Number, + }, + ], + numRespondents: Number, + numResponders: Number, + }); + + const result = await updateVotes(poll, meetingId); + + return result; +} diff --git a/src/2.7.12/imports/api/polls/server/index.js b/src/2.7.12/imports/api/polls/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/polls/server/methods.js b/src/2.7.12/imports/api/polls/server/methods.js new file mode 100644 index 00000000..57d7a082 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/methods.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; +import publishTypedVote from './methods/publishTypedVote'; +import publishVote from './methods/publishVote'; +import publishPoll from './methods/publishPoll'; +import startPoll from './methods/startPoll'; +import stopPoll from './methods/stopPoll'; + +Meteor.methods({ + publishVote, + publishTypedVote, + publishPoll, + startPoll, + stopPoll, +}); diff --git a/src/2.7.12/imports/api/polls/server/methods/publishPoll.js b/src/2.7.12/imports/api/polls/server/methods/publishPoll.js new file mode 100644 index 00000000..2854ea01 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/methods/publishPoll.js @@ -0,0 +1,37 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import Polls from '/imports/api/polls'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +export default async function publishPoll() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ShowPollResultReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const poll = await Polls.findOneAsync({ meetingId }); // TODO--send pollid from client + if (!poll) { + Logger.error(`Attempted to publish inexisting poll for meetingId: ${meetingId}`); + return false; + } + + RedisPubSub.publishUserMessage( + CHANNEL, + EVENT_NAME, + meetingId, + requesterUserId, + ({ requesterId: requesterUserId, pollId: poll.id }), + ); + } catch (err) { + Logger.error(`Exception while invoking method publishPoll ${err.stack}`); + } + //In this case we return true because + //lint asks for async functions to return some value. + return true; +} diff --git a/src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js b/src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js new file mode 100644 index 00000000..b2e10e0b --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js @@ -0,0 +1,79 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import Polls from '/imports/api/polls'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function publishTypedVote(id, pollAnswer) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const MAX_INPUT_CHARS = Meteor.settings.public.poll.maxTypedAnswerLength; + let EVENT_NAME = 'RespondToTypedPollReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(pollAnswer, String); + check(id, String); + + const allowedToVote = await Polls.findOneAsync({ + id, + users: { $in: [requesterUserId] }, + meetingId, + }, { + fields: { + users: 1, + }, + }); + + if (!allowedToVote) { + Logger.info(`Poll User={${requesterUserId}} has already voted in PollId={${id}}`); + return null; + } + + const activePoll = await Polls.findOneAsync({ meetingId, id }, { + fields: { + answers: 1, + }, + }); + + let existingAnsId = null; + activePoll.answers.forEach((a) => { + if (a.key === pollAnswer) existingAnsId = a.id; + }); + + if (existingAnsId !== null) { + check(existingAnsId, Number); + EVENT_NAME = 'RespondToPollReqMsg'; + + return RedisPubSub.publishUserMessage( + CHANNEL, + EVENT_NAME, + meetingId, + requesterUserId, + { + requesterId: requesterUserId, + pollId: id, + questionId: 0, + answerIds: [existingAnsId], + }, + ); + } + + const payload = { + requesterId: requesterUserId, + pollId: id, + questionId: 0, + answer: pollAnswer.substring(0, MAX_INPUT_CHARS), + }; + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method publishTypedVote ${err.stack}`); + } + //In this case we return true because + //lint asks for async functions to return some value. + return true; +} diff --git a/src/2.7.12/imports/api/polls/server/methods/publishVote.js b/src/2.7.12/imports/api/polls/server/methods/publishVote.js new file mode 100644 index 00000000..e079a5d4 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/methods/publishVote.js @@ -0,0 +1,71 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import Polls from '/imports/api/polls'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default async function publishVote(pollId, pollAnswerIds) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'RespondToPollReqMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(pollAnswerIds, Array); + check(pollId, String); + + const allowedToVote = await Polls.findOneAsync({ + id: pollId, + users: { $in: [requesterUserId] }, + meetingId, + }, { + fields: { + users: 1, + }, + }); + + if (!allowedToVote) { + Logger.info(`Poll User={${requesterUserId}} has already voted in PollId={${pollId}}`); + return null; + } + + const selector = { + users: requesterUserId, + meetingId, + 'answers.id': { $all: pollAnswerIds }, + }; + + const payload = { + requesterId: requesterUserId, + pollId, + questionId: 0, + answerIds: pollAnswerIds, + }; + + /* + We keep an array of people who were in the meeting at the time the poll + was started. The poll is published to them only. + Once they vote - their ID is removed and they cannot see the poll anymore + */ + const modifier = { + $pull: { + users: requesterUserId, + }, + }; + + const numberAffected = await Polls.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Removed responded user=${requesterUserId} from poll (meetingId: ${meetingId}, pollId: ${pollId}!)`); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } + } catch (err) { + Logger.error(`Exception while invoking method publishVote ${err.stack}`); + } + //In this case we return true because + //lint asks for async functions to return some value. + return true; +} diff --git a/src/2.7.12/imports/api/polls/server/methods/startPoll.js b/src/2.7.12/imports/api/polls/server/methods/startPoll.js new file mode 100644 index 00000000..6aacb997 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/methods/startPoll.js @@ -0,0 +1,39 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function startPoll(pollTypes, pollType, pollId, secretPoll, question, isMultipleResponse, answers) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + let EVENT_NAME = 'StartPollReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(pollId, String); + check(pollType, String); + check(secretPoll, Boolean); + + const payload = { + requesterId: requesterUserId, + pollId: `${pollId}/${new Date().getTime()}`, + pollType, + secretPoll, + question, + isMultipleResponse, + }; + + if (pollType === pollTypes.Custom) { + EVENT_NAME = 'StartCustomPollReqMsg'; + check(answers, Array); + payload.answers = answers; + } + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method startPoll ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/polls/server/methods/stopPoll.js b/src/2.7.12/imports/api/polls/server/methods/stopPoll.js new file mode 100644 index 00000000..7853a7bc --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/methods/stopPoll.js @@ -0,0 +1,21 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function stopPoll() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StopPollReqMsg'; + + try { + const { meetingId, requesterUserId: requesterId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterId, String); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterId, ({ requesterId })); + } catch (err) { + Logger.error(`Exception while invoking method stopPoll ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/polls/server/modifiers/addPoll.js b/src/2.7.12/imports/api/polls/server/modifiers/addPoll.js new file mode 100644 index 00000000..7984987a --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/modifiers/addPoll.js @@ -0,0 +1,56 @@ +import Users from '/imports/api/users'; +import Polls from '/imports/api/polls'; +import Logger from '/imports/startup/server/logger'; +import flat from 'flat'; +import { check } from 'meteor/check'; + +export default async function addPoll(meetingId, requesterId, poll, pollType, secretPoll, question = '') { + check(requesterId, String); + check(meetingId, String); + check(poll, { + id: String, + answers: [ + { + id: Number, + key: String, + }, + ], + isMultipleResponse: Boolean, + }); + + const userSelector = { + meetingId, + userId: { $ne: requesterId }, + clientType: { $ne: 'dial-in-user' }, + }; + + const users = await Users.find(userSelector, { fields: { userId: 1 } }) + .fetchAsync(); + const userIds = users.map(user => user.userId); + + const selector = { + meetingId, + requester: requesterId, + id: poll.id, + }; + + const modifier = Object.assign( + { meetingId }, + { requester: requesterId }, + { users: userIds }, + { question, pollType, secretPoll }, + flat(poll, { safe: true }), + ); + + try { + const { insertedId } = await Polls.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added Poll id=${poll.id}`); + } else { + Logger.info(`Upserted Poll id=${poll.id}`); + } + } catch (err) { + Logger.error(`Adding Poll to collection: ${poll.id}`); + } +} diff --git a/src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js b/src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js new file mode 100644 index 00000000..f1e74fdd --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js @@ -0,0 +1,26 @@ +import Polls from '/imports/api/polls'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearPolls(meetingId) { + if (meetingId) { + try { + const numberAffected = await Polls.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared Polls (${meetingId})`); + } + } catch (err) { + Logger.info(`Error on clearing Polls (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await Polls.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared Polls (all)'); + } + } catch (err) { + Logger.info(`Error on clearing Polls (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/polls/server/modifiers/removePoll.js b/src/2.7.12/imports/api/polls/server/modifiers/removePoll.js new file mode 100644 index 00000000..b5133edd --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/modifiers/removePoll.js @@ -0,0 +1,23 @@ +import Polls from '/imports/api/polls'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default async function removePoll(meetingId, id) { + check(meetingId, String); + check(id, String); + + const selector = { + meetingId, + id, + }; + + try { + const numberAffected = await Polls.removeAsync(selector); + + if (numberAffected) { + Logger.info(`Removed Poll id=${id}`); + } + } catch (err) { + Logger.error(`Removing Poll from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js b/src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js new file mode 100644 index 00000000..0352a283 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js @@ -0,0 +1,41 @@ +import Polls from '/imports/api/polls'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import flat from 'flat'; + +export default async function updateVotes(poll, meetingId) { + check(meetingId, String); + check(poll, Object); + + const { + id, + answers, + numResponders, + numRespondents, + } = poll; + + check(id, String); + check(answers, Array); + + check(numResponders, Number); + check(numRespondents, Number); + + const selector = { + meetingId, + id, + }; + + const modifier = { + $set: flat(poll, { safe: true }), + }; + + try { + const numberAffected = await Polls.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Updating Polls collection vote (meetingId: ${meetingId}, pollId: ${id}!)`); + } + } catch (err) { + Logger.error(`Updating Polls collection vote: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/polls/server/publishers.js b/src/2.7.12/imports/api/polls/server/publishers.js new file mode 100644 index 00000000..3bd10f52 --- /dev/null +++ b/src/2.7.12/imports/api/polls/server/publishers.js @@ -0,0 +1,146 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import Polls from '/imports/api/polls'; +import AuthTokenValidation, { + ValidationStates, +} from '/imports/api/auth-token-validation'; +import { DDPServer } from 'meteor/ddp-server'; +import { publicationSafeGuard } from '/imports/api/common/server/helpers'; + +Meteor.server.setPublicationStrategy('polls', DDPServer.publicationStrategies.NO_MERGE); + +async function currentPoll(secretPoll) { + check(secretPoll, Boolean); + const tokenValidation = await AuthTokenValidation.findOneAsync({ + connectionId: this.connection.id, + }); + + if ( + !tokenValidation + || tokenValidation.validationStatus !== ValidationStates.VALIDATED + ) { + Logger.warn( + `Publishing Polls was requested by unauth connection ${this.connection.id}`, + ); + + return Polls.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + const User = await Users.findOneAsync({ userId, meetingId }, + { fields: { role: 1, presenter: 1 } }); + + if (!!User && User.presenter) { + Logger.debug('Publishing Polls', { meetingId, userId }); + + const selector = { + meetingId, + requester: userId, + }; + + const options = { fields: {} }; + + const hasPoll = await Polls.findOneAsync(selector); + + if ((hasPoll && hasPoll.secretPoll) || secretPoll) { + options.fields.responses = 0; + selector.secretPoll = true; + } else { + selector.secretPoll = false; + } + Mongo.Collection._publishCursor(Polls.find(selector, options), this, 'current-poll'); + return this.ready(); + } + + Logger.warn( + 'Publishing current-poll was requested by non-presenter connection', + { meetingId, userId, connectionId: this.connection.id }, + ); + Mongo.Collection._publishCursor(Polls.find({ meetingId: '' }), this, 'current-poll'); + return this.ready(); +} + +function publishCurrentPoll(...args) { + const boundPolls = currentPoll.bind(this); + return boundPolls(...args); +} + +Meteor.publish('current-poll', publishCurrentPoll); + +async function polls() { + const tokenValidation = await AuthTokenValidation.findOneAsync({ + connectionId: this.connection.id, + }); + + if ( + !tokenValidation + || tokenValidation.validationStatus !== ValidationStates.VALIDATED + ) { + Logger.warn( + `Publishing Polls was requested by unauth connection ${this.connection.id}`, + ); + return Polls.find({ meetingId: '' }); + } + + const options = { + fields: { + 'answers.numVotes': 0, + responses: 0, + }, + }; + + const noKeyOptions = { + fields: { + 'answers.numVotes': 0, + 'answers.key': 0, + responses: 0, + }, + }; + + const { meetingId, userId } = tokenValidation; + const User = await Users.findOneAsync({ userId, meetingId }, + { fields: { role: 1, presenter: 1 } }); + + Logger.debug('Publishing polls', { meetingId, userId }); + + const selector = { + meetingId, + users: userId, + }; + + if (User) { + const poll = await Polls.findOneAsync(selector, noKeyOptions); + if (User.presenter || poll?.pollType !== 'R-') { + // Monitor this publication and stop it when user is not a presenter anymore + // or poll type has changed + const comparisonFunc = async () => { + const user = await Users.findOneAsync({ userId, meetingId }, + { fields: { role: 1, userId: 1 } }); + const currentPoll = await Polls.findOneAsync(selector, noKeyOptions); + + const condition = user.presenter || currentPoll?.pollType !== 'R-'; + + if (!condition) { + Logger.info(`conditions aren't filled anymore in publication ${this._name}: + user.presenter || currentPoll?.pollType !== 'R-' :${condition}, user.presenter: ${user.presenter} pollType: ${currentPoll?.pollType}`); + } + + return condition; + }; + publicationSafeGuard(comparisonFunc, this); + return Polls.find(selector, options); + } + } + + return Polls.find(selector, noKeyOptions); +} + +function publish(...args) { + const boundPolls = polls.bind(this); + return boundPolls(...args); +} + +Meteor.publish('polls', publish); diff --git a/src/2.7.12/imports/api/presentation-pods/index.js b/src/2.7.12/imports/api/presentation-pods/index.js new file mode 100644 index 00000000..dc6479c9 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/index.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const PresentationPods = new Mongo.Collection('presentation-pods', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the presentation pods: + // 1. meetingId, podId ( 4 ) + + PresentationPods.createIndexAsync({ meetingId: 1, podId: 1 }); +} + +export default PresentationPods; diff --git a/src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js b/src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js new file mode 100644 index 00000000..b1bcd25a --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js @@ -0,0 +1,10 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleCreateNewPresentationPod from './handlers/createNewPresentationPod'; +import handleRemovePresentationPod from './handlers/removePresentationPod'; +import handleSyncGetPresentationPods from './handlers/syncGetPresentationPods'; +import handleSetPresenterInPod from './handlers/setPresenterInPod'; + +RedisPubSub.on('CreateNewPresentationPodEvtMsg', handleCreateNewPresentationPod); +RedisPubSub.on('RemovePresentationPodEvtMsg', handleRemovePresentationPod); +RedisPubSub.on('SetPresenterInPodRespMsg', handleSetPresenterInPod); +RedisPubSub.on('SyncGetPresentationPodsRespMsg', handleSyncGetPresentationPods); diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js new file mode 100644 index 00000000..56d57ed3 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js @@ -0,0 +1,19 @@ +import { check } from 'meteor/check'; +import addPresentationPod from '../modifiers/addPresentationPod'; + +export default async function handleCreateNewPresentationPod({ body }, meetingId) { + check(body, { + currentPresenterId: String, + podId: String, + }); + check(meetingId, String); + + const { currentPresenterId, podId } = body; + + const pod = { + currentPresenterId, + podId, + }; + + await addPresentationPod(meetingId, pod); +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js new file mode 100644 index 00000000..ce15a283 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import removePresentationPod from '../modifiers/removePresentationPod'; + +export default async function handleRemovePresentationPod({ body }, meetingId) { + check(body, Object); + check(meetingId, String); + + const { podId } = body; + + check(podId, String); + + await removePresentationPod(meetingId, podId); +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js new file mode 100644 index 00000000..71c113d8 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import setPresenterInPod from '../modifiers/setPresenterInPod'; + +export default async function handleSetPresenterInPod({ body }, meetingId) { + check(body, Object); + + const { podId, nextPresenterId } = body; + + check(podId, String); + check(nextPresenterId, String); + + await setPresenterInPod(meetingId, podId, nextPresenterId); +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js new file mode 100644 index 00000000..123158f7 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import PresentationPods from '/imports/api/presentation-pods'; +import removePresentationPod from '../modifiers/removePresentationPod'; +import addPresentationPod from '../modifiers/addPresentationPod'; + +export default async function handleSyncGetPresentationPods({ body }, meetingId) { + check(body, Object); + check(meetingId, String); + + const { pods } = body; + check(pods, Array); + + const presentationPodIds = pods.map((pod) => pod.id); + + const presentationPodsToRemove = await PresentationPods.find({ + meetingId, + podId: { $nin: presentationPodIds }, + }, { fields: { podId: 1 } }).fetchAsync(); + + presentationPodsToRemove.forEach(async (p) => removePresentationPod(meetingId, p.podId)); + + pods.forEach(async (pod) => { + // 'podId' and 'currentPresenterId' for some reason called 'id' and 'currentPresenter' + // in this message + const { + id: podId, + currentPresenter: currentPresenterId, + presentations, + } = pod; + await addPresentationPod(meetingId, { podId, currentPresenterId }, presentations); + }); +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/index.js b/src/2.7.12/imports/api/presentation-pods/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/presentation-pods/server/methods.js b/src/2.7.12/imports/api/presentation-pods/server/methods.js new file mode 100644 index 00000000..e69de29b diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js new file mode 100644 index 00000000..cfabffc7 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js @@ -0,0 +1,46 @@ +import { Match, check } from 'meteor/check'; +import PresentationPods from '/imports/api/presentation-pods'; +import Logger from '/imports/startup/server/logger'; +import addPresentation from '/imports/api/presentations/server/modifiers/addPresentation'; + +// 'presentations' is passed down here when we receive a Sync message +// and it's not used when we just create a new presentation pod +export default async function addPresentationPod(meetingId, pod, presentations = undefined) { + check(meetingId, String); + check(presentations, Match.Maybe(Array)); + check(pod, { + currentPresenterId: String, + podId: String, + }); + + const { currentPresenterId, podId } = pod; + + const selector = { + meetingId, + podId, + }; + + const modifier = { + meetingId, + podId, + currentPresenterId, + }; + + try { + const { insertedId } = await PresentationPods.upsertAsync(selector, modifier); + + // if it's a Sync message - continue adding the attached presentations + if (presentations) { + presentations.forEach(async (presentation) => + addPresentation(meetingId, podId, presentation)); + } + + if (insertedId) { + Logger.info(`Added presentation pod id=${podId} meeting=${meetingId}`); + } else { + Logger.info(`Upserted presentation pod id=${podId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding presentation pod to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js new file mode 100644 index 00000000..ff1a6ef0 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js @@ -0,0 +1,32 @@ +import PresentationPods from '/imports/api/presentation-pods'; +import Logger from '/imports/startup/server/logger'; +import clearPresentations from '/imports/api/presentations/server/modifiers/clearPresentations'; +import clearPresentationUploadToken from '/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken'; + +export default async function clearPresentationPods(meetingId) { + if (meetingId) { + try { + const numberAffected = await PresentationPods.removeAsync({ meetingId }); + + if (numberAffected) { + await clearPresentations(meetingId); + await clearPresentationUploadToken(meetingId); + Logger.info(`Cleared Presentations Pods (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing Presentations Pods (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await PresentationPods.removeAsync({}); + + if (numberAffected) { + await clearPresentations(); + await clearPresentationUploadToken(); + Logger.info('Cleared Presentations Pods (all)'); + } + } catch (err) { + Logger.error(`Error on clearing Presentations Pods (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js new file mode 100644 index 00000000..4853f237 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js @@ -0,0 +1,27 @@ +import { check } from 'meteor/check'; +import PresentationPods from '/imports/api/presentation-pods'; +import Logger from '/imports/startup/server/logger'; +import clearPresentations from '/imports/api/presentations/server/modifiers/clearPresentations'; +import clearPresentationUploadToken from '/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken'; + +export default async function removePresentationPod(meetingId, podId) { + check(meetingId, String); + check(podId, String); + + const selector = { + meetingId, + podId, + }; + + try { + const numberAffected = await PresentationPods.removeAsync(selector); + + if (numberAffected && podId) { + Logger.info(`Removed presentation pod id=${podId} meeting=${meetingId}`); + clearPresentations(meetingId, podId); + clearPresentationUploadToken(meetingId, podId); + } + } catch (err) { + Logger.error(`Error on removing presentation pod from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js new file mode 100644 index 00000000..070d2e24 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import PresentationPods from '/imports/api/presentation-pods'; +import Logger from '/imports/startup/server/logger'; + +export default async function setPresenterInPod(meetingId, podId, nextPresenterId) { + check(meetingId, String); + check(podId, String); + check(nextPresenterId, String); + + const selector = { + meetingId, + podId, + }; + + const modifier = { + $set: { + currentPresenterId: nextPresenterId, + }, + }; + + try { + const { numberAffected } = await PresentationPods.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Set a new presenter in pod id=${podId} meeting=${meetingId} presenter=${nextPresenterId}`); + } + } catch (err) { + Logger.error(`Error on setting a presenter in pod: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-pods/server/publishers.js b/src/2.7.12/imports/api/presentation-pods/server/publishers.js new file mode 100644 index 00000000..870e8323 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-pods/server/publishers.js @@ -0,0 +1,26 @@ +import { Meteor } from 'meteor/meteor'; +import PresentationPods from '/imports/api/presentation-pods'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function presentationPods() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing PresentationPods was requested by unauth connection ${this.connection.id}`); + return PresentationPods.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + Logger.debug('Publishing presentation-pods', { meetingId, userId }); + + return PresentationPods.find({ meetingId }); +} + +function publish(...args) { + const boundPresentationPods = presentationPods.bind(this); + return boundPresentationPods(...args); +} + +Meteor.publish('presentation-pods', publish); diff --git a/src/2.7.12/imports/api/presentation-upload-token/index.js b/src/2.7.12/imports/api/presentation-upload-token/index.js new file mode 100644 index 00000000..4800f0a9 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/index.js @@ -0,0 +1,7 @@ +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const PresentationUploadToken = new Mongo.Collection('presentation-upload-token', collectionOptions); + +export default PresentationUploadToken; diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js b/src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js new file mode 100644 index 00000000..0979d6cc --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js @@ -0,0 +1,8 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers'; + +import handlePresentationUploadTokenPass from './handlers/presentationUploadTokenPass'; +import handlePresentationUploadTokenFail from './handlers/presentationUploadTokenFail'; + +RedisPubSub.on('PresentationUploadTokenPassRespMsg', processForHTML5ServerOnly(handlePresentationUploadTokenPass)); +RedisPubSub.on('PresentationUploadTokenFailRespMsg', processForHTML5ServerOnly(handlePresentationUploadTokenFail)); diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js new file mode 100644 index 00000000..34169321 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import PresentationUploadToken from '/imports/api/presentation-upload-token'; + +export default async function handlePresentationUploadTokenFail({ body, header }, meetingId) { + check(body, Object); + + const { userId } = header; + const { podId, filename } = body; + + check(userId, String); + check(podId, String); + check(filename, String); + + const selector = { + meetingId, + userId, + podId, + filename, + }; + + try { + const { numberAffected } = await PresentationUploadToken + .upsertAsync(selector, { failed: true, authzToken: null }); + + if (numberAffected) { + Logger.info(`Removing presentationToken filename=${filename} podId=${podId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Removing presentationToken from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js new file mode 100644 index 00000000..bac1f00a --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js @@ -0,0 +1,45 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import PresentationUploadToken from '/imports/api/presentation-upload-token'; + +export default async function handlePresentationUploadTokenPass({ body, header }, meetingId) { + check(body, Object); + + const { userId } = header; + const { podId, authzToken, filename, temporaryPresentationId } = body; + + check(userId, String); + check(podId, String); + check(authzToken, String); + check(filename, String); + check(temporaryPresentationId, String) + + const selector = { + meetingId, + podId, + userId, + filename, + temporaryPresentationId, + }; + + const modifier = { + meetingId, + podId, + userId, + filename, + authzToken, + temporaryPresentationId, + failed: false, + used: false, + }; + + try { + const { insertedId } = await PresentationUploadToken.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Inserting presentationToken filename=${filename} podId=${podId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Inserting presentationToken from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/index.js b/src/2.7.12/imports/api/presentation-upload-token/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/methods.js b/src/2.7.12/imports/api/presentation-upload-token/server/methods.js new file mode 100644 index 00000000..d417f740 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/methods.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; +import requestPresentationUploadToken from './methods/requestPresentationUploadToken'; +import setUsedToken from './methods/setUsedToken'; + +Meteor.methods({ + requestPresentationUploadToken, + setUsedToken, +}); diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js b/src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js new file mode 100644 index 00000000..fa70d9fb --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js @@ -0,0 +1,34 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function requestPresentationUploadToken( + podId, + filename, + temporaryPresentationId, +) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'PresentationUploadTokenReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(podId, String); + check(filename, String); + check(temporaryPresentationId, String); + + const payload = { + podId, + filename, + temporaryPresentationId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method requestPresentationUploadToken ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js b/src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js new file mode 100644 index 00000000..597d8643 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js @@ -0,0 +1,32 @@ +import PresentationUploadToken from '/imports/api/presentation-upload-token'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +export default async function setUsedToken(authzToken) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(authzToken, String); + + const payload = { + $set: { + used: true, + }, + }; + + const numberAffected = await PresentationUploadToken.updateAsync({ + meetingId, + userId: requesterUserId, + authzToken, + }, payload); + + if (numberAffected) { + Logger.info(`Token: ${authzToken} has been set as used in meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Exception while invoking method setUsedToken ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js b/src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js new file mode 100644 index 00000000..fdda7272 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js @@ -0,0 +1,44 @@ +import PresentationUploadToken from '/imports/api/presentation-upload-token'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearPresentationUploadToken( + meetingId, + podId, +) { + if (meetingId && podId) { + try { + const numberAffected = await PresentationUploadToken.removeAsync({ meetingId, podId }); + + if (numberAffected) { + Logger.info(`Cleared Presentations Upload Token (${meetingId}, ${podId})`); + return true; + } + } catch (err) { + Logger.info(`Error on clearing Presentations Upload Token (${meetingId}, ${podId}). ${err}`); + return false; + } + } + + if (meetingId) { + try { + const numberAffected = await PresentationUploadToken.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared Presentations Upload Token (${meetingId})`); + } + } catch (err) { + Logger.info(`Error on clearing Presentations Upload Token (${meetingId}). ${err}`); + } + } else { + try { + // clearing presentations for the whole server + const numberAffected = await PresentationUploadToken.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared Presentations Upload Token (all)'); + } + } catch (err) { + Logger.info(`Error on clearing Presentations Upload Token (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/publishers.js b/src/2.7.12/imports/api/presentation-upload-token/server/publishers.js new file mode 100644 index 00000000..63216ba2 --- /dev/null +++ b/src/2.7.12/imports/api/presentation-upload-token/server/publishers.js @@ -0,0 +1,40 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import PresentationUploadToken from '/imports/api/presentation-upload-token'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function presentationUploadToken(podId, filename, temporaryPresentationId) { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing PresentationUploadToken was requested by unauth connection ${this.connection.id}`); + return PresentationUploadToken.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + check(podId, String); + check(filename, String); + check(temporaryPresentationId, String); + + const selector = { + meetingId, + podId, + userId, + filename, + temporaryPresentationId, + }; + + Logger.debug('Publishing PresentationUploadToken', { meetingId, userId }); + + return PresentationUploadToken.find(selector); +} + +function publish(...args) { + const boundPresentationUploadToken = presentationUploadToken.bind(this); + return boundPresentationUploadToken(...args); +} + +Meteor.publish('presentation-upload-token', publish); diff --git a/src/2.7.12/imports/api/presentations/index.js b/src/2.7.12/imports/api/presentations/index.js new file mode 100644 index 00000000..9263cd55 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/index.js @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +export const UploadingPresentations = Meteor.isClient ? new Mongo.Collection("uploadingPresentations", collectionOptions) : null; +const Presentations = new Mongo.Collection('presentations', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the presentations: + // 1. meetingId, podId, id ( 3 ) + // 2. meetingId, id ( 1 ) + // 3. meetingId, id, current ( 2 ) + // 4. meetingId ( 1 ) + + Presentations.createIndexAsync({ meetingId: 1, podId: 1, id: 1 }); +} + +export default Presentations; diff --git a/src/2.7.12/imports/api/presentations/server/eventHandlers.js b/src/2.7.12/imports/api/presentations/server/eventHandlers.js new file mode 100644 index 00000000..b146c8af --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/eventHandlers.js @@ -0,0 +1,22 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handlePresentationAdded from './handlers/presentationAdded'; +import handlePresentationRemove from './handlers/presentationRemove'; +import handlePresentationCurrentSet from './handlers/presentationCurrentSet'; +import handlePresentationConversionUpdate from './handlers/presentationConversionUpdate'; +import handlePresentationDownloadableSet from './handlers/presentationDownloadableSet'; +import handlePresentationExport from './handlers/presentationExport'; +import handlePresentationExportToastUpdate from './handlers/presentationExportToastUpdate'; + +RedisPubSub.on('PdfConversionInvalidErrorEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationPageGeneratedEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationPageCountErrorEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationUploadedFileTimeoutErrorEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationHasInvalidMimeTypeErrorEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationConversionUpdateEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationUploadedFileTooLargeErrorEvtMsg', handlePresentationConversionUpdate); +RedisPubSub.on('PresentationConversionCompletedEvtMsg', handlePresentationAdded); +RedisPubSub.on('RemovePresentationEvtMsg', handlePresentationRemove); +RedisPubSub.on('SetCurrentPresentationEvtMsg', handlePresentationCurrentSet); +RedisPubSub.on('SetPresentationDownloadableEvtMsg', handlePresentationDownloadableSet); +RedisPubSub.on('NewPresFileAvailableEvtMsg', handlePresentationExport); +RedisPubSub.on('PresAnnStatusEvtMsg', handlePresentationExportToastUpdate); diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js new file mode 100644 index 00000000..b2d9c1d8 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; +import addPresentation from '../modifiers/addPresentation'; + +export default async function handlePresentationAdded({ body }, meetingId) { + check(body, Object); + + const { presentation, podId } = body; + + check(meetingId, String); + check(podId, String); + check(presentation, Object); + + await addPresentation(meetingId, podId, presentation); +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js new file mode 100644 index 00000000..996e5c6a --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js @@ -0,0 +1,137 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Presentations from '/imports/api/presentations'; + +// const OFFICE_DOC_CONVERSION_SUCCESS_KEY = 'OFFICE_DOC_CONVERSION_SUCCESS'; +const OFFICE_DOC_CONVERSION_FAILED_KEY = 'OFFICE_DOC_CONVERSION_FAILED'; +const OFFICE_DOC_CONVERSION_INVALID_KEY = 'OFFICE_DOC_CONVERSION_INVALID'; +const SUPPORTED_DOCUMENT_KEY = 'SUPPORTED_DOCUMENT'; +const UNSUPPORTED_DOCUMENT_KEY = 'UNSUPPORTED_DOCUMENT'; +const PAGE_COUNT_FAILED_KEY = 'PAGE_COUNT_FAILED'; +const PAGE_COUNT_EXCEEDED_KEY = 'PAGE_COUNT_EXCEEDED'; +const PDF_HAS_BIG_PAGE_KEY = 'PDF_HAS_BIG_PAGE'; +const GENERATED_SLIDE_KEY = 'GENERATED_SLIDE'; +const FILE_TOO_LARGE_KEY = 'FILE_TOO_LARGE'; +const CONVERSION_TIMEOUT_KEY = 'CONVERSION_TIMEOUT'; +const INVALID_MIME_TYPE_KEY = 'INVALID_MIME_TYPE'; +const NO_CONTENT = '204'; +// const GENERATING_THUMBNAIL_KEY = 'GENERATING_THUMBNAIL'; +// const GENERATED_THUMBNAIL_KEY = 'GENERATED_THUMBNAIL'; +// const GENERATING_TEXTFILES_KEY = 'GENERATING_TEXTFILES'; +// const GENERATED_TEXTFILES_KEY = 'GENERATED_TEXTFILES'; +// const GENERATING_SVGIMAGES_KEY = 'GENERATING_SVGIMAGES'; +// const GENERATED_SVGIMAGES_KEY = 'GENERATED_SVGIMAGES'; +// const CONVERSION_COMPLETED_KEY = 'CONVERSION_COMPLETED'; + +export default async function handlePresentationConversionUpdate({ body }, meetingId) { + check(body, Object); + + const { + presentationId, podId, messageKey: status, presName: presentationName, temporaryPresentationId, + } = body; + + check(meetingId, String); + check(presentationId, Match.Maybe(String)); + check(podId, Match.Maybe(String)); + check(status, String); + check(temporaryPresentationId, Match.Maybe(String)); + + const statusModifier = { + 'conversion.status': status, + 'conversion.error': false, + 'conversion.done': false, + }; + + switch (status) { + case SUPPORTED_DOCUMENT_KEY: + statusModifier.id = presentationId; + statusModifier.name = presentationName; + break; + + case FILE_TOO_LARGE_KEY: + statusModifier['conversion.maxFileSize'] = body.maxFileSize; + case UNSUPPORTED_DOCUMENT_KEY: + case OFFICE_DOC_CONVERSION_FAILED_KEY: + case INVALID_MIME_TYPE_KEY: + statusModifier['conversion.error'] = true; + statusModifier['conversion.fileMime'] = body.fileMime; + statusModifier['conversion.fileExtension'] = body.fileExtension; + case OFFICE_DOC_CONVERSION_INVALID_KEY: + case PAGE_COUNT_FAILED_KEY: + case PAGE_COUNT_EXCEEDED_KEY: + statusModifier['conversion.maxNumberPages'] = body.maxNumberPages; + case PDF_HAS_BIG_PAGE_KEY: + statusModifier.id = presentationId ?? body.presentationToken; + statusModifier.name = presentationName ?? body.presentationName; + statusModifier['conversion.error'] = true; + statusModifier['conversion.bigPageSize'] = body.bigPageSize; + break; + case CONVERSION_TIMEOUT_KEY: + statusModifier['conversion.error'] = true; + statusModifier['conversion.maxNumberOfAttempts'] = body.maxNumberOfAttempts; + statusModifier['conversion.numberPageError'] = body.page; + + break; + case GENERATED_SLIDE_KEY: + statusModifier['conversion.pagesCompleted'] = body.pagesCompleted; + statusModifier['conversion.numPages'] = body.numberOfPages; + break; + + case NO_CONTENT: + statusModifier['conversion.done'] = false; + statusModifier['conversion.error'] = true; + statusModifier.id = presentationId; + statusModifier.name = presentationName; + break; + + default: + break; + } + + const selector = { + meetingId, + podId, + id: presentationId ?? body.presentationToken, + }; + + let modifier + if (temporaryPresentationId){ + modifier = { + $set: Object.assign({ meetingId, podId, renderedInToast: false, temporaryPresentationId, }, statusModifier), + }; + } else { + modifier = { + $set: Object.assign({ meetingId, renderedInToast: false, podId }, statusModifier), + }; + } + + try { + const presentations = await Presentations.find(selector).fetchAsync(); + const isPresentationPersisted = await Promise.all(presentations.map(async (item) => { + if (item.temporaryPresentationId && temporaryPresentationId) { + return item.temporaryPresentationId === temporaryPresentationId; + } else { + return item.id === presentationId; + } + })); + const isPersisted = isPresentationPersisted.some((item) => item === true); + + let insertedID; + if (!isPersisted) { + const { insertedId } = await Presentations.upsertAsync(selector, modifier); + insertedID = insertedId; + } else { + selector['conversion.error'] = false; + const { insertedId } = await Presentations.updateAsync(selector, modifier); + insertedID = insertedId; + } + + if (insertedID) { + Logger.info(`Updated presentation conversion status=${status} id=${presentationId} meeting=${meetingId}`); + } else { + Logger.debug('Upserted presentation conversion', { status, presentationId, meetingId }); + } + } catch (err) { + Logger.error(`Updating conversion status presentation to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js new file mode 100644 index 00000000..d4b53ed0 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js @@ -0,0 +1,15 @@ +import { check } from 'meteor/check'; +import setCurrentPresentation from '../modifiers/setCurrentPresentation'; + +export default async function handlePresentationCurrentSet({ body }, meetingId) { + check(body, Object); + + const { presentationId, podId } = body; + + check(meetingId, String); + check(presentationId, String); + check(podId, String); + + const result = await setCurrentPresentation(meetingId, podId, presentationId); + return result; +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js new file mode 100644 index 00000000..67884b9e --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js @@ -0,0 +1,20 @@ +import { check } from 'meteor/check'; +import setPresentationDownloadable from '../modifiers/setPresentationDownloadable'; + +export default async function handlePresentationDownloadableSet({ body }, meetingId) { + check(body, Object); + + const { + presentationId, podId, downloadable, downloadableExtension, + } = body; + + check(meetingId, String); + check(presentationId, String); + check(podId, String); + check(downloadable, Boolean); + check(downloadableExtension, String); + + const result = await setPresentationDownloadable(meetingId, podId, presentationId, downloadable, + downloadableExtension); + return result; +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js new file mode 100644 index 00000000..985276c0 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js @@ -0,0 +1,42 @@ +import { check } from 'meteor/check'; +import sendExportedPresentationChatMsg from '/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg'; +import setPresentationExporting from '/imports/api/presentations/server/modifiers/setPresentationExporting'; +import setOriginalUriDownload from '/imports/api/presentations/server/modifiers/setOriginalUriDownload'; + +export default async function handlePresentationExport({ body }, meetingId) { + check(body, Object); + check(meetingId, String); + + const { + annotatedFileURI, + originalFileURI, + convertedFileURI, + presId, + fileStateType, + } = body; + + check(annotatedFileURI, String); + check(originalFileURI, String); + check(convertedFileURI, String); + check(presId, String); + check(fileStateType, String); + + if (fileStateType === 'Original' || fileStateType === 'Converted') { + if (fileStateType === 'Converted') { + await setOriginalUriDownload( + meetingId, + presId, + convertedFileURI, + ); + } else { + await setOriginalUriDownload( + meetingId, + presId, + originalFileURI, + ); + } + } else { + await sendExportedPresentationChatMsg(meetingId, presId, annotatedFileURI, fileStateType); + } + await setPresentationExporting(meetingId, presId, { status: 'EXPORTED' }); +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js new file mode 100644 index 00000000..ecb6b92f --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js @@ -0,0 +1,21 @@ +import { check } from 'meteor/check'; +import setPresentationExporting from '/imports/api/presentations/server/modifiers/setPresentationExporting'; + +export default async function handlePresentationExportToastUpdate({ body }, meetingId) { + check(body, Object); + check(meetingId, String); + + const { + presId, pageNumber, totalPages, status, error, + } = body; + + check(presId, String); + check(pageNumber, Number); + check(totalPages, Number); + check(status, String); + check(error, Boolean); + + await setPresentationExporting(meetingId, presId, { + pageNumber, totalPages, status, error, + }); +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js new file mode 100644 index 00000000..f77faabc --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; + +import removePresentation from '../modifiers/removePresentation'; + +export default async function handlePresentationRemove({ body }, meetingId) { + const { podId, presentationId } = body; + + check(meetingId, String); + check(podId, String); + check(presentationId, String); + + const result = await removePresentation(meetingId, podId, presentationId); + return result; +} diff --git a/src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js b/src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js new file mode 100644 index 00000000..e3ff491b --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js @@ -0,0 +1,37 @@ +import addSystemMsg from '/imports/api/group-chat-msg/server/modifiers/addSystemMsg'; +import Presentations from '/imports/api/presentations'; + +const DEFAULT_FILENAME = 'annotated_slides.pdf'; + +export default async function sendExportedPresentationChatMsg(meetingId, + presentationId, fileURI, fileStateType) { + const CHAT_CONFIG = Meteor.settings.public.chat; + const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; + const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid; + const CHAT_EXPORTED_PRESENTATION_MESSAGE = CHAT_CONFIG + .system_messages_keys.chat_exported_presentation; + const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system; + + const pres = await Presentations.findOneAsync({ meetingId, id: presentationId }); + + const extra = { + type: 'presentation', + fileURI, + filename: pres?.name || DEFAULT_FILENAME, + fileStateType, + }; + + const payload = { + id: `${SYSTEM_CHAT_TYPE}-${CHAT_EXPORTED_PRESENTATION_MESSAGE}`, + timestamp: Date.now(), + correlationId: `${PUBLIC_CHAT_SYSTEM_ID}-${Date.now()}`, + sender: { + id: PUBLIC_CHAT_SYSTEM_ID, + name: '', + }, + message: '', + extra, + }; + const result = await addSystemMsg(meetingId, PUBLIC_GROUP_CHAT_ID, payload); + return result; +} diff --git a/src/2.7.12/imports/api/presentations/server/index.js b/src/2.7.12/imports/api/presentations/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/presentations/server/methods.js b/src/2.7.12/imports/api/presentations/server/methods.js new file mode 100644 index 00000000..a99dbf40 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/methods.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; +import removePresentation from './methods/removePresentation'; +import setPresentationRenderedInToast from './methods/setPresentationRenderedInToast'; +import setPresentation from './methods/setPresentation'; +import setPresentationDownloadable from './methods/setPresentationDownloadable'; +import exportPresentation from './methods/exportPresentation'; + +Meteor.methods({ + removePresentation, + setPresentation, + setPresentationDownloadable, + exportPresentation, + setPresentationRenderedInToast, +}); diff --git a/src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js b/src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js new file mode 100644 index 00000000..70d5d458 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js @@ -0,0 +1,61 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; +import setPresentationExporting from '/imports/api/presentations/server/modifiers/setPresentationExporting'; +import Presentations from '/imports/api/presentations'; + +const EXPORTING_THRESHOLD_PER_SLIDE = 2500; + +export default async function exportPresentation(presentationId, fileStateType) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'MakePresentationDownloadReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(presentationId, String); + + const payload = { + presId: presentationId, + allPages: true, + fileStateType, + pages: [], + }; + + const setObserver = async () => { + let timeoutRef = null; + + const selector = { meetingId, id: presentationId }; + const cursor = Presentations.find(selector); + const pres = await Presentations.findOneAsync(selector); + const numPages = pres?.pages?.length ?? 1; + const threshold = EXPORTING_THRESHOLD_PER_SLIDE * numPages; + + const observer = cursor.observe({ + changed: (doc) => { + const { status } = doc.exportation; + + if (status === 'EXPORTED') { + Meteor.clearTimeout(timeoutRef); + } + }, + }); + + timeoutRef = Meteor.setTimeout(async () => { + observer.stop(); + await setPresentationExporting(meetingId, presentationId, { status: 'TIMEOUT' }); + }, threshold); + }; + + await setPresentationExporting(meetingId, presentationId, { status: 'RUNNING' }); + await setObserver(); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method exportPresentation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/methods/removePresentation.js b/src/2.7.12/imports/api/presentations/server/methods/removePresentation.js new file mode 100644 index 00000000..8a2ef1e8 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/methods/removePresentation.js @@ -0,0 +1,28 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function removePresentation(presentationId, podId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'RemovePresentationPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(presentationId, String); + check(podId, String); + + const payload = { + presentationId, + podId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method removePresentation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/methods/setPresentation.js b/src/2.7.12/imports/api/presentations/server/methods/setPresentation.js new file mode 100644 index 00000000..3ff16aee --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/methods/setPresentation.js @@ -0,0 +1,28 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function setPresentation(presentationId, podId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetCurrentPresentationPubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(presentationId, String); + check(podId, String); + + const payload = { + presentationId, + podId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setPresentation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js b/src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js new file mode 100644 index 00000000..1ad147ca --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js @@ -0,0 +1,31 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function setPresentationDownloadable(presentationId, downloadable, fileStateType) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetPresentationDownloadablePubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(downloadable, Match.Maybe(Boolean)); + check(presentationId, String); + check(fileStateType, Match.Maybe(String)); + + const payload = { + presentationId, + podId: 'DEFAULT_PRESENTATION_POD', + downloadable, + fileStateType, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setPresentationDownloadable ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js b/src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js new file mode 100644 index 00000000..6ef32720 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js @@ -0,0 +1,28 @@ +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +export default async function setPresentationRenderedInToast() { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + $set: { renderedInToast: true }, + }; + + const numberAffected = await Presentations.updateAsync({ + renderedInToast: false, + meetingId, + }, payload, {multi: true}); + + if (numberAffected) { + Logger.info(`Presentations have been set as rendered in the toast within meeting=${meetingId}, ${numberAffected} documents affected.`); + } + } catch (err) { + Logger.error(`Exception while invoking method setPresentationRenderedInToast ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js b/src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js new file mode 100644 index 00000000..06819c9c --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js @@ -0,0 +1,88 @@ +import axios from 'axios'; +import { check } from 'meteor/check'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; +import flat from 'flat'; +import addSlide from '/imports/api/slides/server/modifiers/addSlide'; +import setCurrentPresentation from './setCurrentPresentation'; + +const getSlideText = async (url) => { + let content = ''; + try { + const request = await axios(url); + content = request.data.toString(); + } catch (error) { + Logger.error(`No file found. ${error}`); + } + return content; +}; + +const addSlides = (meetingId, podId, presentationId, slides) => { + slides.forEach(async (slide) => { + const content = await getSlideText(slide.txtUri); + + Object.assign(slide, { content }); + + await addSlide(meetingId, podId, presentationId, slide); + }); +}; + +export default async function addPresentation(meetingId, podId, presentation) { + check(meetingId, String); + check(podId, String); + check(presentation, { + id: String, + name: String, + current: Boolean, + temporaryPresentationId: String, + pages: [ + { + id: String, + num: Number, + thumbUri: String, + txtUri: String, + svgUri: String, + current: Boolean, + xOffset: Number, + yOffset: Number, + widthRatio: Number, + heightRatio: Number, + }, + ], + downloadable: Boolean, + removable: Boolean, + isInitialPresentation: Boolean, + filenameConverted: String, + }); + + const selector = { + meetingId, + podId, + id: presentation.id, + }; + + const modifier = { + $set: Object.assign({ + meetingId, + podId, + 'conversion.done': true, + 'conversion.error': false, + 'exportation.status': null, + }, flat(presentation, { safe: true })), + }; + + try { + await Presentations.upsertAsync(selector, modifier); + + await addSlides(meetingId, podId, presentation.id, presentation.pages); + + if (presentation.current) { + setCurrentPresentation(meetingId, podId, presentation.id); + Logger.info(`Added presentation id=${presentation.id} meeting=${meetingId}`); + } else { + Logger.info(`Upserted presentation id=${presentation.id} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding presentation to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js b/src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js new file mode 100644 index 00000000..663b9d36 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js @@ -0,0 +1,44 @@ +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearPresentations(meetingId, podId) { + // clearing presentations for 1 pod + if (meetingId && podId) { + try { + const numberAffected = await Presentations.removeAsync({ meetingId, podId }); + + if (numberAffected) { + Logger.info(`Cleared Presentations (${meetingId}, ${podId})`); + return true; + } + } catch (err) { + Logger.error(`Error on cleaning Presentations (${meetingId}, ${podId}). ${err}`); + return false; + } + } + + // clearing presentations for the whole meeting + if (meetingId) { + try { + const numberAffected = await Presentations.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared Presentations (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on cleaning Presentations (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await Presentations.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared Presentations (all)'); + } + } catch (err) { + Logger.error(`Error on cleaning Presentations (all). ${err}`); + } + } + // Returned true because the function requires some value to be returned. + return true; +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js b/src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js new file mode 100644 index 00000000..46498c5b --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js @@ -0,0 +1,28 @@ +import { check } from 'meteor/check'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; + +import clearSlidesPresentation from '/imports/api/slides/server/modifiers/clearSlidesPresentation'; + +export default async function removePresentation(meetingId, podId, presentationId) { + check(meetingId, String); + check(presentationId, String); + check(podId, String); + + const selector = { + meetingId, + podId, + id: presentationId, + }; + + try { + const numberAffected = await Presentations.removeAsync(selector); + + if (numberAffected) { + await clearSlidesPresentation(meetingId, presentationId); + Logger.info(`Removed presentation id=${presentationId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Removing presentation from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js b/src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js new file mode 100644 index 00000000..6a735f6f --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js @@ -0,0 +1,70 @@ +import { check } from 'meteor/check'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; + +export default async function setCurrentPresentation(meetingId, podId, presentationId) { + check(meetingId, String); + check(presentationId, String); + check(podId, String); + + const oldCurrent = { + selector: { + meetingId, + podId, + current: true, + id: { + $ne: presentationId, + }, + }, + modifier: { + $set: { current: false }, + }, + callback: (err) => { + if (err) { + Logger.error(`Unsetting the current presentation: ${err}`); + return; + } + + Logger.info('Unsetted as current presentation'); + }, + }; + + const newCurrent = { + selector: { + meetingId, + podId, + id: presentationId, + }, + modifier: { + $set: { current: true }, + }, + callback: (err) => { + if (err) { + Logger.error(`Setting as current presentation id=${presentationId}: ${err}`); + return; + } + + Logger.info(`Setted as current presentation id=${presentationId}`); + }, + }; + + const oldPresentation = await Presentations.findOneAsync(oldCurrent.selector); + const newPresentation = await Presentations.findOneAsync(newCurrent.selector); +// We update it before unset current to avoid the case where theres no current presentation. + if (newPresentation) { + try{ + await Presentations.updateAsync(newPresentation._id, newCurrent.modifier); + } catch(e){ + newCurrent.callback(e); + } + } + + if (oldPresentation) { + try { + await Presentations.updateAsync(oldCurrent.selector, oldCurrent.modifier, { multi: true }); + } catch (e) { + oldCurrent.callback(e); + } + } + +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js b/src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js new file mode 100644 index 00000000..d00f1ddb --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; + +export default async function setOriginalUriDownload(meetingId, presentationId, fileURI) { + check(meetingId, String); + check(presentationId, String); + check(fileURI, String); + + const selector = { + meetingId, + id: presentationId, + }; + + const modifier = { + $set: { + originalFileURI: fileURI, + }, + }; + + try { + const { numberAffected } = await Presentations.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Set URI for file ${presentationId} in meeting ${meetingId} URI=${fileURI}`); + } + } catch (err) { + Logger.error(`Could not set URI for file ${presentationId} in meeting ${meetingId} ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js new file mode 100644 index 00000000..a7623fc0 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js @@ -0,0 +1,39 @@ +import { check } from 'meteor/check'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; + +export default async function setPresentationDownloadable(meetingId, podId, + presentationId, downloadable, extensionToBeDownloadable) { + check(meetingId, String); + check(presentationId, String); + check(podId, String); + check(downloadable, Boolean); + check(extensionToBeDownloadable, String); + + const selector = { + meetingId, + podId, + id: presentationId, + }; + + let downloadableExtension = extensionToBeDownloadable; + if (!downloadable) { + downloadableExtension = ''; + } + const modifier = { + $set: { + downloadable, + downloadableExtension, + }, + }; + + try { + const { numberAffected } = await Presentations.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Set downloadable status on presentation {${presentationId} in meeting {${meetingId}}`); + } + } catch (err) { + Logger.error(`Could not set downloadable on pres {${presentationId} in meeting {${meetingId}} ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js new file mode 100644 index 00000000..ec91f762 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; + +export default async function setPresentationExporting(meetingId, presentationId, exportation) { + check(meetingId, String); + check(presentationId, String); + check(exportation, Object); + + const selector = { + meetingId, + id: presentationId, + }; + + const modifier = { + $set: { + exportation, + }, + }; + + try { + const { numberAffected } = await Presentations.upsertAsync(selector, modifier); + + if (numberAffected && ['RUNNING', 'EXPORTED'].includes(exportation?.status)) { + Logger.info(`Set exporting status on presentation ${presentationId} in meeting ${meetingId} status=${exportation.status}`); + } + } catch (err) { + Logger.error(`Could not set exporting status on pres ${presentationId} in meeting ${meetingId} ${err}`); + } +} diff --git a/src/2.7.12/imports/api/presentations/server/publishers.js b/src/2.7.12/imports/api/presentations/server/publishers.js new file mode 100644 index 00000000..df2a8e47 --- /dev/null +++ b/src/2.7.12/imports/api/presentations/server/publishers.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import Presentations from '/imports/api/presentations'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function presentations() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Presentation was requested by unauth connection ${this.connection.id}`); + return Presentations.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing Presentations', { meetingId, userId }); + + return Presentations.find({ meetingId }); +} + +function publish(...args) { + const boundPresentations = presentations.bind(this); + return boundPresentations(...args); +} + +Meteor.publish('presentations', publish); diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/errors.js b/src/2.7.12/imports/api/screenshare/client/bridge/errors.js new file mode 100644 index 00000000..11339954 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/client/bridge/errors.js @@ -0,0 +1,59 @@ +import { + SFU_SERVER_SIDE_ERRORS +} from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors'; + +// Mapped getDisplayMedia errors. These are bridge agnostic +// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia +const GDM_ERRORS = { + // Fallback error: 1130 + 1130: 'GetDisplayMediaGenericError', + 1131: 'AbortError', + 1132: 'InvalidStateError', + 1133: 'OverconstrainedError', + 1134: 'TypeError', + 1135: 'NotFoundError', + 1136: 'NotAllowedError', + 1137: 'NotSupportedError', + 1138: 'NotReadableError', +}; + +// Import as many bridge specific errors you want in this utilitary and shove +// them into the error class slots down below. +const CLIENT_SIDE_ERRORS = { + 1101: "SIGNALLING_TRANSPORT_DISCONNECTED", + 1102: "SIGNALLING_TRANSPORT_CONNECTION_FAILED", + 1104: "SCREENSHARE_PLAY_FAILED", + 1105: "PEER_NEGOTIATION_FAILED", + 1107: "ICE_STATE_FAILED", + 1110: "ENDED_WHILE_STARTING", + 1120: "MEDIA_TIMEOUT", + 1121: "UNKNOWN_ERROR", +}; + +const SERVER_SIDE_ERRORS = { + ...SFU_SERVER_SIDE_ERRORS, +} + +const AGGREGATED_ERRORS = { + ...CLIENT_SIDE_ERRORS, + ...SERVER_SIDE_ERRORS, + ...GDM_ERRORS, +} + +const expandErrors = () => { + const expandedErrors = Object.keys(AGGREGATED_ERRORS).reduce((map, key) => { + map[AGGREGATED_ERRORS[key]] = { errorCode: key, errorMessage: AGGREGATED_ERRORS[key] }; + return map; + }, {}); + + return { ...AGGREGATED_ERRORS, ...expandedErrors }; +} + +const SCREENSHARING_ERRORS = expandErrors(); + +export { + GDM_ERRORS, + // All errors, [code]: [message] + // Expanded errors. It's AGGREGATED + message: { errorCode, errorMessage } + SCREENSHARING_ERRORS, +} diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/index.js b/src/2.7.12/imports/api/screenshare/client/bridge/index.js new file mode 100644 index 00000000..2188d631 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/client/bridge/index.js @@ -0,0 +1,5 @@ +import KurentoBridge from './kurento'; + +const screenshareBridge = new KurentoBridge(); + +export default screenshareBridge; diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/kurento.js b/src/2.7.12/imports/api/screenshare/client/bridge/kurento.js new file mode 100644 index 00000000..71d1aaa3 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/client/bridge/kurento.js @@ -0,0 +1,405 @@ +import Auth from '/imports/ui/services/auth'; +import logger from '/imports/startup/client/logger'; +import BridgeService from './service'; +import ScreenshareBroker from '/imports/ui/services/bbb-webrtc-sfu/screenshare-broker'; +import { setIsSharing, screenShareEndAlert, setOutputDeviceId } from '/imports/ui/components/screenshare/service'; +import { SCREENSHARING_ERRORS } from './errors'; +import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils'; +import MediaStreamUtils from '/imports/utils/media-stream-utils'; +import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service'; + +const SFU_CONFIG = Meteor.settings.public.kurento; +const SFU_URL = SFU_CONFIG.wsUrl; +const OFFERING = SFU_CONFIG.screenshare.subscriberOffering; +const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates; +const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs; +const { screenshare: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {}; +const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout; + +const BRIDGE_NAME = 'kurento' +const SCREENSHARE_VIDEO_TAG = 'screenshareVideo'; +const SEND_ROLE = 'send'; +const RECV_ROLE = 'recv'; +const DEFAULT_VOLUME = 1; + +// the error-code mapping is bridge specific; that's why it's not in the errors util +const ERROR_MAP = { + 1301: SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_DISCONNECTED, + 1302: SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_CONNECTION_FAILED, + 1305: SCREENSHARING_ERRORS.PEER_NEGOTIATION_FAILED, + 1307: SCREENSHARING_ERRORS.ICE_STATE_FAILED, + 1310: SCREENSHARING_ERRORS.ENDED_WHILE_STARTING, +}; + +const mapErrorCode = (error) => { + const { errorCode } = error; + const mappedError = ERROR_MAP[errorCode]; + + if (errorCode == null || mappedError == null) return error; + error.errorCode = mappedError.errorCode; + error.errorMessage = mappedError.errorMessage; + error.message = mappedError.errorMessage; + + return error; +} + +export default class KurentoScreenshareBridge { + constructor() { + this.role; + this.broker; + this._gdmStream; + this.hasAudio = false; + this.connectionAttempts = 0; + this.reconnecting = false; + this.reconnectionTimeout; + this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT; + this.startedOnce = false; + this.outputDeviceId = null; + } + + get gdmStream() { + return this._gdmStream; + } + + set gdmStream(stream) { + this._gdmStream = stream; + } + + _shouldReconnect() { + // Sender/presenter reconnect is *not* implemented yet + return this.reconnectionTimeout == null && this.role === RECV_ROLE; + } + + /** + * Get the RTCPeerConnection object related to the screensharing stream. + * @returns {Object} The RTCPeerConnection object related to the presenter/ + * viewer peer. If there's no stream being shared, returns + * null. + */ + getPeerConnection() { + try { + let peerConnection = null; + + if (this.broker && this.broker.webRtcPeer) { + peerConnection = this.broker.webRtcPeer.peerConnection; + } + + return peerConnection; + } catch (error) { + return null; + } + } + + inboundStreamReconnect() { + const currentRestartIntervalMs = this.restartIntervalMs; + + logger.warn({ + logCode: 'screenshare_viewer_reconnect', + extraInfo: { + reconnecting: this.reconnecting, + role: this.role, + bridge: BRIDGE_NAME, + }, + }, 'Screenshare viewer is reconnecting'); + + // Cleanly stop everything before triggering a reconnect + this._stop(); + // Create new reconnect interval time + this.restartIntervalMs = BridgeService.getNextReconnectionInterval(currentRestartIntervalMs); + this.view(this.hasAudio).then(() => { + this.clearReconnectionTimeout(); + }).catch((error) => { + // Error handling is a no-op because it will be "handled" in handleViewerFailure + logger.debug({ + logCode: 'screenshare_reconnect_failed', + extraInfo: { + errorCode: error.errorCode, + errorMessage: error.errorMessage, + reconnecting: this.reconnecting, + role: this.role, + bridge: BRIDGE_NAME + }, + }, 'Screensharing reconnect failed'); + }); + } + + handleConnectionTimeoutExpiry() { + this.reconnecting = true; + + switch (this.role) { + case RECV_ROLE: + return this.inboundStreamReconnect(); + + // Sender/presenter reconnect is *not* implemented yet + case SEND_ROLE: + default: + this.reconnecting = false; + logger.error({ + logCode: 'screenshare_wont_reconnect', + extraInfo: { + role: this.broker?.role || this.role, + started: !!(this.broker?.started), + bridge: BRIDGE_NAME, + }, + }, 'Screen sharing will not reconnect'); + break; + } + } + + maxConnectionAttemptsReached () { + return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS; + } + + scheduleReconnect({ + overrideTimeout, + } = { }) { + if (this.reconnectionTimeout == null) { + let nextRestartInterval = this.restartIntervalMs; + if (typeof overrideTimeout === 'number') nextRestartInterval = overrideTimeout; + + this.reconnectionTimeout = setTimeout( + this.handleConnectionTimeoutExpiry.bind(this), + nextRestartInterval, + ); + } + } + + clearReconnectionTimeout () { + this.reconnecting = false; + this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT; + + if (this.reconnectionTimeout) { + clearTimeout(this.reconnectionTimeout); + this.reconnectionTimeout = null; + } + } + + setVolume(volume) { + const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG); + + if (mediaElement) { + if (typeof volume === 'number' && volume >= 0 && volume <= 1) { + mediaElement.volume = volume; + } + + return mediaElement.volume; + } + + return DEFAULT_VOLUME; + } + + getVolume() { + const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG); + + if (mediaElement) return mediaElement.volume; + + return DEFAULT_VOLUME; + } + + handleViewerStart() { + const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG); + + if (mediaElement && this.broker && this.broker.webRtcPeer) { + const stream = this.broker.webRtcPeer.getRemoteStream(); + + if (this.hasAudio && this.outputDeviceId && typeof this.outputDeviceId === 'string') { + setOutputDeviceId(this.outputDeviceId); + } + + BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, !this.broker.hasAudio); + } + + this.startedOnce = true; + this.clearReconnectionTimeout(); + this.connectionAttempts = 0; + } + + handleBrokerFailure(error) { + mapErrorCode(error); + const { errorMessage, errorCode } = error; + + logger.error({ + logCode: 'screenshare_broker_failure', + extraInfo: { + errorCode, + errorMessage, + role: this.broker.role, + started: this.broker.started, + reconnecting: this.reconnecting, + bridge: BRIDGE_NAME, + }, + }, `Screenshare broker failure: ${errorMessage}`); + + notifyStreamStateChange('screenshare', 'failed'); + // Screensharing was already successfully negotiated and error occurred during + // during call; schedule a reconnect + if (this._shouldReconnect()) { + // this.broker.started => whether the reconnect should happen immediately. + // If this session previously established connection (N-sessions back) + // and it failed abruptly, then the timeout is overridden to a intermediate value + // (BASE_RECONNECTION_TIMEOUT) + let overrideTimeout; + if (this.broker?.started) { + overrideTimeout = 0; + } else if (this.startedOnce) { + overrideTimeout = BridgeService.BASE_RECONNECTION_TIMEOUT; + } + + this.scheduleReconnect({ overrideTimeout }); + } + + return error; + } + + async view(options = { + hasAudio: false, + outputDeviceId: null, + }) { + this.hasAudio = options.hasAudio; + this.outputDeviceId = options.outputDeviceId; + this.role = RECV_ROLE; + const iceServers = await BridgeService.getIceServers(Auth.sessionToken); + const brokerOptions = { + iceServers, + userName: Auth.fullname, + hasAudio: options.hasAudio, + offering: OFFERING, + mediaServer: BridgeService.getMediaServerAdapter(), + signalCandidates: SIGNAL_CANDIDATES, + forceRelay: shouldForceRelay(), + traceLogs: TRACE_LOGS, + gatheringTimeout: GATHERING_TIMEOUT, + }; + + this.broker = new ScreenshareBroker( + Auth.authenticateURL(SFU_URL), + BridgeService.getConferenceBridge(), + Auth.userID, + Auth.meetingID, + this.role, + brokerOptions, + ); + + this.broker.onstart = this.handleViewerStart.bind(this); + this.broker.onerror = this.handleBrokerFailure.bind(this); + if (!this.reconnecting) { + this.broker.onended = this.handleEnded.bind(this); + } + return this.broker.view().finally(this.scheduleReconnect.bind(this)); + } + + handlePresenterStart() { + logger.info({ + logCode: 'screenshare_presenter_start_success', + }, 'Screenshare presenter started succesfully'); + this.clearReconnectionTimeout(); + this.startedOnce = true; + this.reconnecting = false; + this.connectionAttempts = 0; + } + + handleEnded() { + screenShareEndAlert(); + } + + share(stream, onFailure, contentType) { + return new Promise(async (resolve, reject) => { + this.onerror = onFailure; + this.connectionAttempts += 1; + this.role = SEND_ROLE; + this.hasAudio = BridgeService.streamHasAudioTrack(stream); + this.gdmStream = stream; + + const onerror = (error) => { + const normalizedError = this.handleBrokerFailure(error); + if (!this.broker.started) { + // Broker hasn't started - if there are retries left, try again. + if (this.maxConnectionAttemptsReached()) { + this.clearReconnectionTimeout(); + this.connectionAttempts = 0; + onFailure(SCREENSHARING_ERRORS.MEDIA_TIMEOUT); + reject(SCREENSHARING_ERRORS.MEDIA_TIMEOUT); + } + } else if (!this._shouldReconnect()) { + // Broker has started - should reconnect? If it shouldn't, end it. + onFailure(normalizedError); + } + }; + + const iceServers = await BridgeService.getIceServers(Auth.sessionToken); + const options = { + iceServers, + userName: Auth.fullname, + stream, + hasAudio: this.hasAudio, + contentType: contentType, + bitrate: BridgeService.BASE_BITRATE, + offering: true, + mediaServer: BridgeService.getMediaServerAdapter(), + signalCandidates: SIGNAL_CANDIDATES, + forceRelay: shouldForceRelay(), + traceLogs: TRACE_LOGS, + networkPriority: NETWORK_PRIORITY, + gatheringTimeout: GATHERING_TIMEOUT, + }; + + this.broker = new ScreenshareBroker( + Auth.authenticateURL(SFU_URL), + BridgeService.getConferenceBridge(), + Auth.userID, + Auth.meetingID, + this.role, + options, + ); + + this.broker.onerror = onerror.bind(this); + this.broker.onstreamended = this.stop.bind(this); + this.broker.onstart = this.handlePresenterStart.bind(this); + this.broker.onended = this.handleEnded.bind(this); + + this.broker.share().then(() => { + this.scheduleReconnect(); + return resolve(); + }).catch((error) => reject(mapErrorCode(error))); + }); + } + + // This is a reconnect-safe internal method. Should be used when one wants + // to clear the internal components (ie broker, connection timeouts) without + // affecting externally controlled components (ie gDM stream, + // media tag, connectionAttempts, ...) + _stop() { + if (this.broker) { + this.broker.stop(); + // Checks if this session is a sharer and if it's not reconnecting + // If that's the case, clear the local sharing state in screen sharing UI + // component tracker to be extra sure we won't have any client-side state + // inconsistency - prlanzarin + if (this.broker && this.broker.role === SEND_ROLE && !this.reconnecting) { + setIsSharing(false); + } + this.broker = null; + } + + this.clearReconnectionTimeout(); + } + + stop() { + const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG); + + this._stop(); + this.connectionAttempts = 0; + + if (mediaElement && typeof mediaElement.pause === 'function') { + mediaElement.pause(); + mediaElement.srcObject = null; + } + + if (this.gdmStream) { + MediaStreamUtils.stopMediaStreamTracks(this.gdmStream); + this.gdmStream = null; + } + + this.outputDeviceId = null; + } +} diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/service.js b/src/2.7.12/imports/api/screenshare/client/bridge/service.js new file mode 100644 index 00000000..9216eb6a --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/client/bridge/service.js @@ -0,0 +1,164 @@ +import Meetings from '/imports/api/meetings'; +import logger from '/imports/startup/client/logger'; +import { fetchWebRTCMappedStunTurnServers, getMappedFallbackStun } from '/imports/utils/fetchStunTurnServers'; +import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play'; +import { SCREENSHARING_ERRORS } from './errors'; +import getFromMeetingSettings from '/imports/ui/services/meeting-settings'; + +const { + constraints: GDM_CONSTRAINTS, + mediaTimeouts: MEDIA_TIMEOUTS, + bitrate: BASE_BITRATE, + mediaServer: DEFAULT_SCREENSHARE_MEDIA_SERVER, +} = Meteor.settings.public.kurento.screenshare; +const { + baseTimeout: BASE_MEDIA_TIMEOUT, + maxTimeout: MAX_MEDIA_TIMEOUT, + maxConnectionAttempts: MAX_CONN_ATTEMPTS, + timeoutIncreaseFactor: TIMEOUT_INCREASE_FACTOR, + baseReconnectionTimeout: BASE_RECONNECTION_TIMEOUT, +} = MEDIA_TIMEOUTS; + +const HAS_DISPLAY_MEDIA = (typeof navigator.getDisplayMedia === 'function' + || (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function')); + +const getConferenceBridge = () => Meetings.findOne().voiceProp.voiceConf; + +const normalizeGetDisplayMediaError = (error) => { + return SCREENSHARING_ERRORS[error.name] || SCREENSHARING_ERRORS.GetDisplayMediaGenericError; +}; + +const getBoundGDM = () => { + if (typeof navigator.getDisplayMedia === 'function') { + return navigator.getDisplayMedia.bind(navigator); + } else if (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function') { + return navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); + } +} + +const getScreenStream = async () => { + const gDMCallback = (stream) => { + // Some older Chromium variants choke on gDM when audio: true by NOT generating + // a promise rejection AND not generating a valid input screen stream, need to + // work around that manually for now - prlanzarin + if (stream == null) { + return Promise.reject(SCREENSHARING_ERRORS.NotSupportedError); + } + + if (typeof stream.getVideoTracks === 'function' + && typeof GDM_CONSTRAINTS.video === 'object') { + stream.getVideoTracks().forEach(track => { + if (typeof track.applyConstraints === 'function') { + track.applyConstraints(GDM_CONSTRAINTS.video).catch(error => { + logger.warn({ + logCode: 'screenshare_videoconstraint_failed', + extraInfo: { errorName: error.name, errorCode: error.code }, + }, + 'Error applying screenshare video constraint'); + }); + } + }); + } + + if (typeof stream.getAudioTracks === 'function' + && typeof GDM_CONSTRAINTS.audio === 'object') { + stream.getAudioTracks().forEach(track => { + if (typeof track.applyConstraints === 'function') { + track.applyConstraints(GDM_CONSTRAINTS.audio).catch(error => { + logger.warn({ + logCode: 'screenshare_audioconstraint_failed', + extraInfo: { errorName: error.name, errorCode: error.code }, + }, 'Error applying screenshare audio constraint'); + }); + } + }); + } + + return Promise.resolve(stream); + }; + + const getDisplayMedia = getBoundGDM(); + + if (typeof getDisplayMedia === 'function') { + return getDisplayMedia(GDM_CONSTRAINTS) + .then(gDMCallback) + .catch(error => { + const normalizedError = normalizeGetDisplayMediaError(error); + logger.error({ + logCode: 'screenshare_getdisplaymedia_failed', + extraInfo: { errorCode: normalizedError.errorCode, errorMessage: normalizedError.errorMessage }, + }, 'getDisplayMedia call failed'); + return Promise.reject(normalizedError); + }); + } else { + // getDisplayMedia isn't supported, error its way out + return Promise.reject(SCREENSHARING_ERRORS.NotSupportedError); + } +}; + +const getIceServers = (sessionToken) => { + return fetchWebRTCMappedStunTurnServers(sessionToken).catch(error => { + logger.error({ + logCode: 'screenshare_fetchstunturninfo_error', + extraInfo: { error } + }, 'Screenshare bridge failed to fetch STUN/TURN info'); + return getMappedFallbackStun(); + }); +} + +const getMediaServerAdapter = () => { + return getFromMeetingSettings('media-server-screenshare', DEFAULT_SCREENSHARE_MEDIA_SERVER); +} + +const getNextReconnectionInterval = (oldInterval) => { + return Math.min( + (TIMEOUT_INCREASE_FACTOR * Math.max(oldInterval, BASE_RECONNECTION_TIMEOUT)), + MAX_MEDIA_TIMEOUT, + ); +} + +const streamHasAudioTrack = (stream) => { + return stream + && typeof stream.getAudioTracks === 'function' + && stream.getAudioTracks().length >= 1; +} + +const dispatchAutoplayHandlingEvent = (mediaElement) => { + const tagFailedEvent = new CustomEvent('screensharePlayFailed', + { detail: { mediaElement } }); + window.dispatchEvent(tagFailedEvent); +} + +const screenshareLoadAndPlayMediaStream = (stream, mediaElement, muted) => { + return loadAndPlayMediaStream(stream, mediaElement, muted).catch(error => { + // NotAllowedError equals autoplay issues, fire autoplay handling event. + // This will be handled in the screenshare react component. + if (error.name === 'NotAllowedError') { + logger.error({ + logCode: 'screenshare_error_autoplay', + extraInfo: { errorName: error.name }, + }, 'Screen share media play failed: autoplay error'); + dispatchAutoplayHandlingEvent(mediaElement); + } else { + throw { + errorCode: SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorCode, + errorMessage: error.message || SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorMessage, + }; + } + }); +} + +export default { + HAS_DISPLAY_MEDIA, + getConferenceBridge, + getScreenStream, + getIceServers, + getNextReconnectionInterval, + streamHasAudioTrack, + screenshareLoadAndPlayMediaStream, + getMediaServerAdapter, + BASE_MEDIA_TIMEOUT, + BASE_RECONNECTION_TIMEOUT, + MAX_CONN_ATTEMPTS, + BASE_BITRATE, +}; diff --git a/src/2.7.12/imports/api/screenshare/index.js b/src/2.7.12/imports/api/screenshare/index.js new file mode 100644 index 00000000..fc380b23 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/index.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Screenshare = new Mongo.Collection('screenshare', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the screenshare: + // 1. meetingId + + Screenshare.createIndexAsync({ meetingId: 1 }); +} + +export default Screenshare; diff --git a/src/2.7.12/imports/api/screenshare/server/eventHandlers.js b/src/2.7.12/imports/api/screenshare/server/eventHandlers.js new file mode 100644 index 00000000..ee75acab --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/eventHandlers.js @@ -0,0 +1,8 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleScreenshareStarted from './handlers/screenshareStarted'; +import handleScreenshareStopped from './handlers/screenshareStopped'; +import handleScreenshareSync from './handlers/screenshareSync'; + +RedisPubSub.on('ScreenshareRtmpBroadcastStartedEvtMsg', handleScreenshareStarted); +RedisPubSub.on('ScreenshareRtmpBroadcastStoppedEvtMsg', handleScreenshareStopped); +RedisPubSub.on('SyncGetScreenshareInfoRespMsg', handleScreenshareSync); diff --git a/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js new file mode 100644 index 00000000..44a4f931 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js @@ -0,0 +1,9 @@ +import { check } from 'meteor/check'; +import addScreenshare from '../modifiers/addScreenshare'; + +export default async function handleScreenshareStarted({ body }, meetingId) { + check(meetingId, String); + check(body, Object); + const result = await addScreenshare(meetingId, body); + return result; +} diff --git a/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js new file mode 100644 index 00000000..c2d262c9 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js @@ -0,0 +1,11 @@ +import { check } from 'meteor/check'; +import clearScreenshare from '../modifiers/clearScreenshare'; + +export default async function handleScreenshareStopped({ body }, meetingId) { + const { screenshareConf } = body; + + check(meetingId, String); + check(screenshareConf, String); + const result = await clearScreenshare(meetingId, screenshareConf); + return result; +} diff --git a/src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js new file mode 100644 index 00000000..0b55b85d --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js @@ -0,0 +1,20 @@ +import { check } from 'meteor/check'; +import addScreenshare from '../modifiers/addScreenshare'; +import clearScreenshare from '../modifiers/clearScreenshare'; + +export default async function handleScreenshareSync({ body }, meetingId) { + check(meetingId, String); + check(body, Object); + + const { isBroadcasting, screenshareConf } = body; + + check(screenshareConf, String); + check(isBroadcasting, Boolean); + + if (!isBroadcasting) { + const result = await clearScreenshare(meetingId, screenshareConf); + return result; + } + const result = await addScreenshare(meetingId, body); + return result; +} diff --git a/src/2.7.12/imports/api/screenshare/server/index.js b/src/2.7.12/imports/api/screenshare/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/screenshare/server/methods.js b/src/2.7.12/imports/api/screenshare/server/methods.js new file mode 100644 index 00000000..1ce65c36 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/methods.js @@ -0,0 +1,4 @@ +import { Meteor } from 'meteor/meteor'; + +Meteor.methods({ +}); diff --git a/src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js b/src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js new file mode 100644 index 00000000..ba41cdff --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js @@ -0,0 +1,29 @@ +import { check } from 'meteor/check'; +import flat from 'flat'; +import Logger from '/imports/startup/server/logger'; +import Screenshare from '/imports/api/screenshare'; + +export default async function addScreenshare(meetingId, body) { + check(meetingId, String); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + meetingId, + screenshare: flat(body), + }, + }; + + try { + const { numberAffected } = await Screenshare.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Upserted screenshare id=${body.screenshareConf}`); + } + } catch (err) { + Logger.error(`Adding screenshare to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js b/src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js new file mode 100644 index 00000000..c0cba5d4 --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js @@ -0,0 +1,22 @@ +import Logger from '/imports/startup/server/logger'; +import Screenshare from '/imports/api/screenshare'; + +export default async function clearScreenshare(meetingId, screenshareConf) { + try { + let numberAffected; + + if (meetingId && screenshareConf) { + numberAffected = await Screenshare.removeAsync({ meetingId, 'screenshare.screenshareConf': screenshareConf }); + } else if (meetingId) { + numberAffected = await Screenshare.removeAsync({ meetingId }); + } else { + numberAffected = await Screenshare.removeAsync({}); + } + + if (numberAffected) { + Logger.info(`removed screenshare meetingId=${meetingId} id=${screenshareConf}`); + } + } catch (err) { + Logger.error(`removing screenshare to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/screenshare/server/publishers.js b/src/2.7.12/imports/api/screenshare/server/publishers.js new file mode 100644 index 00000000..a073c9ee --- /dev/null +++ b/src/2.7.12/imports/api/screenshare/server/publishers.js @@ -0,0 +1,27 @@ +import Screenshare from '/imports/api/screenshare'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function screenshare() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Screenshare was requested by unauth connection ${this.connection.id}`); + return Screenshare.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing Screenshare', { meetingId, userId }); + + return Screenshare.find({ meetingId }); +} + +function publish(...args) { + const boundScreenshare = screenshare.bind(this); + return boundScreenshare(...args); +} + +Meteor.publish('screenshare', publish); diff --git a/src/2.7.12/imports/api/slides/index.js b/src/2.7.12/imports/api/slides/index.js new file mode 100644 index 00000000..e1acb162 --- /dev/null +++ b/src/2.7.12/imports/api/slides/index.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Slides = new Mongo.Collection('slides', collectionOptions); +const SlidePositions = new Mongo.Collection('slide-positions', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the slides: + + // 1. meetingId ( 1 ) + // 2. meetingId, podId ( 1 ) + // 3. meetingId, presentationId ( 1 ) + // 3. meetingId, presentationId, num ( 1 ) + // 4. meetingId, podId, presentationId, id ( 3 ) - incl. resizeSlide, which can be intense + // 5. meetingId, podId, presentationId, current ( 1 ) + + Slides.createIndexAsync({ + meetingId: 1, podId: 1, presentationId: 1, id: 1, + }); + + SlidePositions.createIndexAsync({ + meetingId: 1, podId: 1, presentationId: 1, id: 1, + }); +} + +export { Slides, SlidePositions }; diff --git a/src/2.7.12/imports/api/slides/server/eventHandlers.js b/src/2.7.12/imports/api/slides/server/eventHandlers.js new file mode 100644 index 00000000..b039b82f --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/eventHandlers.js @@ -0,0 +1,6 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleSlideResize from './handlers/slideResize'; +import handleSlideChange from './handlers/slideChange'; + +RedisPubSub.on('ResizeAndMovePageEvtMsg', handleSlideResize); +RedisPubSub.on('SetCurrentPageEvtMsg', handleSlideChange); diff --git a/src/2.7.12/imports/api/slides/server/handlers/slideChange.js b/src/2.7.12/imports/api/slides/server/handlers/slideChange.js new file mode 100644 index 00000000..9bff7998 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/handlers/slideChange.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import changeCurrentSlide from '../modifiers/changeCurrentSlide'; + +export default async function handleSlideChange({ body }, meetingId) { + const { pageId, presentationId, podId } = body; + + check(pageId, String); + check(presentationId, String); + check(podId, String); + + const result = await changeCurrentSlide(meetingId, podId, presentationId, pageId); + return result; +} diff --git a/src/2.7.12/imports/api/slides/server/handlers/slideResize.js b/src/2.7.12/imports/api/slides/server/handlers/slideResize.js new file mode 100644 index 00000000..2cb3d72c --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/handlers/slideResize.js @@ -0,0 +1,10 @@ +import { check } from 'meteor/check'; +import resizeSlide from '../modifiers/resizeSlide'; + +export default async function handleSlideResize({ body }, meetingId) { + check(meetingId, String); + check(body, Object); + + const result = await resizeSlide(meetingId, body); + return result; +} diff --git a/src/2.7.12/imports/api/slides/server/helpers.js b/src/2.7.12/imports/api/slides/server/helpers.js new file mode 100644 index 00000000..bf1ae4c1 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/helpers.js @@ -0,0 +1,27 @@ + +const calculateSlideData = (slideData) => { + const { + width, height, xOffset, yOffset, widthRatio, heightRatio, + } = slideData; + + // calculating viewBox and offsets for the current presentation + const maxImageWidth = 1440; + const maxImageHeight = 1080; + + const ratio = Math.min(maxImageWidth / width, maxImageHeight / height); + const scaledWidth = width * ratio; + const scaledHeight = height * ratio; + const scaledViewBoxWidth = width * widthRatio / 100 * ratio; + const scaledViewBoxHeight = height * heightRatio / 100 * ratio; + + return { + width: scaledWidth, + height: scaledHeight, + x: xOffset, + y: yOffset, + viewBoxWidth: scaledViewBoxWidth, + viewBoxHeight: scaledViewBoxHeight, + }; +}; + +export default calculateSlideData; diff --git a/src/2.7.12/imports/api/slides/server/index.js b/src/2.7.12/imports/api/slides/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/slides/server/methods.js b/src/2.7.12/imports/api/slides/server/methods.js new file mode 100644 index 00000000..e5616579 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/methods.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; +import switchSlide from './methods/switchSlide'; +import zoomSlide from './methods/zoomSlide'; + +Meteor.methods({ + switchSlide, + zoomSlide, +}); diff --git a/src/2.7.12/imports/api/slides/server/methods/switchSlide.js b/src/2.7.12/imports/api/slides/server/methods/switchSlide.js new file mode 100644 index 00000000..0753b943 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/methods/switchSlide.js @@ -0,0 +1,56 @@ +import Presentations from '/imports/api/presentations'; +import { Slides } from '/imports/api/slides'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function switchSlide(slideNumber, podId) { + // TODO-- send presentationId and SlideId + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetCurrentPagePubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(slideNumber, Number); + check(podId, String); + + const selector = { + meetingId, + podId, + current: true, + }; + + const Presentation = await Presentations.findOneAsync(selector); + + if (!Presentation) { + throw new Meteor.Error('presentation-not-found', 'You need a presentation to be able to switch slides'); + } + + const Slide = await Slides.findOneAsync({ + meetingId, + podId, + presentationId: Presentation.id, + num: slideNumber, + }); + + if (!Slide) { + throw new Meteor.Error('slide-not-found', `Slide number ${slideNumber} not found in the current presentation`); + } + + const payload = { + podId, + presentationId: Presentation.id, + pageId: Slide.id, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method switchSlide ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/slides/server/methods/zoomSlide.js b/src/2.7.12/imports/api/slides/server/methods/zoomSlide.js new file mode 100644 index 00000000..37313618 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/methods/zoomSlide.js @@ -0,0 +1,65 @@ +import Presentations from '/imports/api/presentations'; +import { Slides } from '/imports/api/slides'; +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default async function zoomSlide(slideNumber, podId, widthRatio, heightRatio, x, y) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ResizeAndMovePagePubMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(slideNumber, Number); + check(podId, String); + check(widthRatio, Number); + check(heightRatio, Number); + check(x, Number); + check(y, Number); + + const selector = { + meetingId, + podId, + current: true, + }; + const Presentation = await Presentations.findOneAsync(selector); + + if (!Presentation) { + throw new Meteor.Error('presentation-not-found', 'You need a presentation to be able to switch slides'); + } + + let validSlideNum = slideNumber; + if (validSlideNum > Presentation?.pages?.length) validSlideNum = 1; + + const Slide = await Slides.findOneAsync({ + meetingId, + podId, + presentationId: Presentation.id, + num: validSlideNum, + }); + + if (!Slide) { + throw new Meteor.Error('slide-not-found', `Slide number ${validSlideNum} not found in the current presentation`); + } + + const payload = { + podId, + presentationId: Presentation.id, + pageId: Slide.id, + xOffset: x, + yOffset: y, + widthRatio, + heightRatio, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method zoomSlide ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/slides/server/modifiers/addSlide.js b/src/2.7.12/imports/api/slides/server/modifiers/addSlide.js new file mode 100644 index 00000000..e785af53 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/modifiers/addSlide.js @@ -0,0 +1,130 @@ +import probe from 'probe-image-size'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import flat from 'flat'; +import RedisPubSub from '/imports/startup/server/redis'; +import { Slides } from '/imports/api/slides'; +import Logger from '/imports/startup/server/logger'; +import { SVG, PNG } from '/imports/utils/mimeTypes'; +import calculateSlideData from '/imports/api/slides/server/helpers'; +import addSlidePositions from './addSlidePositions'; + +const loadSlidesFromHttpAlways = Meteor.settings.private.app.loadSlidesFromHttpAlways || false; + +const requestWhiteboardHistory = async (meetingId, slideId) => { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'GetWhiteboardAnnotationsReqMsg'; + const USER_ID = 'nodeJSapp'; + + const payload = { + whiteboardId: slideId, + }; + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, payload); +}; + +const SUPPORTED_TYPES = [SVG, PNG]; + +const fetchImageSizes = (imageUri) => probe(imageUri) + .then((result) => { + if (!SUPPORTED_TYPES.includes(result.mime)) { + throw new Meteor.Error('invalid-image-type', `received ${result.mime} expecting ${SUPPORTED_TYPES.join()}`); + } + + return { + width: result.width, + height: result.height, + }; + }) + .catch((reason) => { + Logger.error(`Error parsing image size. ${reason}. uri=${imageUri}`); + return reason; + }); + +export default async function addSlide(meetingId, podId, presentationId, slide) { + check(podId, String); + check(presentationId, String); + + check(slide, { + id: String, + num: Number, + thumbUri: String, + txtUri: String, + svgUri: String, + current: Boolean, + xOffset: Number, + yOffset: Number, + widthRatio: Number, + heightRatio: Number, + content: String, + }); + + const { + id: slideId, + xOffset, + yOffset, + widthRatio, + heightRatio, + ...restSlide + } = slide; + + const selector = { + meetingId, + podId, + presentationId, + id: slideId, + }; + + const imageUri = slide.svgUri || slide.pngUri; + + const modifier = { + $set: Object.assign( + { meetingId }, + { podId }, + { presentationId }, + { id: slideId }, + { imageUri }, + flat(restSlide), + { safe: true }, + ), + }; + + const imageSizeUri = (loadSlidesFromHttpAlways ? imageUri.replace(/^https/i, 'http') : imageUri); + + return fetchImageSizes(imageSizeUri) + .then(async ({ width, height }) => { + // there is a rare case when for a very long not-active meeting the presentation + // files just disappear and width/height can't be retrieved + if (width && height) { + // pre-calculating the width, height, and vieBox coordinates / dimensions + // to unload the client-side + const slideData = { + width, + height, + xOffset, + yOffset, + widthRatio, + heightRatio, + }; + const slidePosition = calculateSlideData(slideData); + + await addSlidePositions(meetingId, podId, presentationId, slideId, slidePosition); + } + + try { + const { insertedId, numberAffected } = await Slides.upsertAsync(selector, modifier); + + await requestWhiteboardHistory(meetingId, slideId); + + if (insertedId) { + Logger.info(`Added slide id=${slideId} pod=${podId} presentation=${presentationId}`); + } else if (numberAffected) { + Logger.info(`Upserted slide id=${slideId} pod=${podId} presentation=${presentationId}`); + } + } catch (err) { + Logger.error(`Error on adding slide to collection: ${err}`); + } + }) + .catch((reason) => Logger.error(`Error parsing image size. ${reason}. slide=${slideId} uri=${imageUri}`)); +} diff --git a/src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js b/src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js new file mode 100644 index 00000000..250baaf3 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js @@ -0,0 +1,56 @@ +import { SlidePositions } from '/imports/api/slides'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import flat from 'flat'; + +export default async function addSlidePositions( + meetingId, + podId, + presentationId, + slideId, + slidePosition, +) { + check(meetingId, String); + check(podId, String); + check(presentationId, String); + check(slideId, String); + + check(slidePosition, { + width: Number, + height: Number, + x: Number, + y: Number, + viewBoxWidth: Number, + viewBoxHeight: Number, + }); + + const selector = { + meetingId, + podId, + presentationId, + id: slideId, + }; + + const modifier = { + $set: Object.assign( + { meetingId }, + { podId }, + { presentationId }, + { id: slideId }, + flat(slidePosition), + { safe: true }, + ), + }; + + try { + const { insertedId } = await SlidePositions.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added slide position id=${slideId} pod=${podId} presentation=${presentationId}`); + } else { + Logger.info(`Upserted slide position id=${slideId} pod=${podId} presentation=${presentationId}`); + } + } catch (err) { + Logger.error(`Adding slide position to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js b/src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js new file mode 100644 index 00000000..8776fd65 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js @@ -0,0 +1,66 @@ +import { check } from 'meteor/check'; +import { Slides } from '/imports/api/slides'; +import Logger from '/imports/startup/server/logger'; + +export default async function changeCurrentSlide(meetingId, podId, presentationId, slideId) { + check(meetingId, String); + check(presentationId, String); + check(slideId, String); + check(podId, String); + + const oldCurrent = { + selector: { + meetingId, + podId, + presentationId, + current: true, + }, + modifier: { + $set: { current: false }, + }, + callback: (err) => { + if (err) { + Logger.error(`Unsetting the current slide: ${err}`); + return; + } + + Logger.info('Unsetted the current slide'); + }, + }; + + const newCurrent = { + selector: { + meetingId, + podId, + presentationId, + id: slideId, + }, + modifier: { + $set: { current: true }, + }, + callback: (err) => { + if (err) { + Logger.error(`Setting as current slide id=${slideId}: ${err}`); + return; + } + + Logger.info(`Setted as current slide id=${slideId}`); + }, + }; + + const oldSlide = await Slides.findOneAsync(oldCurrent.selector); + const newSlide = await Slides.findOneAsync(newCurrent.selector); + + // if the oldCurrent and newCurrent have the same ids + if (oldSlide && newSlide && (oldSlide._id === newSlide._id)) { + return; + } + + if (newSlide) { + await Slides.updateAsync(newSlide._id, newCurrent.modifier, newCurrent.callback); + } + + if (oldSlide) { + await Slides.updateAsync(oldSlide._id, oldCurrent.modifier, oldCurrent.callback); + } +} diff --git a/src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js b/src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js new file mode 100644 index 00000000..744b474c --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js @@ -0,0 +1,38 @@ +import { Slides, SlidePositions } from '/imports/api/slides'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearSlides(meetingId) { + if (meetingId) { + try { + const numberAffectedSlidePositions = await SlidePositions.removeAsync({ meetingId }); + + const numberAffected = await Slides.removeAsync({ meetingId }); + + if (numberAffectedSlidePositions) { + Logger.info(`Cleared SlidePositions (${meetingId})`); + } + + if (numberAffected) { + Logger.info(`Cleared Slides (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on cleaning Slides (${meetingId}). ${err}`); + } + } else { + try { + const numberAffectedSlidePositions = await SlidePositions.removeAsync({ meetingId }); + + const numberAffected = await Slides.removeAsync({ meetingId }); + + if (numberAffectedSlidePositions) { + Logger.info(`Cleared SlidePositions (${meetingId})`); + } + + if (numberAffected) { + Logger.info('Cleared Slides (all)'); + } + } catch (err) { + Logger.error(`Error on cleaning Slides (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js b/src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js new file mode 100644 index 00000000..5f50ddcc --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js @@ -0,0 +1,30 @@ +import { Slides, SlidePositions } from '/imports/api/slides'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import clearAnnotations from '/imports/api/annotations/server/modifiers/clearAnnotations'; + +export default async function clearSlidesPresentation(meetingId, presentationId) { + check(meetingId, String); + check(presentationId, String); + + const selector = { + meetingId, + presentationId, + }; + + const whiteboardIds = await Slides.find(selector, { fields: { id: 1 } }).mapAsync(row => row.id); + + try { + await SlidePositions.removeAsync(selector); + + const numberAffected = await Slides.removeAsync(selector); + + if (numberAffected) { + whiteboardIds.forEach(async (whiteboardId) => clearAnnotations(meetingId, whiteboardId)); + + Logger.info(`Removed Slides where presentationId=${presentationId}`); + } + } catch (err) { + Logger.error(`Removing Slides from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js b/src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js new file mode 100644 index 00000000..3612a236 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js @@ -0,0 +1,63 @@ +import { check } from 'meteor/check'; +import { SlidePositions } from '/imports/api/slides'; +import Logger from '/imports/startup/server/logger'; +import calculateSlideData from '/imports/api/slides/server/helpers'; + +export default async function resizeSlide(meetingId, slide) { + check(meetingId, String); + + const { + podId, + presentationId, + pageId, + widthRatio, + heightRatio, + xOffset, + yOffset, + } = slide; + + const selector = { + meetingId, + podId, + presentationId, + id: pageId, + }; + + // fetching the current slide data + // and pre-calculating the width, height, and vieBox coordinates / sizes + // to reduce the client-side load + const SlidePosition = await SlidePositions.findOneAsync(selector); + + if (SlidePosition) { + const { + width, + height, + } = SlidePosition; + + const slideData = { + width, + height, + xOffset, + yOffset, + widthRatio, + heightRatio, + }; + const calculatedData = calculateSlideData(slideData); + + const modifier = { + $set: calculatedData, + }; + + try { + const numberAffected = await SlidePositions.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.debug(`Resized slide positions id=${pageId}`); + } else { + Logger.info(`No slide positions found with id=${pageId}`); + } + } catch (err) { + Logger.error(`Resizing slide positions id=${pageId}: ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/slides/server/publishers.js b/src/2.7.12/imports/api/slides/server/publishers.js new file mode 100644 index 00000000..767d6401 --- /dev/null +++ b/src/2.7.12/imports/api/slides/server/publishers.js @@ -0,0 +1,50 @@ +import { Slides, SlidePositions } from '/imports/api/slides'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function slides() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Slides was requested by unauth connection ${this.connection.id}`); + return Slides.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing Slides', { meetingId, userId }); + + return Slides.find({ meetingId }); +} + +function publish(...args) { + const boundSlides = slides.bind(this); + return boundSlides(...args); +} + +Meteor.publish('slides', publish); + +async function slidePositions() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing SlidePositions was requested by unauth connection ${this.connection.id}`); + return SlidePositions.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing SlidePositions', { meetingId, userId }); + + return SlidePositions.find({ meetingId }); +} + +function publishPositions(...args) { + const boundSlidePositions = slidePositions.bind(this); + return boundSlidePositions(...args); +} + +Meteor.publish('slide-positions', publishPositions); diff --git a/src/2.7.12/imports/api/timer/index.js b/src/2.7.12/imports/api/timer/index.js new file mode 100644 index 00000000..c43d8e33 --- /dev/null +++ b/src/2.7.12/imports/api/timer/index.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +const Timer = new Mongo.Collection('timer'); + +if (Meteor.isServer) { + Timer.createIndex({ meetingId: 1 }); +} + +export default Timer; diff --git a/src/2.7.12/imports/api/timer/server/eventHandlers.js b/src/2.7.12/imports/api/timer/server/eventHandlers.js new file mode 100644 index 00000000..48d442ff --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/eventHandlers.js @@ -0,0 +1,20 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleTimerActivated from './handlers/timerActivated'; +import handleTimerDeactivated from './handlers/timerDeactivated'; +import handleTimerStarted from './handlers/timerStarted'; +import handleTimerStopped from './handlers/timerStopped'; +import handleTimerSwitched from './handlers/timerSwitched'; +import handleTimerSet from './handlers/timerSet'; +import handleTimerReset from './handlers/timerReset'; +import handleTimerEnded from './handlers/timerEnded'; +import handleTrackSet from './handlers/trackSet'; + +RedisPubSub.on('ActivateTimerRespMsg', handleTimerActivated); +RedisPubSub.on('DeactivateTimerRespMsg', handleTimerDeactivated); +RedisPubSub.on('StartTimerRespMsg', handleTimerStarted); +RedisPubSub.on('StopTimerRespMsg', handleTimerStopped); +RedisPubSub.on('SwitchTimerRespMsg', handleTimerSwitched); +RedisPubSub.on('SetTimerRespMsg', handleTimerSet); +RedisPubSub.on('ResetTimerRespMsg', handleTimerReset); +RedisPubSub.on('TimerEndedEvtMsg', handleTimerEnded); +RedisPubSub.on('SetTrackRespMsg', handleTrackSet); diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerActivated.js b/src/2.7.12/imports/api/timer/server/handlers/timerActivated.js new file mode 100644 index 00000000..fdeaccba --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerActivated.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerActivated({ body }, meetingId) { + const { userId } = body; + check(meetingId, String); + check(userId, String); + + updateTimer({ + action: 'activate', + meetingId, + requesterUserId: userId, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js b/src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js new file mode 100644 index 00000000..61a7ae45 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerDeactivated({ body }, meetingId) { + const { userId } = body; + check(meetingId, String); + check(userId, String); + + updateTimer({ + action: 'deactivate', + meetingId, + requesterUserId: userId, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerEnded.js b/src/2.7.12/imports/api/timer/server/handlers/timerEnded.js new file mode 100644 index 00000000..410b3eb5 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerEnded.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerEnded({ body }, meetingId) { + check(meetingId, String); + check(body, Object); + + updateTimer({ + action: 'reset', + meetingId, + requesterUserId: 'nodeJSapp', + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerReset.js b/src/2.7.12/imports/api/timer/server/handlers/timerReset.js new file mode 100644 index 00000000..f44b69f2 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerReset.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerReset({ body }, meetingId) { + const { userId } = body; + check(meetingId, String); + check(userId, String); + + updateTimer({ + action: 'reset', + meetingId, + requesterUserId: userId, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerSet.js b/src/2.7.12/imports/api/timer/server/handlers/timerSet.js new file mode 100644 index 00000000..0ae32827 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerSet.js @@ -0,0 +1,17 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerSet({ body }, meetingId) { + const { userId, time } = body; + + check(meetingId, String); + check(userId, String); + check(time, Number); + + updateTimer({ + action: 'set', + meetingId, + requesterUserId: userId, + time, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerStarted.js b/src/2.7.12/imports/api/timer/server/handlers/timerStarted.js new file mode 100644 index 00000000..2400f46a --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerStarted.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerStarted({ body }, meetingId) { + const { userId } = body; + check(meetingId, String); + check(userId, String); + + updateTimer({ + action: 'start', + meetingId, + requesterUserId: userId, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerStopped.js b/src/2.7.12/imports/api/timer/server/handlers/timerStopped.js new file mode 100644 index 00000000..cb4023e5 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerStopped.js @@ -0,0 +1,17 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerStopped({ body }, meetingId) { + const { userId, accumulated } = body; + + check(meetingId, String); + check(userId, String); + check(accumulated, Number); + + updateTimer({ + action: 'stop', + meetingId, + requesterUserId: userId, + accumulated, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js b/src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js new file mode 100644 index 00000000..38dc03d2 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js @@ -0,0 +1,17 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTimerSwitched({ body }, meetingId) { + const { userId, stopwatch } = body; + + check(meetingId, String); + check(userId, String); + check(stopwatch, Boolean); + + updateTimer({ + action: 'switch', + meetingId, + requesterUserId: userId, + stopwatch, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/handlers/trackSet.js b/src/2.7.12/imports/api/timer/server/handlers/trackSet.js new file mode 100644 index 00000000..570cffb3 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/handlers/trackSet.js @@ -0,0 +1,18 @@ +import { check } from 'meteor/check'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; + +export default function handleTrackSet({ body }, meetingId) { + const { userId, track } = body; + + check(meetingId, String); + check(userId, String); + check(track, String); + + updateTimer({ + action: 'track', + meetingId, + requesterUserId: userId, + stopwatch: false, + track, + }); +} diff --git a/src/2.7.12/imports/api/timer/server/helpers.js b/src/2.7.12/imports/api/timer/server/helpers.js new file mode 100644 index 00000000..88494d6a --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/helpers.js @@ -0,0 +1,40 @@ +import { Meteor } from 'meteor/meteor'; + +const TIMER_CONFIG = Meteor.settings.public.timer; + +const MILLI_IN_MINUTE = 60000; + +const TRACKS = [ + 'noTrack', + 'track1', + 'track2', + 'track3', +]; + +const isEnabled = () => TIMER_CONFIG.enabled; + +const getDefaultTime = () => TIMER_CONFIG.time * MILLI_IN_MINUTE; + +const getInitialState = () => { + const time = getDefaultTime(); + check(time, Number); + + return { + stopwatch: true, + running: false, + time, + accumulated: 0, + timestamp: 0, + track: TRACKS[0], + }; +}; + +const isTrackValid = (track) => TRACKS.includes(track); + +export { + TRACKS, + isEnabled, + getDefaultTime, + getInitialState, + isTrackValid, +}; diff --git a/src/2.7.12/imports/api/timer/server/index.js b/src/2.7.12/imports/api/timer/server/index.js new file mode 100644 index 00000000..f57b4620 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/index.js @@ -0,0 +1,3 @@ +import './methods'; +import './eventHandlers'; +import './publishers'; diff --git a/src/2.7.12/imports/api/timer/server/methods.js b/src/2.7.12/imports/api/timer/server/methods.js new file mode 100644 index 00000000..85be11fa --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods.js @@ -0,0 +1,24 @@ +import { Meteor } from 'meteor/meteor'; +import activateTimer from './methods/activateTimer'; +import deactivateTimer from './methods/deactivateTimer'; +import resetTimer from './methods/resetTimer'; +import startTimer from './methods/startTimer'; +import stopTimer from './methods/stopTimer'; +import switchTimer from './methods/switchTimer'; +import setTimer from './methods/setTimer'; +import getServerTime from './methods/getServerTime'; +import setTrack from './methods/setTrack'; +import timerEnded from './methods/endTimer'; + +Meteor.methods({ + activateTimer, + deactivateTimer, + resetTimer, + startTimer, + stopTimer, + switchTimer, + setTimer, + getServerTime, + setTrack, + timerEnded, +}); diff --git a/src/2.7.12/imports/api/timer/server/methods/activateTimer.js b/src/2.7.12/imports/api/timer/server/methods/activateTimer.js new file mode 100644 index 00000000..41cb168c --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/activateTimer.js @@ -0,0 +1,23 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { getInitialState } from '../helpers'; + +export default function activateTimer() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ActivateTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + + const payload = getInitialState(); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Activating timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/createTimer.js b/src/2.7.12/imports/api/timer/server/methods/createTimer.js new file mode 100644 index 00000000..262aedcf --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/createTimer.js @@ -0,0 +1,29 @@ +import { check } from 'meteor/check'; +import { getInitialState, isEnabled } from '/imports/api/timer/server/helpers'; +import addTimer from '/imports/api/timer/server/modifiers/addTimer'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; + +// This method should only be used by the server +export default function createTimer(meetingId) { + check(meetingId, String); + + // Avoid timer creation if this feature is disabled + if (!isEnabled()) { + Logger.warn(`Timers are disabled for ${meetingId}`); + return; + } + + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'CreateTimerPubMsg'; + + try { + addTimer(meetingId); + const payload = getInitialState(); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, 'nodeJsApp', payload); + } catch (err) { + Logger.error(`Activating timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js b/src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js new file mode 100644 index 00000000..5cbd4a33 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js @@ -0,0 +1,20 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function deactivateTimer() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'DeactivateTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, {}); + } catch (err) { + Logger.error(`Deactivating timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/endTimer.js b/src/2.7.12/imports/api/timer/server/methods/endTimer.js new file mode 100644 index 00000000..835f2425 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/endTimer.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import updateTimer from '/imports/api/timer/server/modifiers/updateTimer'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function timerEnded() { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + + updateTimer({ + action: 'ended', + meetingId, + requesterUserId, + }); +} + +// This method should only be used by the server +export function sysEndTimer(meetingId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'TimerEndedPubMsg'; + const USER_ID = 'nodeJSapp'; + + try { + check(meetingId, String); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, {}); + } catch (err) { + Logger.error(`Ending timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/getServerTime.js b/src/2.7.12/imports/api/timer/server/methods/getServerTime.js new file mode 100644 index 00000000..6ff652f1 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/getServerTime.js @@ -0,0 +1,5 @@ +export default function getServerTime() { + if (this.userId) return Date.now(); + + return 0; +} diff --git a/src/2.7.12/imports/api/timer/server/methods/resetTimer.js b/src/2.7.12/imports/api/timer/server/methods/resetTimer.js new file mode 100644 index 00000000..4d534f2c --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/resetTimer.js @@ -0,0 +1,20 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function resetTimer() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ResetTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, {}); + } catch (err) { + Logger.error(`Resetting timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/setTimer.js b/src/2.7.12/imports/api/timer/server/methods/setTimer.js new file mode 100644 index 00000000..b17e7574 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/setTimer.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function setTimer(time) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + check(time, Number); + + const payload = { + time, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Setting timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/setTrack.js b/src/2.7.12/imports/api/timer/server/methods/setTrack.js new file mode 100644 index 00000000..a63262db --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/setTrack.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { isTrackValid } from '/imports/api/timer/server/helpers'; + +export default function setTrack(track) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetTrackReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + check(track, String); + + if (isTrackValid(track)) { + const payload = { + track, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } else { + Logger.warn(`User=${requesterUserId} tried to set invalid track '${track}' in meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Setting track: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/startTimer.js b/src/2.7.12/imports/api/timer/server/methods/startTimer.js new file mode 100644 index 00000000..cb372a30 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/startTimer.js @@ -0,0 +1,20 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function startTimer() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StartTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, {}); + } catch (err) { + Logger.error(`Starting timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/stopTimer.js b/src/2.7.12/imports/api/timer/server/methods/stopTimer.js new file mode 100644 index 00000000..3b769f65 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/stopTimer.js @@ -0,0 +1,92 @@ +import { check } from 'meteor/check'; +import Timer from '/imports/api/timer'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function stopTimer() { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StopTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + + const now = Date.now(); + const timer = Timer.findOne( + { meetingId }, + { + fields: + { + stopwatch: 1, + time: 1, + accumulated: 1, + timestamp: 1, + }, + }, + ); + + if (timer) { + const { + timestamp, + } = timer; + + const accumulated = timer.accumulated + (now - timestamp); + + const payload = { + accumulated, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } else { + Logger.warn(`Could not stop timer for meeting=${meetingId}, timer not found`); + } + } catch (err) { + Logger.error(`Stopping timer: ${err}`); + } +} + +// This method should only be used by the server +export function sysStopTimer(meetingId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StopTimerReqMsg'; + const USER_ID = 'nodeJSapp'; + + try { + check(meetingId, String); + const now = Date.now(); + const timer = Timer.findOne( + { meetingId }, + { + fields: + { + stopwatch: 1, + time: 1, + accumulated: 1, + timestamp: 1, + }, + }, + ); + + if (timer) { + const { + timestamp, + } = timer; + + const accumulated = timer.accumulated + (now - timestamp); + + const payload = { + accumulated, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, payload); + } else { + Logger.warn(`Could not stop timer for meeting=${meetingId}, timer not found`); + } + } catch (err) { + Logger.error(`Stopping timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/methods/switchTimer.js b/src/2.7.12/imports/api/timer/server/methods/switchTimer.js new file mode 100644 index 00000000..0dfbaa0d --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/methods/switchTimer.js @@ -0,0 +1,25 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function switchTimer(stopwatch) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SwitchTimerReqMsg'; + + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + check(meetingId, String); + check(requesterUserId, String); + check(stopwatch, Boolean); + + const payload = { + stopwatch, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Switching timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/modifiers/addTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/addTimer.js new file mode 100644 index 00000000..2ab47072 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/modifiers/addTimer.js @@ -0,0 +1,34 @@ +import { check } from 'meteor/check'; +import Timer from '/imports/api/timer'; +import Logger from '/imports/startup/server/logger'; +import { getInitialState } from '/imports/api/timer/server/helpers'; + +// This method should only be used by the server +export default function addTimer(meetingId) { + check(meetingId, String); + + const selector = { + meetingId, + }; + + const modifier = { + meetingId, + ...getInitialState(), + active: false, + ended: 0, + }; + + const cb = (err, numChanged) => { + if (err) { + return Logger.error(`Adding timer to the collection: ${err}`); + } + + if (numChanged) { + return Logger.debug(`Added timer meeting=${meetingId}`); + } + + return Logger.debug(`Upserted timer meeting=${meetingId}`); + }; + + return Timer.upsert(selector, modifier, cb); +} diff --git a/src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js new file mode 100644 index 00000000..49e595c1 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js @@ -0,0 +1,14 @@ +import Timer from '/imports/api/timer'; +import Logger from '/imports/startup/server/logger'; + +export default function clearTimer(meetingId) { + if (meetingId) { + return Timer.remove({ meetingId }, () => { + Logger.info(`Cleared Timer (${meetingId})`); + }); + } + + return Timer.remove({}, () => { + Logger.info('Cleared Timer (all)'); + }); +} diff --git a/src/2.7.12/imports/api/timer/server/modifiers/endTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/endTimer.js new file mode 100644 index 00000000..bcedfdd1 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/modifiers/endTimer.js @@ -0,0 +1,18 @@ +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; + +export default function endTimer(meetingId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'TimerEndedPubMsg'; + const USER_ID = 'nodeJSapp'; + + try { + check(meetingId, String); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, {}); + } catch (err) { + Logger.error(`Ending timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js new file mode 100644 index 00000000..1ed53990 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js @@ -0,0 +1,46 @@ +import { check } from 'meteor/check'; +import Timer from '/imports/api/timer'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; + +export default function stopTimer(meetingId) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'StopTimerReqMsg'; + const USER_ID = 'nodeJSapp'; + + try { + check(meetingId, String); + const now = Date.now(); + const timer = Timer.findOne( + { meetingId }, + { + fields: + { + stopwatch: 1, + time: 1, + accumulated: 1, + timestamp: 1, + }, + }, + ); + + if (timer) { + const { + timestamp, + } = timer; + + const accumulated = timer.accumulated + (now - timestamp); + + const payload = { + accumulated, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, payload); + } else { + Logger.warn(`Could not stop timer for meeting=${meetingId}, timer not found`); + } + } catch (err) { + Logger.error(`Stopping timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js new file mode 100644 index 00000000..1bf3b1f2 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js @@ -0,0 +1,188 @@ +import { check } from 'meteor/check'; +import Timer from '/imports/api/timer'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import { TRACKS, getInitialState } from '/imports/api/timer/server/helpers'; +import { sysStopTimer } from '../methods/stopTimer'; +import { sysEndTimer } from '../methods/endTimer'; + +const getActivateModifier = () => ({ + $set: { + active: true, + ...getInitialState(), + ended: 0, + }, +}); + +const getDeactivateModifier = () => ({ + $set: { + active: false, + running: false, + ended: 0, + }, +}); + +const getResetModifier = () => ({ + $set: { + accumulated: 0, + timestamp: Date.now(), + ended: 0, + }, +}); + +const handleTimerEndedNotifications = (fields, meetingId, handle) => { + const meetingUsers = Users.find({ meetingId }).count(); + + if (fields.running === false) { + handle.stop(); + } + + if (fields.ended >= meetingUsers) { + sysStopTimer(meetingId); + sysEndTimer(meetingId); + } +}; + +const setTimerEndObserver = (meetingId) => { + const { stopwatch } = Timer.findOne({ meetingId }); + + if (stopwatch === false) { + const meetingTimer = Timer.find( + { meetingId }, + { fields: { ended: 1, running: 1 } }, + ); + const handle = meetingTimer.observeChanges({ + changed: (id, fields) => { + handleTimerEndedNotifications(fields, meetingId, handle); + }, + }); + } +}; + +const getStartModifier = () => ({ + $set: { + running: true, + timestamp: Date.now(), + ended: 0, + }, +}); + +const getStopModifier = (accumulated) => ({ + $set: { + running: false, + accumulated, + timestamp: 0, + ended: 0, + }, +}); + +const getSwitchModifier = (stopwatch) => ({ + $set: { + stopwatch, + running: false, + accumulated: 0, + timestamp: 0, + track: TRACKS[0], + ended: 0, + }, +}); + +const getSetModifier = (time) => ({ + $set: { + running: false, + accumulated: 0, + timestamp: 0, + time, + }, +}); + +const getTrackModifier = (track) => ({ + $set: { + track, + }, +}); + +const getEndedModifier = () => ({ + $inc: { + ended: 1, + }, +}); + +const logTimer = (meetingId, requesterUserId, action, stopwatch, time, track) => { + if (action === 'switch') { + Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action} stopwatch=${stopwatch} `); + } else if (action === 'set' && time !== 0) { + Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action} ${time}ms`); + } else if (action === 'track') { + Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action} changed to ${track}`); + } else { + Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action}`); + } +}; + +export default function updateTimer({ + action, + meetingId, + requesterUserId, + time = 0, + stopwatch = true, + accumulated = 0, + track = TRACKS[0], +}) { + check(action, String); + check(meetingId, String); + check(requesterUserId, String); + check(time, Number); + check(stopwatch, Boolean); + check(accumulated, Number); + check(track, String); + + const selector = { + meetingId, + }; + + let modifier; + + switch (action) { + case 'activate': + modifier = getActivateModifier(); + break; + case 'deactivate': + modifier = getDeactivateModifier(); + break; + case 'reset': + modifier = getResetModifier(); + break; + case 'start': + setTimerEndObserver(meetingId); + modifier = getStartModifier(); + break; + case 'stop': + modifier = getStopModifier(accumulated); + break; + case 'switch': + modifier = getSwitchModifier(stopwatch); + break; + case 'set': + modifier = getSetModifier(time); + break; + case 'track': + modifier = getTrackModifier(track); + break; + case 'ended': + modifier = getEndedModifier(); + break; + default: + Logger.error(`Unhandled timer action=${action}`); + } + + try { + const { numberAffected } = Timer.upsert(selector, modifier); + + if (numberAffected) { + logTimer(meetingId, requesterUserId, action, stopwatch, time, track); + } + } catch (err) { + Logger.error(`Updating timer: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/timer/server/publishers.js b/src/2.7.12/imports/api/timer/server/publishers.js new file mode 100644 index 00000000..c9535d41 --- /dev/null +++ b/src/2.7.12/imports/api/timer/server/publishers.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import Timer from '/imports/api/timer'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +function timer() { + if (!this.userId) { + return Timer.find({ meetingId: '' }); + } + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + Logger.info(`Publishing timer for ${meetingId} ${requesterUserId}`); + + return Timer.find({ meetingId }); +} + +function publish(...args) { + const boundTimer = timer.bind(this); + return boundTimer(...args); +} + +Meteor.publish('timer', publish); diff --git a/src/2.7.12/imports/api/user-reaction/index.js b/src/2.7.12/imports/api/user-reaction/index.js new file mode 100644 index 00000000..eb5ffcbd --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/index.js @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +const expireSeconds = Meteor.settings.public.userReaction.expire; +const UserReaction = new Mongo.Collection('user-reaction'); + +if (Meteor.isServer) { + // TTL indexes are special single-field indexes to automatically remove documents + // from a collection after a certain amount of time. + // A single-field with only a date is necessary to this special single-field index, because + // compound indexes do not support TTL. + UserReaction._ensureIndex({ creationDate: 1 }, { expireAfterSeconds: expireSeconds }); +} + +export default UserReaction; diff --git a/src/2.7.12/imports/api/user-reaction/server/eventHandlers.js b/src/2.7.12/imports/api/user-reaction/server/eventHandlers.js new file mode 100644 index 00000000..31437b0d --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/eventHandlers.js @@ -0,0 +1,6 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleSetUserReaction from './handlers/setUserReaction'; +import handleClearUsersReaction from './handlers/clearUsersReaction'; + +RedisPubSub.on('UserReactionEmojiChangedEvtMsg', handleSetUserReaction); +RedisPubSub.on('ClearedAllUsersReactionEvtMsg', handleClearUsersReaction); diff --git a/src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js b/src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js new file mode 100644 index 00000000..b9113912 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js @@ -0,0 +1,7 @@ +import { check } from 'meteor/check'; +import clearReactions from '../modifiers/clearReactions'; + +export default function handleClearUsersReaction({ body }, meetingId) { + check(meetingId, String); + clearReactions(meetingId); +} diff --git a/src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js b/src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js new file mode 100644 index 00000000..4b8e2862 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js @@ -0,0 +1,7 @@ +import addUserReaction from '../modifiers/addUserReaction'; + +export default function handleSetUserReaction({ body }, meetingId) { + const { userId, reactionEmoji } = body; + + addUserReaction(meetingId, userId, reactionEmoji); +} diff --git a/src/2.7.12/imports/api/user-reaction/server/helpers.js b/src/2.7.12/imports/api/user-reaction/server/helpers.js new file mode 100644 index 00000000..1efe976b --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/helpers.js @@ -0,0 +1,44 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import UserReactions from '/imports/api/user-reaction'; +import Logger from '/imports/startup/server/logger'; + +const expireSeconds = Meteor.settings.public.userReaction.expire; +const expireMilliseconds = expireSeconds * 1000; + +const notifyExpiredReaction = (meetingId, userId) => { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserReactionTimeExpiredCmdMsg'; + const NODE_USER = 'nodeJSapp'; + const emoji = 'none'; + + check(meetingId, String); + + const payload = { + userId, + }; + + Logger.verbose('User emoji status updated due to expiration time', { + emoji, NODE_USER, meetingId, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, NODE_USER, payload); + } catch (err) { + Logger.error(`Exception while invoking method resetUserReaction ${err.stack}`); + } +}; + +const addUserReactionsObserver = (meetingId) => { + const meetingUserReactions = UserReactions.find({ meetingId }); + return meetingUserReactions.observe({ + removed(document) { + const isExpirationTriggeredRemoval = (Date.now() - Date.parse(document.creationDate)) >= expireMilliseconds; + if (isExpirationTriggeredRemoval) { + notifyExpiredReaction(meetingId, document.userId); + } + }, + }); +}; + +export default addUserReactionsObserver; diff --git a/src/2.7.12/imports/api/user-reaction/server/index.js b/src/2.7.12/imports/api/user-reaction/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/user-reaction/server/methods.js b/src/2.7.12/imports/api/user-reaction/server/methods.js new file mode 100644 index 00000000..0e69eb8f --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/methods.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; +import setUserReaction from './methods/setUserReaction'; +import clearAllUsersReaction from './methods/clearAllUsersReaction'; + +Meteor.methods({ + setUserReaction, + clearAllUsersReaction, +}); diff --git a/src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js b/src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js new file mode 100644 index 00000000..77b7e960 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function clearAllUsersReaction() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ClearAllUsersReactionCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + userId: requesterUserId, + }; + + Logger.verbose('Sending clear all users reactions', { + requesterUserId, meetingId, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method clearAllUsersReaction ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js b/src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js new file mode 100644 index 00000000..5a8889a6 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js @@ -0,0 +1,36 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +const ALLOWED_REACTIONS = Meteor.settings.public.userReaction.reactions; +const nativeEmojis = ALLOWED_REACTIONS.map((reaction) => reaction.native); + +export default function setUserReaction(reactionEmoji, userId = undefined) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserReactionEmojiReqMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(reactionEmoji, String); + if (!nativeEmojis.includes(reactionEmoji) && reactionEmoji !== 'none') return; + + const payload = { + reactionEmoji, + userId: userId || requesterUserId, + }; + + Logger.verbose('User reactionEmoji status updated', { + reactionEmoji, requesterUserId, meetingId, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setUserReaction ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js b/src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js new file mode 100644 index 00000000..53d46c37 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js @@ -0,0 +1,34 @@ +import UserReaction from '/imports/api/user-reaction'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default function addUserReaction(meetingId, userId, reaction) { + check(meetingId, String); + check(userId, String); + check(reaction, String); + + const selector = { + creationDate: new Date(), + meetingId, + userId, + }; + + const modifier = { + $set: { + meetingId, + userId, + reaction, + }, + }; + + try { + UserReaction.remove({ meetingId, userId }); + const { numberAffected } = UserReaction.upsert(selector, modifier); + + if (numberAffected) { + Logger.verbose(`Added user reaction meetingId=${meetingId} userId=${userId}`); + } + } catch (err) { + Logger.error(`Adding user reaction: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js b/src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js new file mode 100644 index 00000000..4696b48b --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js @@ -0,0 +1,26 @@ +import UserReaction from '/imports/api/user-reaction'; +import Logger from '/imports/startup/server/logger'; + +export default function clearReactions(meetingId) { + const selector = {}; + + if (meetingId) { + selector.meetingId = meetingId; + } + + try { + const numberAffected = UserReaction.remove(selector); + + if (numberAffected) { + if (meetingId) { + Logger.info(`Removed UserReaction (${meetingId})`); + } else { + Logger.info('Removed UserReaction (all)'); + } + } else { + Logger.warn('Removing UserReaction nonaffected'); + } + } catch (err) { + Logger.error(`Removing UserReaction: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/user-reaction/server/publishers.js b/src/2.7.12/imports/api/user-reaction/server/publishers.js new file mode 100644 index 00000000..82658437 --- /dev/null +++ b/src/2.7.12/imports/api/user-reaction/server/publishers.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import UserReaction from '/imports/api/user-reaction'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +function userReaction() { + if (!this.userId) { + return UserReaction.find({ meetingId: '' }); + } + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + Logger.info(`Publishing user reaction for ${meetingId} ${requesterUserId}`); + + return UserReaction.find({ meetingId }); +} + +function publish(...args) { + const boundUserReaction = userReaction.bind(this); + return boundUserReaction(...args); +} + +Meteor.publish('user-reaction', publish); diff --git a/src/2.7.12/imports/api/users-infos/index.js b/src/2.7.12/imports/api/users-infos/index.js new file mode 100644 index 00000000..ee64f75c --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/index.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const UserInfos = new Mongo.Collection('users-infos', collectionOptions); + +if (Meteor.isServer) { + UserInfos.createIndexAsync({ meetingId: 1, userId: 1 }); +} + +export default UserInfos; diff --git a/src/2.7.12/imports/api/users-infos/server/eventHandlers.js b/src/2.7.12/imports/api/users-infos/server/eventHandlers.js new file mode 100644 index 00000000..ad5c5536 --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/eventHandlers.js @@ -0,0 +1,4 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleUserInformation from './handlers/userInformation'; + +RedisPubSub.on('LookUpUserRespMsg', handleUserInformation); diff --git a/src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js b/src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js new file mode 100644 index 00000000..6f3262ca --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js @@ -0,0 +1,17 @@ +import { check } from 'meteor/check'; +import addUserInfo from '../modifiers/addUserInfo'; + +export default async function handleUserInformation({ header, body }) { + check(body, Object); + check(header, Object); + + const { userInfo } = body; + const { userId, meetingId } = header; + + check(userInfo, Array); + check(userId, String); + check(meetingId, String); + + const result = await addUserInfo(userInfo, userId, meetingId); + return result; +} diff --git a/src/2.7.12/imports/api/users-infos/server/index.js b/src/2.7.12/imports/api/users-infos/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/users-infos/server/methods.js b/src/2.7.12/imports/api/users-infos/server/methods.js new file mode 100644 index 00000000..3b11b958 --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/methods.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; +import requestUserInformation from './methods/requestUserInformation'; +import removeUserInformation from './methods/removeUserInformation'; + +Meteor.methods({ + requestUserInformation, + removeUserInformation, +}); diff --git a/src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js b/src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js new file mode 100644 index 00000000..66e76206 --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js @@ -0,0 +1,26 @@ +import UserInfos from '/imports/api/users-infos'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +export default async function removeUserInformation() { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const selector = { + meetingId, + requesterUserId, + }; + + const numberAffected = await UserInfos.removeAsync(selector); + + if (numberAffected) { + Logger.info(`Removed user information: requester id=${requesterUserId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Exception while invoking method removeUserInformation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js b/src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js new file mode 100644 index 00000000..6a9b2c4d --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function getUserInformation(externalUserId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toThirdParty; + const EVENT_NAME = 'LookUpUserReqMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(externalUserId, String); + + const payload = { + externalUserId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method getUserInformation ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js b/src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js new file mode 100644 index 00000000..882f0efa --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js @@ -0,0 +1,20 @@ +import UserInfos from '/imports/api/users-infos'; +import Logger from '/imports/startup/server/logger'; + +export default async function addUserInfo(userInfo, requesterUserId, meetingId) { + const info = { + meetingId, + requesterUserId, + userInfo, + }; + + try { + const numberAffected = await UserInfos.insertAsync(info); + + if (numberAffected) { + Logger.info(`Added user information: requester id=${requesterUserId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding user information to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js new file mode 100644 index 00000000..ca76f675 --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js @@ -0,0 +1,14 @@ +import UserInfos from '/imports/api/users-infos'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearUsersInfo(meetingId) { + try { + const numberAffected = await UserInfos.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared User Infos (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing User Infos (${meetingId}). ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js new file mode 100644 index 00000000..8766fe41 --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js @@ -0,0 +1,14 @@ +import UserInfos from '/imports/api/users-infos'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearUsersInfoForRequester(meetingId, requesterUserId) { + try { + const numberAffected = await UserInfos.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared User Infos requested by user=${requesterUserId}`); + } + } catch (err) { + Logger.info(`Error on clearing User Infos requested by user=${requesterUserId}. ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-infos/server/publishers.js b/src/2.7.12/imports/api/users-infos/server/publishers.js new file mode 100644 index 00000000..0bdc333c --- /dev/null +++ b/src/2.7.12/imports/api/users-infos/server/publishers.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import UserInfos from '/imports/api/users-infos'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function userInfos() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing UserInfos was requested by unauth connection ${this.connection.id}`); + return UserInfos.find({ meetingId: '' }); + } + + const { meetingId, userId: requesterUserId } = tokenValidation; + + Logger.debug('Publishing UserInfos requested', { meetingId, requesterUserId }); + + return UserInfos.find({ meetingId, requesterUserId }); +} + +function publish(...args) { + const boundUserInfos = userInfos.bind(this); + return boundUserInfos(...args); +} + +Meteor.publish('users-infos', publish); diff --git a/src/2.7.12/imports/api/users-persistent-data/index.js b/src/2.7.12/imports/api/users-persistent-data/index.js new file mode 100644 index 00000000..4f5a0190 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/index.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const UsersPersistentData = new Mongo.Collection('users-persistent-data', collectionOptions); + +if (Meteor.isServer) { + UsersPersistentData.createIndexAsync({ meetingId: 1, userId: 1 }); +} + +export default UsersPersistentData; diff --git a/src/2.7.12/imports/api/users-persistent-data/server/index.js b/src/2.7.12/imports/api/users-persistent-data/server/index.js new file mode 100644 index 00000000..f7744b2b --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/index.js @@ -0,0 +1 @@ +import './publishers'; diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js new file mode 100644 index 00000000..33c6a510 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js @@ -0,0 +1,87 @@ +import { check } from 'meteor/check'; +import UsersPersistentData from '/imports/api/users-persistent-data'; +import Logger from '/imports/startup/server/logger'; + +export default async function addUserPersistentData(user) { + check(user, { + meetingId: String, + sortName: String, + color: String, + speechLocale: String, + mobile: Boolean, + breakoutProps: Object, + inactivityCheck: Boolean, + responseDelay: Number, + loggedOut: Boolean, + intId: String, + extId: String, + name: String, + pin: Boolean, + role: String, + guest: Boolean, + authed: Boolean, + waitingForAcceptance: Match.Maybe(Boolean), + guestStatus: String, + emoji: String, + reactionEmoji: String, + raiseHand: Boolean, + away: Boolean, + presenter: Boolean, + locked: Boolean, + avatar: String, + webcamBackground: String, + clientType: String, + left: Boolean, + effectiveConnectionType: null, + }); + + const { + intId, + extId, + meetingId, + name, + role, + token, + avatar, + webcamBackground, + guest, + color, + pin, + } = user; + + const userData = { + userId: intId, + extId, + meetingId, + name, + role, + token, + avatar, + webcamBackground, + guest, + color, + pin, + loggedOut: false, + }; + + const selector = { + userId: intId, + meetingId, + }; + + const modifier = { + $set: userData, + }; + + try { + const { insertedId } = await UsersPersistentData.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added user id=${intId} to user persistent Data: meeting=${meetingId}`); + } else { + Logger.info(`Upserted user id=${intId} to user persistent Data: meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding note to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js new file mode 100644 index 00000000..918767e5 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js @@ -0,0 +1,25 @@ +import Logger from '/imports/startup/server/logger'; +import UsersPersistentData from '/imports/api/users-persistent-data'; + +export default async function changeHasConnectionStatus(hasConnectionStatus, userId, meetingId) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + 'shouldPersist.hasConnectionStatus': hasConnectionStatus, + }, + }; + + try { + const numberAffected = await UsersPersistentData.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Changed hasConnectionStatus=${hasConnectionStatus} id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Change hasConnectionStatus error: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js new file mode 100644 index 00000000..77b186f6 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js @@ -0,0 +1,30 @@ +import Logger from '/imports/startup/server/logger'; +import UsersPersistentData from '/imports/api/users-persistent-data'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id; + +export default async function changeHasMessages(hasMessages, userId, meetingId, chatId) { + const selector = { + meetingId, + userId, + }; + + const type = chatId === PUBLIC_GROUP_CHAT_KEY ? 'public' : 'private'; + + const modifier = { + $set: { + [`shouldPersist.hasMessages.${type}`]: hasMessages, + }, + }; + + try { + const numberAffected = await UsersPersistentData.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Changed hasMessages=${hasMessages} id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Change hasMessages error: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js new file mode 100644 index 00000000..19bbe807 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js @@ -0,0 +1,30 @@ +import Logger from '/imports/startup/server/logger'; +import UsersPersistentData from '/imports/api/users-persistent-data'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id; + +export default async function clearChatHasMessages(meetingId, chatId) { + const selector = { + meetingId, + }; + + const type = chatId === PUBLIC_GROUP_CHAT_KEY ? 'public' : 'private'; + + const modifier = { + $set: { + [`shouldPersist.hasMessages.${type}`]: false, + }, + }; + + try { + const numberAffected = await UsersPersistentData + .updateAsync(selector, modifier, { multi: true }); + + if (numberAffected) { + Logger.info(`Cleared hasMessages meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Clear hasMessages error: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js new file mode 100644 index 00000000..ec659dab --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import UsersPersistentData from '/imports/api/users-persistent-data/index'; + +export default async function clearUsersPersistentData(meetingId) { + if (meetingId) { + try { + const numberAffected = await UsersPersistentData.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared users persistent data (${meetingId})`); + } + } catch (err) { + Logger.error(`Error clearing users persistent data (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await UsersPersistentData.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared users persistent data (all)'); + } + } catch (err) { + Logger.error(`Error clearing users persistent data (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js new file mode 100644 index 00000000..09e95432 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js @@ -0,0 +1,26 @@ +import { check } from 'meteor/check'; +import UsersPersistentData from '/imports/api/users-persistent-data'; +import Logger from '/imports/startup/server/logger'; + +export default async function setloggedOutStatus(userId, meetingId, status = true) { + check(userId, String); + check(meetingId, String); + check(status, Boolean); + + const selector = { + userId, + meetingId, + }; + + const modifier = { + $set: { + loggedOut: status, + }, + }; + + try { + await UsersPersistentData.updateAsync(selector, modifier); + } catch (err) { + Logger.error(`Setting users persistent data's logged out status to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js new file mode 100644 index 00000000..95c97af9 --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js @@ -0,0 +1,26 @@ +import { check } from 'meteor/check'; +import UsersPersistentData from '/imports/api/users-persistent-data'; +import Logger from '/imports/startup/server/logger'; + +export default async function updateRole(userId, meetingId, role) { + check(userId, String); + check(meetingId, String); + check(role, String); + + const selector = { + userId, + meetingId, + }; + + const modifier = { + $set: { + role, + }, + }; + + try { + await UsersPersistentData.updateAsync(selector, modifier); + } catch (err) { + Logger.error(`Updating users persistent data's role to the collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js new file mode 100644 index 00000000..b24ef57d --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js @@ -0,0 +1,38 @@ +import { check } from 'meteor/check'; +import UsersPersistentData from '/imports/api/users-persistent-data'; +import Logger from '/imports/startup/server/logger'; +import Breakouts from '/imports/api/breakouts'; + +export default async function updateUserBreakoutRoom(meetingId, breakoutId, users) { + check(meetingId, String); + check(breakoutId, String); + check(users, Array); + + const lastBreakoutRoom = await Breakouts.findOneAsync({ breakoutId }, { + fields: { + isDefaultName: 1, + sequence: 1, + shortName: 1, + }, + }); + await Promise.all(users.map(async (user) => { + const userId = user.id.substr(0, user.id.lastIndexOf('-')); + + const selector = { + userId, + meetingId, + }; + + const modifier = { + $set: { + lastBreakoutRoom, + }, + }; + + try { + await UsersPersistentData.updateAsync(selector, modifier); + } catch (err) { + Logger.error(`Updating users persistent data's lastBreakoutRoom to the collection: ${err}`); + } + })); +} diff --git a/src/2.7.12/imports/api/users-persistent-data/server/publishers.js b/src/2.7.12/imports/api/users-persistent-data/server/publishers.js new file mode 100644 index 00000000..3eb78e0c --- /dev/null +++ b/src/2.7.12/imports/api/users-persistent-data/server/publishers.js @@ -0,0 +1,54 @@ +import UsersPersistentData from '/imports/api/users-persistent-data'; +import { Meteor } from 'meteor/meteor'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +async function usersPersistentData() { + if (!this.userId) { + return UsersPersistentData.find({ meetingId: '' }); + } + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const selector = { + meetingId, + }; + + const options = {}; + + const User = await Users + .findOneAsync({ userId: requesterUserId, meetingId }, { fields: { role: 1 } }); + if (!User || User.role !== ROLE_MODERATOR) { + options.fields = { + lastBreakoutRoom: false, + }; + + // viewers are allowed to see other users' data if: + // user is logged in or user sent a message in chat + const viewerSelector = { + meetingId, + $or: [ + { + 'shouldPersist.hasMessages': true, + }, + { + loggedOut: false, + }, + ], + }; + return UsersPersistentData.find(viewerSelector, options); + } + return UsersPersistentData.find(selector, options); +} + +function publishUsersPersistentData(...args) { + const boundUsers = usersPersistentData.bind(this); + return boundUsers(...args); +} + +Meteor.publish('users-persistent-data', publishUsersPersistentData); diff --git a/src/2.7.12/imports/api/users-settings/index.js b/src/2.7.12/imports/api/users-settings/index.js new file mode 100644 index 00000000..7c822af5 --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/index.js @@ -0,0 +1,15 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const UserSettings = new Mongo.Collection('users-settings', collectionOptions); + +if (Meteor.isServer) { + UserSettings.createIndexAsync({ + meetingId: 1, userId: 1, + }); +} + +export default UserSettings; diff --git a/src/2.7.12/imports/api/users-settings/server/index.js b/src/2.7.12/imports/api/users-settings/server/index.js new file mode 100644 index 00000000..cb2e6664 --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/server/index.js @@ -0,0 +1,2 @@ +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/users-settings/server/methods.js b/src/2.7.12/imports/api/users-settings/server/methods.js new file mode 100644 index 00000000..85bcb7d1 --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/server/methods.js @@ -0,0 +1,6 @@ +import { Meteor } from 'meteor/meteor'; +import addUserSettings from './methods/addUserSettings'; + +Meteor.methods({ + addUserSettings, +}); diff --git a/src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js b/src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js new file mode 100644 index 00000000..31d7f8af --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js @@ -0,0 +1,137 @@ +import { check } from 'meteor/check'; +import addUserSetting from '/imports/api/users-settings/server/modifiers/addUserSetting'; +import logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +const oldParameters = { + askForFeedbackOnLogout: 'bbb_ask_for_feedback_on_logout', + autoJoin: 'bbb_auto_join_audio', + autoShareWebcam: 'bbb_auto_share_webcam', + clientTitle: 'bbb_client_title', + customStyle: 'bbb_custom_style', + customStyleUrl: 'bbb_custom_style_url', + displayBrandingArea: 'bbb_display_branding_area', + enableVideo: 'bbb_enable_video', + forceListenOnly: 'bbb_force_listen_only', + hidePresentationOnJoin: 'bbb_hide_presentation', + listenOnlyMode: 'bbb_listen_only_mode', + multiUserPenOnly: 'bbb_multi_user_pen_only', + multiUserTools: 'bbb_multi_user_tools', + presenterTools: 'bbb_presenter_tools', + shortcuts: 'bbb_shortcuts', + skipCheck: 'bbb_skip_check_audio', +}; + +const oldParametersKeys = Object.keys(oldParameters); + +const currentParameters = [ + // APP + 'bbb_ask_for_feedback_on_logout', + 'bbb_override_default_locale', + 'bbb_auto_join_audio', + 'bbb_client_title', + 'bbb_force_listen_only', + 'bbb_listen_only_mode', + 'bbb_skip_check_audio', + 'bbb_skip_check_audio_on_first_join', + 'bbb_fullaudio_bridge', + 'bbb_transparent_listen_only', + 'bbb_show_animations_default', + // BRANDING + 'bbb_display_branding_area', + // SHORTCUTS + 'bbb_shortcuts', + // KURENTO + 'bbb_auto_share_webcam', + 'bbb_preferred_camera_profile', + 'bbb_enable_video', + 'bbb_record_video', + 'bbb_skip_video_preview', + 'bbb_skip_video_preview_on_first_join', + 'bbb_mirror_own_webcam', + // PRESENTATION + 'bbb_force_restore_presentation_on_new_events', + // WHITEBOARD + 'bbb_multi_user_pen_only', + 'bbb_presenter_tools', + 'bbb_multi_user_tools', + // SKINNING/THEMMING + 'bbb_custom_style', + 'bbb_custom_style_url', + // LAYOUT + 'bbb_hide_presentation_on_join', + 'bbb_show_participants_on_login', + 'bbb_show_public_chat_on_login', + 'bbb_hide_actions_bar', + 'bbb_hide_nav_bar', + 'bbb_change_layout', + 'bbb_direct_leave_button', + // TRANSCRIPTION + 'bbb_transcription_partial_utterances', + 'bbb_transcription_min_utterance_length', + 'bbb_transcription_provider', +]; + +function valueParser(val) { + try { + const parsedValue = JSON.parse(val.toLowerCase().trim()); + return parsedValue; + } catch (error) { + logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`); + return val; + } +} + +export default function addUserSettings(settings) { + try { + check(settings, [Object]); + + const { meetingId, requesterUserId: userId } = extractCredentials(this.userId); + + check(meetingId, String); + check(userId, String); + + let parameters = {}; + + settings.forEach((el) => { + const settingKey = Object.keys(el).shift(); + const normalizedKey = settingKey.trim(); + + if (currentParameters.includes(normalizedKey)) { + if (!Object.keys(parameters).includes(normalizedKey)) { + parameters = { + [normalizedKey]: valueParser(el[settingKey]), + ...parameters, + }; + } else { + parameters[normalizedKey] = el[settingKey]; + } + return; + } + + if (oldParametersKeys.includes(normalizedKey)) { + const matchingNewKey = oldParameters[normalizedKey]; + if (!Object.keys(parameters).includes(matchingNewKey)) { + parameters = { + [matchingNewKey]: valueParser(el[settingKey]), + ...parameters, + }; + } + return; + } + + logger.warn(`Parameter ${normalizedKey} not handled`); + }); + + const settingsAdded = []; + Object.entries(parameters).forEach((el) => { + const setting = el[0]; + const value = el[1]; + settingsAdded.push(addUserSetting(meetingId, userId, setting, value)); + }); + + return settingsAdded; + } catch (err) { + logger.error(`Exception while invoking method addUserSettings ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js b/src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js new file mode 100644 index 00000000..682e67ae --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js @@ -0,0 +1,34 @@ +import { check } from 'meteor/check'; +import UserSettings from '/imports/api/users-settings'; +import Logger from '/imports/startup/server/logger'; + +export default async function addUserSetting(meetingId, userId, setting, value) { + check(meetingId, String); + check(userId, String); + check(setting, String); + check(value, Match.Any); + + const selector = { + meetingId, + userId, + setting, + }; + const modifier = { + $set: { + meetingId, + userId, + setting, + value, + }, + }; + + try { + const { numberAffected } = await UserSettings.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.verbose('Upserted user setting', { meetingId, userId, setting }); + } + } catch (err) { + Logger.error(`Adding user setting to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js b/src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js new file mode 100644 index 00000000..2b6bac05 --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js @@ -0,0 +1,14 @@ +import UserSettings from '/imports/api/users-settings'; +import Logger from '/imports/startup/server/logger'; + +export default async function clearUsersSettings(meetingId) { + try { + const numberAffected = await UserSettings.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared User Settings (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing User Settings (${meetingId}). ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users-settings/server/publishers.js b/src/2.7.12/imports/api/users-settings/server/publishers.js new file mode 100644 index 00000000..2bce6ed0 --- /dev/null +++ b/src/2.7.12/imports/api/users-settings/server/publishers.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import UserSettings from '/imports/api/users-settings'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function userSettings() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing UserSettings was requested by unauth connection ${this.connection.id}`); + return UserSettings.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing UserSettings', { meetingId, userId }); + + return UserSettings.find({ meetingId, userId }); +} + +function publish(...args) { + const boundUserSettings = userSettings.bind(this); + return boundUserSettings(...args); +} + +Meteor.publish('users-settings', publish); diff --git a/src/2.7.12/imports/api/users/index.js b/src/2.7.12/imports/api/users/index.js new file mode 100644 index 00000000..3b867291 --- /dev/null +++ b/src/2.7.12/imports/api/users/index.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const Users = new Mongo.Collection('users', collectionOptions); +export const CurrentUser = new Mongo.Collection('current-user', { connection: null }); + +if (Meteor.isServer) { + // types of queries for the users: + // 1. meetingId + // 2. meetingId, userId + // { connection: Meteor.isClient ? null : true } + Users.createIndexAsync({ meetingId: 1, userId: 1 }); +} + +export default Users; diff --git a/src/2.7.12/imports/api/users/server/eventHandlers.js b/src/2.7.12/imports/api/users/server/eventHandlers.js new file mode 100644 index 00000000..405261fe --- /dev/null +++ b/src/2.7.12/imports/api/users/server/eventHandlers.js @@ -0,0 +1,30 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleRemoveUser from './handlers/removeUser'; +import handleUserJoined from './handlers/userJoined'; +import handleUserLeftFlagUpdated from './handlers/userLeftFlagUpdated'; +import handleValidateAuthToken from './handlers/validateAuthToken'; +import handlePresenterAssigned from './handlers/presenterAssigned'; +import handleEmojiStatus from './handlers/emojiStatus'; +import handleChangeRole from './handlers/changeRole'; +import handleUserPinChanged from './handlers/userPinChanged'; +import handleUserInactivityInspect from './handlers/userInactivityInspect'; +import handleChangeMobileFlag from './handlers/changeMobileFlag'; +import handleChangeRaiseHand from './handlers/changeRaiseHand'; +import handleAway from './handlers/changeAway'; +import handleClearUsersEmoji from './handlers/clearUsersEmoji'; +import handleUserSpeechLocaleChanged from './handlers/userSpeechLocaleChanged'; + +RedisPubSub.on('PresenterAssignedEvtMsg', handlePresenterAssigned); +RedisPubSub.on('UserJoinedMeetingEvtMsg', handleUserJoined); +RedisPubSub.on('UserLeftMeetingEvtMsg', handleRemoveUser); +RedisPubSub.on('ValidateAuthTokenRespMsg', handleValidateAuthToken); +RedisPubSub.on('UserEmojiChangedEvtMsg', handleEmojiStatus); +RedisPubSub.on('ClearedAllUsersEmojiEvtMsg', handleClearUsersEmoji); +RedisPubSub.on('UserRaiseHandChangedEvtMsg', handleChangeRaiseHand); +RedisPubSub.on('UserAwayChangedEvtMsg', handleAway); +RedisPubSub.on('UserRoleChangedEvtMsg', handleChangeRole); +RedisPubSub.on('UserMobileFlagChangedEvtMsg', handleChangeMobileFlag); +RedisPubSub.on('UserLeftFlagUpdatedEvtMsg', handleUserLeftFlagUpdated); +RedisPubSub.on('UserPinStateChangedEvtMsg', handleUserPinChanged); +RedisPubSub.on('UserInactivityInspectMsg', handleUserInactivityInspect); +RedisPubSub.on('UserSpeechLocaleChangedEvtMsg', handleUserSpeechLocaleChanged); diff --git a/src/2.7.12/imports/api/users/server/handlers/changeAway.js b/src/2.7.12/imports/api/users/server/handlers/changeAway.js new file mode 100644 index 00000000..eff0b91e --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/changeAway.js @@ -0,0 +1,11 @@ +import { check } from 'meteor/check'; +import changeAway from '/imports/api/users/server/modifiers/changeAway'; + +export default async function handleAway(payload, meetingId) { + check(payload.body, Object); + check(meetingId, String); + + const { userId: requesterUserId, away } = payload.body; + + await changeAway(meetingId, requesterUserId, away); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js b/src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js new file mode 100644 index 00000000..3ac64656 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import setMobile from '/imports/api/users/server/modifiers/setMobile'; + +export default async function handleChangeMobileFlag(payload, meetingId) { + check(payload.body, Object); + check(meetingId, String); + + const { userId: requesterUserId, mobile } = payload.body; + + if (mobile) { + await setMobile(meetingId, requesterUserId); + } +} diff --git a/src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js b/src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js new file mode 100644 index 00000000..6cef5bee --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js @@ -0,0 +1,11 @@ +import { check } from 'meteor/check'; +import changeRaiseHand from '/imports/api/users/server/modifiers/changeRaiseHand'; + +export default async function handleChangeRaiseHand(payload, meetingId) { + check(payload.body, Object); + check(meetingId, String); + + const { userId: requesterUserId, raiseHand } = payload.body; + + await changeRaiseHand(meetingId, requesterUserId, raiseHand); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/changeRole.js b/src/2.7.12/imports/api/users/server/handlers/changeRole.js new file mode 100644 index 00000000..3039313c --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/changeRole.js @@ -0,0 +1,11 @@ +import { check } from 'meteor/check'; +import changeRole from '/imports/api/users/server/modifiers/changeRole'; + +export default async function handleChangeRole(payload, meetingId) { + check(payload.body, Object); + check(meetingId, String); + + const { userId, role, changedBy } = payload.body; + + await changeRole(role, userId, meetingId, changedBy); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js b/src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js new file mode 100644 index 00000000..3f9ef63c --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js @@ -0,0 +1,7 @@ +import { check } from 'meteor/check'; +import clearUsersEmoji from '../modifiers/clearUsersEmoji'; + +export default function handleClearUsersEmoji({ body }, meetingId) { + check(meetingId, String); + clearUsersEmoji(meetingId); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/emojiStatus.js b/src/2.7.12/imports/api/users/server/handlers/emojiStatus.js new file mode 100644 index 00000000..4f67a4b4 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/emojiStatus.js @@ -0,0 +1,32 @@ +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; + +export default async function handleEmojiStatus({ body }, meetingId) { + const { userId, emoji } = body; + + check(userId, String); + check(emoji, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + emojiTime: (new Date()).getTime(), + emoji, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Assigned user emoji status ${emoji} id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Assigning user emoji status: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js b/src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js new file mode 100644 index 00000000..a84dc87b --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js @@ -0,0 +1,64 @@ +import Users from '/imports/api/users'; +import PresentationPods from '/imports/api/presentation-pods'; +import changePresenter from '/imports/api/users/server/modifiers/changePresenter'; +import RedisPubSub from '/imports/startup/server/redis'; + +function setPresenterInPodReqMsg(credentials) { // TODO-- switch to meetingId, etc + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetPresenterInPodReqMsg'; + + const { meetingId, requesterUserId, presenterId } = credentials; + + const payload = { + podId: 'DEFAULT_PRESENTATION_POD', + nextPresenterId: presenterId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); +} + +export default async function handlePresenterAssigned({ body }, meetingId) { + const { presenterId, assignedBy } = body; + + await changePresenter(true, presenterId, meetingId, assignedBy); + + const selector = { + meetingId, + userId: { $ne: presenterId }, + presenter: true, + }; + + const defaultPodSelector = { + meetingId, + podId: 'DEFAULT_PRESENTATION_POD', + }; + + const currentDefaultPod = await PresentationPods.findOneAsync(defaultPodSelector); + + const setPresenterPayload = { + meetingId, + requesterUserId: assignedBy, + presenterId, + }; + + const prevPresenter = await Users.findOneAsync(selector); + + if (prevPresenter) { + await changePresenter(false, prevPresenter.userId, meetingId, assignedBy); + } + + /** + * In the cases where the first moderator joins the meeting or + * the current presenter left the meeting, akka-apps doesn't assign the new presenter + * to the default presentation pod. This step is done manually here. + */ + + if (currentDefaultPod.currentPresenterId !== presenterId) { + const presenterToBeAssigned = await Users.findOneAsync({ userId: presenterId }); + + if (!presenterToBeAssigned) setPresenterPayload.presenterId = ''; + + setPresenterInPodReqMsg(setPresenterPayload); + } +} diff --git a/src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js b/src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js new file mode 100644 index 00000000..bff5d4d2 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js @@ -0,0 +1,32 @@ +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; + +export default async function handleReactionEmoji({ body }, meetingId) { + const { userId, reactionEmoji } = body; +aaa + check(userId, String); + check(reactionEmoji, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + reactionEmojiTime: (new Date()).getTime(), + reactionEmoji, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Assigned user rectionEmoji ${reactionEmoji} id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Assigning user reactionEmoji: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/handlers/removeUser.js b/src/2.7.12/imports/api/users/server/handlers/removeUser.js new file mode 100644 index 00000000..60ba2242 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/removeUser.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; + +import removeUser from '../modifiers/removeUser'; + +export default async function handleRemoveUser({ body }, meetingId) { + const { intId } = body; + + check(meetingId, String); + check(intId, String); + + const result = await removeUser(body, meetingId); + return result; +} diff --git a/src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js b/src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js new file mode 100644 index 00000000..b94efe5a --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import userInactivityInspect from '../modifiers/userInactivityInspect'; + +export default async function handleUserInactivityInspect({ header, body }, meetingId) { + const { userId } = header; + const { responseDelay } = body; + + check(userId, String); + check(responseDelay, Match.Integer); + check(meetingId, String); + + await userInactivityInspect(userId, responseDelay); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/userJoin.js b/src/2.7.12/imports/api/users/server/handlers/userJoin.js new file mode 100644 index 00000000..93ecd8bd --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/userJoin.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; + +export default function userJoin(meetingId, userId, authToken) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserJoinMeetingReqMsg'; + + check(authToken, String); + + const payload = { + userId, + authToken, + clientType: 'HTML5', + }; + + Logger.info(`User='${userId}' is joining meeting='${meetingId}' authToken='${authToken}'`); + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/userJoined.js b/src/2.7.12/imports/api/users/server/handlers/userJoined.js new file mode 100644 index 00000000..a0e237c0 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/userJoined.js @@ -0,0 +1,11 @@ +import { check } from 'meteor/check'; + +import addUser from '../modifiers/addUser'; + +export default async function handleUserJoined({ body }, meetingId) { + const user = body; + + check(user, Object); + + await addUser(meetingId, user); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js b/src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js new file mode 100644 index 00000000..8041c10a --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; + +import userLeftFlag from '../modifiers/userLeftFlagUpdated'; + +export default async function handleUserLeftFlag({ body }, meetingId) { + const user = body; + check(user, { + intId: String, + userLeftFlag: Boolean, + }); + + await userLeftFlag(meetingId, user.intId, user.userLeftFlag); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/userPinChanged.js b/src/2.7.12/imports/api/users/server/handlers/userPinChanged.js new file mode 100644 index 00000000..15e8b30c --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/userPinChanged.js @@ -0,0 +1,14 @@ +import { check } from 'meteor/check'; +import changePin from '../modifiers/changePin'; + +export default async function handlePinAssigned({ body }, meetingId) { + const { userId, pin, changedBy } = body; + + check(meetingId, String); + check(userId, String); + check(pin, Boolean); + check(changedBy, String); + + const result = await changePin(meetingId, userId, pin, changedBy); + return result; +} diff --git a/src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js b/src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js new file mode 100644 index 00000000..205cfee3 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import updateSpeechLocale from '../modifiers/updateSpeechLocale'; + +export default function handleUserSpeechLocaleChanged({ body, header }, meetingId) { + const { locale } = body; + const { userId } = header; + + check(meetingId, String); + check(userId, String); + check(locale, String); + + return updateSpeechLocale(meetingId, userId, locale); +} diff --git a/src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js b/src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js new file mode 100644 index 00000000..d7c8d9eb --- /dev/null +++ b/src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js @@ -0,0 +1,157 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import userJoin from './userJoin'; +import pendingAuthenticationsStore from '../store/pendingAuthentications'; +import createDummyUser from '../modifiers/createDummyUser'; +import updateUserConnectionId from '../modifiers/updateUserConnectionId'; +import ClientConnections from '/imports/startup/server/ClientConnections'; + +import upsertValidationState from '/imports/api/auth-token-validation/server/modifiers/upsertValidationState'; +import { ValidationStates } from '/imports/api/auth-token-validation'; + +const clearOtherSessions = (sessionUserId, current = false) => { + const serverSessions = Meteor.server.sessions; + Object.keys(serverSessions) + .filter(i => serverSessions[i].userId === sessionUserId) + .filter(i => i !== current) + .forEach(i => serverSessions[i].close()); +}; + +export default async function handleValidateAuthToken({ body }, meetingId) { + const { + userId, + valid, + authToken, + waitForApproval, + registeredOn, + authTokenValidatedOn, + reasonCode, + } = body; + + check(userId, String); + check(authToken, String); + check(valid, Boolean); + check(waitForApproval, Boolean); + check(registeredOn, Number); + check(authTokenValidatedOn, Number); + check(reasonCode, String); + + const pendingAuths = pendingAuthenticationsStore.take(meetingId, userId, authToken); + const printablePendingAuthStore = pendingAuths.map((a) => { + return { meetingId: a.meetingId, userId: a.userId, authToken: a.authToken } + }) + Logger.info(`PendingAuths length [${pendingAuths.length}]: ${JSON.stringify(printablePendingAuthStore)}`); + if (pendingAuths.length === 0) return; + + if (!valid) { + await Promise.all(pendingAuths.map( + async (pendingAuth) => { + try { + const { methodInvocationObject } = pendingAuth; + const connectionId = methodInvocationObject.connection.id; + + await upsertValidationState( + meetingId, + userId, + ValidationStates.INVALID, + connectionId, + reasonCode, + ); + + // Schedule socket disconnection for this user + // giving some time for client receiving the reason of disconnection + Logger.info(`Scheduling socket disconnection for user ${userId} ${connectionId} due to invalid auth token`); + new Promise((resolve) => { + Meteor.setTimeout(() => { + methodInvocationObject.connection.close(); + Logger.info(`Closed connection ${connectionId} due to invalid auth token.`); + resolve(); + }, 2000); + }); + } catch (e) { + Logger.error(`Error closing socket for meetingId '${meetingId}', userId '${userId}', authToken ${authToken}`); + } + }, + )); + + return; + } + + // Define user ID on connections + await Promise.all(pendingAuths.map( + async (pendingAuth) => { + const { methodInvocationObject } = pendingAuth; + + /* Logic migrated from validateAuthToken method + ( postponed to only run in case of success response ) - Begin */ + const sessionId = `${meetingId}--${userId}`; + + methodInvocationObject.setUserId(sessionId); + + const User = await Users.findOneAsync({ + meetingId, + userId, + }); + + if (!User) { + await createDummyUser(meetingId, userId, authToken); + } else { + await updateUserConnectionId(meetingId, userId, methodInvocationObject.connection.id); + } + + ClientConnections.add(sessionId, methodInvocationObject.connection); + await upsertValidationState( + meetingId, + userId, + ValidationStates.VALIDATED, + methodInvocationObject.connection.id, + ); + + /* End of logic migrated from validateAuthToken */ + }, + )); + + const selector = { + meetingId, + userId, + clientType: 'HTML5', + }; + + const User = await Users.findOneAsync(selector); + + // If we dont find the user on our collection is a flash user and we can skip + if (!User) return; + + // Publish user join message + if (!waitForApproval) { + Logger.info('User=', User); + userJoin(meetingId, userId, User.authToken); + } + + const modifier = { + $set: { + validated: valid, + approved: !waitForApproval, + loginTime: registeredOn, + authTokenValidatedTime: authTokenValidatedOn, + inactivityCheck: false, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + const sessionUserId = `${meetingId}-${userId}`; + const currentConnectionId = User.connectionId ? User.connectionId : false; + clearOtherSessions(sessionUserId, currentConnectionId); + + Logger.info(`Validated auth token as ${valid} user=${userId} meeting=${meetingId}`); + } else { + Logger.info('No auth to validate'); + } + } catch (err) { + Logger.error(`Validating auth token: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/index.js b/src/2.7.12/imports/api/users/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/users/server/methods.js b/src/2.7.12/imports/api/users/server/methods.js new file mode 100644 index 00000000..f6f7303f --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods.js @@ -0,0 +1,42 @@ +import { Meteor } from 'meteor/meteor'; +import validateAuthToken from './methods/validateAuthToken'; +import setSpeechLocale from './methods/setSpeechLocale'; +import setSpeechOptions from './methods/setSpeechOptions'; +import setMobileUser from './methods/setMobileUser'; +import setEmojiStatus from './methods/setEmojiStatus'; +import changeAway from './methods/changeAway'; +import changeRaiseHand from './methods/changeRaiseHand'; +import assignPresenter from './methods/assignPresenter'; +import changeRole from './methods/changeRole'; +import removeUser from './methods/removeUser'; +import toggleUserLock from './methods/toggleUserLock'; +import toggleUserChatLock from './methods/toggleUserChatLock'; +import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType'; +import userActivitySign from './methods/userActivitySign'; +import userLeftMeeting from './methods/userLeftMeeting'; +import changePin from './methods/changePin'; +import setRandomUser from './methods/setRandomUser'; +import setExitReason from './methods/setExitReason'; +import clearAllUsersEmoji from './methods/clearAllUsersEmoji'; + +Meteor.methods({ + setSpeechLocale, + setSpeechOptions, + setMobileUser, + setEmojiStatus, + clearAllUsersEmoji, + changeAway, + changeRaiseHand, + assignPresenter, + changeRole, + removeUser, + validateAuthToken, + toggleUserLock, + toggleUserChatLock, + setUserEffectiveConnectionType, + userActivitySign, + userLeftMeeting, + changePin, + setRandomUser, + setExitReason, +}); diff --git a/src/2.7.12/imports/api/users/server/methods/assignPresenter.js b/src/2.7.12/imports/api/users/server/methods/assignPresenter.js new file mode 100644 index 00000000..eb6284fd --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/assignPresenter.js @@ -0,0 +1,41 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default async function assignPresenter(userId) { // TODO-- send username from client side + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'AssignPresenterReqMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(userId, String); + + const User = await Users.findOneAsync({ + meetingId, + userId, + }); + + if (!User) { + throw new Meteor.Error('user-not-found', 'You need a valid user to be able to set presenter'); + } + + const payload = { + newPresenterId: userId, + newPresenterName: User.name, + assignedBy: requesterUserId, + requesterId: requesterUserId, + }; + + Logger.verbose('User set as presenter', { userId, meetingId, setBy: requesterUserId }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method assignPresenter ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/changeAway.js b/src/2.7.12/imports/api/users/server/methods/changeAway.js new file mode 100644 index 00000000..929dfad5 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/changeAway.js @@ -0,0 +1,31 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import RedisPubSub from '/imports/startup/server/redis'; + +export default async function changeAway(away) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserAwayReqMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(away, Boolean); + + const payload = { + userId: requesterUserId, + away, + }; + + Logger.verbose('Updated away status for user', { + meetingId, requesterUserId, away, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method changeAway ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/changePin.js b/src/2.7.12/imports/api/users/server/methods/changePin.js new file mode 100644 index 00000000..7c387136 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/changePin.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function changePin(userId, pin) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserPinStateReqMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(userId, String); + check(pin, Boolean); + + const payload = { + pin, + changedBy: requesterUserId, + userId, + }; + + Logger.verbose('User pin requested', { + userId, meetingId, changedBy: requesterUserId, pin, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method changePin ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js b/src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js new file mode 100644 index 00000000..77d7d8b7 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js @@ -0,0 +1,31 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import RedisPubSub from '/imports/startup/server/redis'; + +export default async function changeRaiseHand(raiseHand, userId = undefined) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserRaiseHandReqMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(raiseHand, Boolean); + + const payload = { + userId: userId || requesterUserId, + raiseHand, + }; + + Logger.verbose('Updated raiseHand status for user', { + meetingId, requesterUserId, raiseHand, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method changeRaiseHand ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/changeRole.js b/src/2.7.12/imports/api/users/server/methods/changeRole.js new file mode 100644 index 00000000..65cd2fbc --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/changeRole.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function changeRole(userId, role) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserRoleCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(userId, String); + check(role, String); + + const payload = { + userId, + role, + changedBy: requesterUserId, + }; + + Logger.verbose('Changed user role', { + userId, role, changedBy: requesterUserId, meetingId, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method changeRole ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js b/src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js new file mode 100644 index 00000000..916f31db --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function clearAllUsersEmoji() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ClearAllUsersEmojiCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + userId: requesterUserId, + }; + + Logger.verbose('Sending clear all users emojis', { + requesterUserId, meetingId, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setUserReaction ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/removeUser.js b/src/2.7.12/imports/api/users/server/methods/removeUser.js new file mode 100644 index 00000000..7a8c222b --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/removeUser.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function removeUser(userId, banUser) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'EjectUserFromMeetingCmdMsg'; + + const { meetingId, requesterUserId: ejectedBy } = extractCredentials(this.userId); + + check(meetingId, String); + check(ejectedBy, String); + check(userId, String); + check(banUser, Boolean); + + const payload = { + userId, + ejectedBy, + banUser, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, ejectedBy, payload); + } catch (err) { + Logger.error(`Exception while invoking method removeUser ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js b/src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js new file mode 100644 index 00000000..d2c082d7 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js @@ -0,0 +1,72 @@ +import { Meteor } from 'meteor/meteor'; + +import Meetings from '/imports/api/meetings'; +import Users from '/imports/api/users'; +import addSystemMsg from '/imports/api/group-chat-msg/server/modifiers/addSystemMsg'; + +const ROLE_VIEWER = Meteor.settings.public.user.role_viewer; +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; +const CHAT_USER_STATUS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_status_message; +const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system; + +export default function sendAwayStatusChatMsg(meetingId, userId, newAwayStatus) { + const user = Users.findOne( + { meetingId, userId }, + { + fields: { + name: 1, + role: 1, + locked: 1, + away: 1, + }, + }, + ); + + if (!user) return null; + + // Check for viewer permissions + if (user.role === ROLE_VIEWER && user.locked) { + const meeting = Meetings.findOne( + { meetingId }, + { fields: { 'lockSettingsProps.disablePublicChat': 1 } }, + ); + + if (!meeting) return null; + + // Return if viewer has his public chat disabled + const { lockSettingsProps } = meeting; + if (lockSettingsProps && lockSettingsProps.disablePublicChat) { + return null; + } + } + + // Send message if previous emoji or actual emoji is 'away' + let status; + if (user.away && !newAwayStatus) { + status = 'notAway'; + } else if (!user.away && newAwayStatus) { + status = 'away'; + } else { + return null; + } + + const extra = { + type: 'status', + status, + }; + + const payload = { + id: `${SYSTEM_CHAT_TYPE}-${CHAT_USER_STATUS_MESSAGE}`, + timestamp: Date.now(), + correlationId: `${userId}-${Date.now()}`, + sender: { + id: userId, + name: user.name, + }, + message: '', + extra, + }; + + return addSystemMsg(meetingId, PUBLIC_GROUP_CHAT_ID, payload); +} diff --git a/src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js b/src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js new file mode 100644 index 00000000..dd799886 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function setEmojiStatus(userId, status) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserEmojiCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(userId, String); + check(status, String); + + const payload = { + emoji: status, + userId, + }; + + Logger.verbose('User emoji status updated', { + userId, status, requesterUserId, meetingId, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setEmojiStatus ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/setExitReason.js b/src/2.7.12/imports/api/users/server/methods/setExitReason.js new file mode 100644 index 00000000..335a31ba --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setExitReason.js @@ -0,0 +1,21 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import setUserExitReason from '/imports/api/users/server/modifiers/setUserExitReason'; + +export default async function setExitReason(reason) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + // Unauthenticated user, just ignore and go ahead. + if (!meetingId || !requesterUserId) return; + + check(meetingId, String); + check(requesterUserId, String); + check(reason, String); + + await setUserExitReason(meetingId, requesterUserId, reason); + } catch (err) { + Logger.error(`Exception while invoking method setExitReason ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/setMobileUser.js b/src/2.7.12/imports/api/users/server/methods/setMobileUser.js new file mode 100644 index 00000000..cc329598 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setMobileUser.js @@ -0,0 +1,28 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import RedisPubSub from '/imports/startup/server/redis'; + +export default async function setMobileUser() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserMobileFlagReqMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + userId: requesterUserId, + mobile: true, + }; + + Logger.verbose(`Mobile user ${requesterUserId} from meeting ${meetingId}`); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setMobileUser ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/setRandomUser.js b/src/2.7.12/imports/api/users/server/methods/setRandomUser.js new file mode 100644 index 00000000..aa751daa --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setRandomUser.js @@ -0,0 +1,26 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default function setRandomUser() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SelectRandomViewerReqMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + requestedBy: requesterUserId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setRandomUser ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js b/src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js new file mode 100644 index 00000000..0076f853 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +const LANGUAGES = Meteor.settings.public.app.audioCaptions.language.available; + +export default function setSpeechLocale(locale, provider) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetUserSpeechLocaleReqMsg'; + + check(meetingId, String); + check(requesterUserId, String); + check(locale, String); + check(provider, String); + + const payload = { + locale, + provider: provider !== 'webspeech' ? provider : '', + }; + + if (LANGUAGES.includes(locale) || locale === '' || locale === 'auto') { + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } + } catch (err) { + Logger.error(`Exception while invoking method setSpeechLocale ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js b/src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js new file mode 100644 index 00000000..6b8b5835 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default async function setSpeechOptions(partialUtterances, minUtteranceLength) { + try { + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'SetUserSpeechOptionsReqMsg'; + + Logger.info(`Setting speech options for ${meetingId} ${requesterUserId} ${partialUtterances} ${minUtteranceLength}`); + + check(meetingId, String); + check(requesterUserId, String); + check(partialUtterances, Boolean); + check(minUtteranceLength, Number); + + const payload = { + partialUtterances, + minUtteranceLength, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (e) { + Logger.error(e); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js b/src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js new file mode 100644 index 00000000..6826d66d --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import setEffectiveConnectionType from '../modifiers/setUserEffectiveConnectionType'; + +export default async function setUserEffectiveConnectionType(effectiveConnectionType) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserEffectiveConnectionMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(effectiveConnectionType, String); + + const payload = { + meetingId, + userId: requesterUserId, + effectiveConnectionType, + }; + + await setEffectiveConnectionType(meetingId, requesterUserId, effectiveConnectionType); + + Logger.verbose('Updated user effective connection', { requesterUserId, effectiveConnectionType }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method setUserEffectiveConnectionType ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js b/src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js new file mode 100644 index 00000000..65942109 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function toggleUserChatLock(userId, isLocked) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'LockUserChatInMeetingCmdMsg'; + + const { meetingId, requesterUserId: lockedBy } = extractCredentials(this.userId); + + check(meetingId, String); + check(lockedBy, String); + check(userId, String); + check(isLocked, Boolean); + + const payload = { + lockedBy, + userId, + isLocked, + }; + + Logger.verbose('Updated chat lock status for user', { + meetingId, userId, isLocked, lockedBy, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, lockedBy, payload); + } catch (err) { + Logger.error(`Exception while invoking method toggleUserChatLock ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/toggleUserLock.js b/src/2.7.12/imports/api/users/server/methods/toggleUserLock.js new file mode 100644 index 00000000..ccb9efc2 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/toggleUserLock.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function toggleUserLock(userId, lock) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'LockUserInMeetingCmdMsg'; + + const { meetingId, requesterUserId: lockedBy } = extractCredentials(this.userId); + + check(meetingId, String); + check(lockedBy, String); + check(userId, String); + check(lock, Boolean); + + const payload = { + lockedBy, + userId, + lock, + }; + + Logger.verbose('Updated lock status for user', { + meetingId, userId, lock, lockedBy, + }); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, lockedBy, payload); + } catch (err) { + Logger.error(`Exception while invoking method toggleUserLock ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/userActivitySign.js b/src/2.7.12/imports/api/users/server/methods/userActivitySign.js new file mode 100644 index 00000000..75722dbf --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/userActivitySign.js @@ -0,0 +1,40 @@ +import { Meteor } from 'meteor/meteor'; +import Users from '/imports/api/users'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +export default async function userActivitySign() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserActivitySignCmdMsg'; + const { meetingId, requesterUserId: userId } = extractCredentials(this.userId); + + check(meetingId, String); + check(userId, String); + + const payload = { + userId, + }; + + const selector = { + userId, + }; + + const modifier = { + $set: { + inactivityCheck: false, + }, + }; + + await Users.updateAsync(selector, modifier); // TODO-- we should move this to a modifier + + Logger.info(`User ${userId} sent a activity sign for meeting ${meetingId}`); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); + } catch (err) { + Logger.error(`Exception while invoking method userActivitySign ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/userLeaving.js b/src/2.7.12/imports/api/users/server/methods/userLeaving.js new file mode 100644 index 00000000..b74044e3 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/userLeaving.js @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation from '/imports/api/auth-token-validation'; +import Users from '/imports/api/users'; +import ClientConnections from '/imports/startup/server/ClientConnections'; + +export default async function userLeaving(meetingId, userId, connectionId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserLeaveReqMsg'; + + check(userId, String); + + const selector = { + meetingId, + userId, + }; + + const user = await Users.findOneAsync(selector); + + if (!user) { + Logger.info(`Skipping userLeaving. Could not find ${userId} in ${meetingId}`); + return; + } + + const auth = await AuthTokenValidation.findOneAsync({ + meetingId, + userId, + }, { sort: { updatedAt: -1 } }); + + // If the current user connection is not the same that triggered the leave we skip + if (auth?.connectionId !== connectionId) { + Logger.info(`Skipping userLeaving. User connectionId=${user.connectionId} is different from requester connectionId=${connectionId}`); + return false; + } + + const payload = { + userId, + sessionId: meetingId, + loggedOut: user.loggedOut || false, + }; + + ClientConnections.removeClientConnection(`${meetingId}--${userId}`, connectionId); + + let reason; + + if (user.loggedOut) { + // User explicitly requested logout. + reason = 'logout'; + } else if (user.exitReason) { + // User didn't requested logout but exited graciously. + reason = user.exitReason; + } else { + // User didn't exit graciously (disconnection). + reason = 'disconnection'; + } + + Logger.info(`User '${userId}' is leaving meeting '${meetingId}' reason=${reason}`); + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); + } catch (err) { + Logger.error(`Exception while invoking method userLeaving ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js b/src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js new file mode 100644 index 00000000..7d4e6166 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js @@ -0,0 +1,32 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import ClientConnections from '/imports/startup/server/ClientConnections'; +import { check } from 'meteor/check'; +import UsersPersistentData from '/imports/api/users-persistent-data'; + +export default async function userLeftMeeting() { + // TODO-- spread the code to method/modifier/handler + try { + // so we don't update the db in a method + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const selector = { + meetingId, + userId: requesterUserId, + }; + + const numberAffected = await Users.updateAsync(selector, { $set: { loggedOut: true } }); + + if (numberAffected) { + await UsersPersistentData.updateAsync(selector, { $set: { loggedOut: true } }); + Logger.info(`user left id=${requesterUserId} meeting=${meetingId}`); + ClientConnections.removeClientConnection(this.userId, this.connection.id); + } + } catch (err) { + Logger.error(`Exception while invoking method userLeftMeeting ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/methods/validateAuthToken.js b/src/2.7.12/imports/api/users/server/methods/validateAuthToken.js new file mode 100644 index 00000000..28efd2c6 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/methods/validateAuthToken.js @@ -0,0 +1,78 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import Logger from '/imports/startup/server/logger'; +import upsertValidationState from '/imports/api/auth-token-validation/server/modifiers/upsertValidationState'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; +import pendingAuthenticationsStore from '../store/pendingAuthentications'; + +const AUTH_TIMEOUT = 120000; + +async function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) { + let setTimeoutRef = null; + const userValidation = await new Promise(async (res, rej) => { + const observeFunc = (obj) => { + if (obj.validationStatus === ValidationStates.VALIDATED) { + Meteor.clearTimeout(setTimeoutRef); + return res(obj); + } + if (obj.validationStatus === ValidationStates.INVALID) { + Meteor.clearTimeout(setTimeoutRef); + return res(obj); + } + }; + const authTokenValidationObserver = AuthTokenValidation.find({ + connectionId: this.connection.id, + }).observe({ + added: observeFunc, + changed: observeFunc, + }); + + setTimeoutRef = Meteor.setTimeout(() => { + authTokenValidationObserver.stop(); + rej(); + }, AUTH_TIMEOUT); + + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ValidateAuthTokenReqMsg'; + + Logger.debug('ValidateAuthToken method called', { meetingId, requesterUserId, requesterToken, externalId }); + + if (!meetingId) return false; + + // Store reference of methodInvocationObject ( to postpone the connection userId definition ) + pendingAuthenticationsStore.add(meetingId, requesterUserId, requesterToken, this); + await upsertValidationState( + meetingId, + requesterUserId, + ValidationStates.VALIDATING, + this.connection.id, + ); + + const payload = { + userId: requesterUserId, + authToken: requesterToken, + }; + + Logger.info(`User '${requesterUserId}' is trying to validate auth token for meeting '${meetingId}' from connection '${this.connection.id}'`); + + return RedisPubSub.publishUserMessage( + CHANNEL, + EVENT_NAME, + meetingId, + requesterUserId, + payload, + ); + } catch (err) { + const errMsg = `Exception while invoking method validateAuthToken ${err}`; + Logger.error(errMsg); + rej(errMsg); + Meteor.clearTimeout(setTimeoutRef); + authTokenValidationObserver.stop(); + } + }); + return userValidation; +} + +export default validateAuthToken; diff --git a/src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js b/src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js new file mode 100644 index 00000000..68d0d04e --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js @@ -0,0 +1,36 @@ +import { check } from 'meteor/check'; +import addUser from '/imports/api/users/server/modifiers/addUser'; + +export default async function addDialInUser(meetingId, voiceUser) { + check(meetingId, String); + check(voiceUser, Object); + + const USER_CONFIG = Meteor.settings.public.user; + const ROLE_VIEWER = USER_CONFIG.role_viewer; + + const { intId, callerName, color } = voiceUser; + + const voiceOnlyUser = { + intId, + extId: intId, // TODO + name: callerName, + role: ROLE_VIEWER.toLowerCase(), + guest: true, + authed: true, + waitingForAcceptance: false, + guestStatus: 'ALLOW', + emoji: 'none', + reactionEmoji: 'none', + raiseHand: false, + away: false, + presenter: false, + locked: false, // TODO + avatar: '', + webcamBackground: '', + color, + pin: false, + clientType: 'dial-in-user', + }; + const user = await addUser(meetingId, voiceOnlyUser); + return user; +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/addUser.js b/src/2.7.12/imports/api/users/server/modifiers/addUser.js new file mode 100644 index 00000000..570dbc12 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/addUser.js @@ -0,0 +1,112 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import Meetings from '/imports/api/meetings'; +import VoiceUsers from '/imports/api/voice-users/'; +import addUserPsersistentData from '/imports/api/users-persistent-data/server/modifiers/addUserPersistentData'; +import flat from 'flat'; +import { lowercaseTrim } from '/imports/utils/string-utils'; + +import addVoiceUser from '/imports/api/voice-users/server/modifiers/addVoiceUser'; + +export default async function addUser(meetingId, userData) { + const user = userData; + + check(meetingId, String); + + check(user, { + intId: String, + extId: String, + name: String, + role: String, + guest: Boolean, + authed: Boolean, + waitingForAcceptance: Match.Maybe(Boolean), + guestStatus: String, + emoji: String, + reactionEmoji: String, + raiseHand: Boolean, + away: Boolean, + presenter: Boolean, + locked: Boolean, + avatar: String, + webcamBackground: String, + color: String, + pin: Boolean, + clientType: String, + userCustomData: Match.Optional(Match.Any), + }); + + const userId = user.intId; + + const selector = { + meetingId, + userId, + }; + const Meeting = await Meetings.findOneAsync({ meetingId }); + + const { userCustomData, ...restOfUser } = user; + + const userInfos = { + meetingId, + sortName: lowercaseTrim(user.name), + speechLocale: '', + mobile: false, + breakoutProps: { + isBreakoutUser: Meeting.meetingProp.isBreakout, + parentId: Meeting.breakoutProps.parentId, + }, + effectiveConnectionType: null, + inactivityCheck: false, + responseDelay: 0, + loggedOut: false, + left: false, + ...flat(restOfUser), + }; + + const modifier = { + $set: userInfos, + }; + await addUserPsersistentData(userInfos); + // Only add an empty VoiceUser if there isn't one already and if the user coming in isn't a + // dial-in user. We want to avoid overwriting good data + const voiceUser = await VoiceUsers.findOneAsync({ meetingId, intId: userId }); + if (user.clientType !== 'dial-in-user' && !voiceUser) { + await addVoiceUser(meetingId, { + voiceUserId: '', + intId: userId, + callerName: user.name, + callerNum: '', + color: user.color, + muted: false, + talking: false, + callingWith: '', + listenOnly: false, + voiceConf: '', + joined: false, + }); + } + + /** + * Add a verification to check if the user was set as presenter. + * In some cases the user information is set after the presenter is set + * causing the first moderator to join a meeting be marked as presenter: false + */ + const partialUser = await Users.findOneAsync(selector); + + if (partialUser?.presenter) { + modifier.$set.presenter = true; + } + + try { + const { insertedId } = await Users.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Added user id=${userId} meeting=${meetingId}`); + } else { + Logger.info(`Upserted user id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Adding user to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/changeAway.js b/src/2.7.12/imports/api/users/server/modifiers/changeAway.js new file mode 100644 index 00000000..c5ec077c --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/changeAway.js @@ -0,0 +1,30 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import sendAwayStatusChatMsg from '../methods/sendAwayStatusChatMsg'; + +export default async function changeAway(meetingId, userId, away) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + away, + awayTime: away ? (new Date()).getTime() : 0, + }, + }; + + try { + // must be called before modifying the users collection, because it + // needs to be consulted in order to know the previous emoji + sendAwayStatusChatMsg(meetingId, userId, away); + + const numberAffected = await Users.updateAsync(selector, modifier); + if (numberAffected) { + Logger.info(`Assigned away=${away} user id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Assigning away user: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/changePin.js b/src/2.7.12/imports/api/users/server/modifiers/changePin.js new file mode 100644 index 00000000..14f3a26e --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/changePin.js @@ -0,0 +1,25 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function changePin(meetingId, userId, pin, changedBy) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + pin, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Change pin=${pin} id=${userId} meeting=${meetingId} changedBy=${changedBy}`); + } + } catch (err) { + Logger.error(`Change pin error: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/changePresenter.js b/src/2.7.12/imports/api/users/server/modifiers/changePresenter.js new file mode 100644 index 00000000..bd2253b8 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/changePresenter.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function changePresenter(presenter, userId, meetingId, changedBy) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + presenter, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Changed presenter=${presenter} id=${userId} meeting=${meetingId}` + + `${changedBy ? ` changedBy=${changedBy}` : ''}`); + } + } catch (err) { + Logger.error(`Change presenter error: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js b/src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js new file mode 100644 index 00000000..543e1d7e --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function changeRaiseHand(meetingId, userId, raiseHand) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + raiseHand, + raiseHandTime: raiseHand ? (new Date()).getTime() : 0, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Assigned raiseHand=${raiseHand} user id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Assigning raiseHand user: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/changeRole.js b/src/2.7.12/imports/api/users/server/modifiers/changeRole.js new file mode 100644 index 00000000..6a175d89 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/changeRole.js @@ -0,0 +1,28 @@ +import Logger from '/imports/startup/server/logger'; +import updateRole from '/imports/api/users-persistent-data/server/modifiers/updateRole'; +import Users from '/imports/api/users'; + +export default async function changeRole(role, userId, meetingId, changedBy) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + role, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + await updateRole(userId, meetingId, role); + Logger.info(`Changed user role=${role} id=${userId} meeting=${meetingId}` + + `${changedBy ? ` changedBy=${changedBy}` : ''}`); + } + } catch (err) { + Logger.error(`Changed user role: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/clearUsers.js b/src/2.7.12/imports/api/users/server/modifiers/clearUsers.js new file mode 100644 index 00000000..4025469a --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/clearUsers.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users/index'; + +export default async function clearUsers(meetingId) { + if (meetingId) { + try { + const numberAffected = await Users.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared Users (${meetingId})`); + } + } catch (err) { + Logger.error(`Error clearing Users (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await Users.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared Users (all)'); + } + } catch (err) { + Logger.error(`Error clearing Users (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js b/src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js new file mode 100644 index 00000000..ed9ad03e --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js @@ -0,0 +1,35 @@ +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default function clearUsersEmoji(meetingId) { + const selector = {}; + + if (meetingId) { + selector.meetingId = meetingId; + } + + try { + const numberAffected = Users.update(selector, { + $set: { + emojiTime: (new Date()).getTime(), + emoji: 'none', + awayTime: 0, + away: false, + raiseHandTime: 0, + raiseHand: false, + }, + }, { multi: true }); + + if (numberAffected) { + if (meetingId) { + Logger.info(`Removed users emoji status (${meetingId})`); + } else { + Logger.info('Removed users emoji status (all)'); + } + } else { + Logger.warn('Removing users emoji status nonaffected'); + } + } catch (err) { + Logger.error(`Removing users emoji stautus: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js b/src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js new file mode 100644 index 00000000..32ff22e6 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function createDummyUser(meetingId, userId, authToken) { + check(meetingId, String); + check(userId, String); + check(authToken, String); + + const User = await Users.findOneAsync({ meetingId, userId }); + if (User) { + throw new Meteor.Error('existing-user', 'Tried to create a dummy user for an existing user'); + } + + const doc = { + meetingId, + userId, + authToken, + clientType: 'HTML5', + validated: null, + left: false, + }; + + try { + const insertedId = await Users.insertAsync(doc); + + if (insertedId) { + Logger.info(`Created dummy user id=${userId} token=${authToken} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Creating dummy user to collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/removeUser.js b/src/2.7.12/imports/api/users/server/modifiers/removeUser.js new file mode 100644 index 00000000..c01b59bf --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/removeUser.js @@ -0,0 +1,83 @@ +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; +import VideoStreams from '/imports/api/video-streams'; +import Logger from '/imports/startup/server/logger'; +import setloggedOutStatus from '/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus'; +import clearUserInfoForRequester from '/imports/api/users-infos/server/modifiers/clearUserInfoForRequester'; +import ClientConnections from '/imports/startup/server/ClientConnections'; +import UsersPersistentData from '/imports/api/users-persistent-data'; +import userEjected from '/imports/api/users/server/modifiers/userEjected'; +import clearVoiceUser from '/imports/api/voice-users/server/modifiers/clearVoiceUser'; + +const disconnectUser = (meetingId, userId) => { + const sessionUserId = `${meetingId}--${userId}`; + ClientConnections.removeClientConnection(sessionUserId); + + const serverSessions = Meteor.server.sessions; + const interable = serverSessions.values(); + + for (const session of interable) { + if (session.userId === sessionUserId) { + Logger.info(`Removed session id=${userId} meeting=${meetingId}`); + session.close(); + } + } +}; + +export default async function removeUser(body, meetingId) { + const { intId: userId, reasonCode } = body; + check(meetingId, String); + check(userId, String); + + try { + const selector = { + meetingId, + userId, + }; + + // we don't want to fully process the redis message in frontend + // since the backend is supposed to update Mongo + if ((process.env.BBB_HTML5_ROLE !== 'frontend')) { + if (body.eject) { + await userEjected(meetingId, userId, reasonCode); + } + + await setloggedOutStatus(userId, meetingId, true); + await VideoStreams.removeAsync({ meetingId, userId }); + + await clearUserInfoForRequester(meetingId, userId); + + const currentUser = await UsersPersistentData.findOneAsync({ userId, meetingId }); + const hasMessages = currentUser?.shouldPersist?.hasMessages?.public || + currentUser?.shouldPersist?.hasMessages?.private; + const hasConnectionStatus = currentUser?.shouldPersist?.hasConnectionStatus; + + if (!hasMessages && !hasConnectionStatus) { + await UsersPersistentData.removeAsync(selector); + } + + await Users.removeAsync(selector); + await clearVoiceUser(meetingId, userId); + } + + if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') { + // Wait for user removal and then kill user connections and sessions + const queryCurrentUser = Users.find(selector); + const countUser = await queryCurrentUser.countAsync(); + if (countUser === 0) { + disconnectUser(meetingId, userId); + } else { + const queryUserObserver = queryCurrentUser.observeChanges({ + removed() { + disconnectUser(meetingId, userId); + queryUserObserver.stop(); + }, + }); + } + } + + Logger.info(`Removed user id=${userId} meeting=${meetingId}`); + } catch (err) { + Logger.error(`Removing user from Users collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js b/src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js new file mode 100644 index 00000000..72002ff8 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js @@ -0,0 +1,37 @@ +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default async function setConnectionIdAndAuthToken( + meetingId, + userId, + connectionId, + authToken, +) { + check(meetingId, String); + check(userId, String); + check(authToken, String); + check(connectionId, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + connectionId, + authToken, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Updated connectionId and authToken user=${userId} connectionId=${connectionId} meeting=${meetingId} authToken=${authToken}`); + } + } catch (err) { + Logger.error(`Updating connectionId user=${userId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/setMobile.js b/src/2.7.12/imports/api/users/server/modifiers/setMobile.js new file mode 100644 index 00000000..1634d110 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/setMobile.js @@ -0,0 +1,25 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function setMobile(meetingId, userId) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + mobile: true, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Assigned mobile user id=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Assigning mobile user: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js b/src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js new file mode 100644 index 00000000..8f926caf --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js @@ -0,0 +1,35 @@ +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default async function setUserEffectiveConnectionType( + meetingId, + userId, + effectiveConnectionType, +) { + check(meetingId, String); + check(userId, String); + check(effectiveConnectionType, String); + + const selector = { + meetingId, + userId, + effectiveConnectionType: { $ne: effectiveConnectionType }, + }; + + const modifier = { + $set: { + effectiveConnectionType, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Updated user ${userId} effective connection to ${effectiveConnectionType} in meeting ${meetingId}`); + } + } catch (err) { + Logger.error(`Updating user ${userId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js b/src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js new file mode 100644 index 00000000..d5e41dff --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js @@ -0,0 +1,25 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function setUserExitReason(meetingId, userId, reason) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + exitReason: reason, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Set exit reason userId=${userId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Setting user exit reason: ${err}`); + } +}; diff --git a/src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js b/src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js new file mode 100644 index 00000000..cad50ca9 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js @@ -0,0 +1,25 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function updateSpeechLocale(meetingId, userId, locale) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + speechLocale: locale, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Updated speech locale=${locale} userId=${userId} meetingId=${meetingId}`); + } + } catch (err) { + Logger.error(`Updating speech locale: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js b/src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js new file mode 100644 index 00000000..aeda50b2 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function updateUserConnectionId(meetingId, userId, connectionId) { + check(meetingId, String); + check(userId, String); + check(connectionId, String); + + const selector = { meetingId, userId }; + + const modifier = { + $set: { + currentConnectionId: connectionId, + connectionIdUpdateTime: new Date().getTime(), + }, + }; + + const User = await Users.findOneAsync(selector); + + if (User) { + try { + const updated = await Users.updateAsync(selector, modifier); + + if (updated) { + Logger.info(`Updated connection user=${userId} connectionid=${connectionId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Updating user connection: ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/userEjected.js b/src/2.7.12/imports/api/users/server/modifiers/userEjected.js new file mode 100644 index 00000000..c06de19e --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/userEjected.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; +import clearUserInfoForRequester from '/imports/api/users-infos/server/modifiers/clearUserInfoForRequester'; + +export default async function userEjected(meetingId, userId, ejectedReason) { + check(meetingId, String); + check(userId, String); + check(ejectedReason, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + ejected: true, + ejectedReason, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + await clearUserInfoForRequester(meetingId, userId); + Logger.info(`Ejected user id=${userId} meeting=${meetingId} reason=${ejectedReason}`); + } + } catch (err) { + Logger.error(`Ejecting user from collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js b/src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js new file mode 100644 index 00000000..f709d6d4 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js @@ -0,0 +1,30 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function userInactivityInspect(userId, responseDelay) { + check(userId, String); + check(responseDelay, Match.Integer); + + const selector = { + userId, + inactivityCheck: false, + }; + + const modifier = { + $set: { + inactivityCheck: true, + responseDelay, + }, + }; + + try { + const { numberAffected } = await Users.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Updated user ${userId} with inactivity inspect`); + } + } catch (err) { + Logger.error(`Inactivity check for user ${userId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js b/src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js new file mode 100644 index 00000000..51e0f225 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js @@ -0,0 +1,24 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +export default async function userLeftFlagUpdated(meetingId, userId, left) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + left, + }, + }; + + try { + const numberAffected = await Users.updateAsync(selector, modifier); + if (numberAffected) { + Logger.info(`Updated user ${userId} with left flag as ${left} in meeting ${meetingId}`); + } + } catch (err) { + Logger.error(`Changed user role: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/users/server/publishers.js b/src/2.7.12/imports/api/users/server/publishers.js new file mode 100644 index 00000000..562b40b9 --- /dev/null +++ b/src/2.7.12/imports/api/users/server/publishers.js @@ -0,0 +1,104 @@ +import Users from '/imports/api/users'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; +import { extractCredentials, publicationSafeGuard } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +function currentUser() { + if (!this.userId) { + Mongo.Collection._publishCursor(Users.find({ meetingId: '' }), this, 'current-user'); + return this.ready(); + } + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const selector = { + meetingId, + userId: requesterUserId, + intId: { $exists: true }, + }; + + const options = { + fields: { + user: false, + authToken: false, // Not asking for authToken from client side but also not exposing it + }, + }; + Mongo.Collection._publishCursor(Users.find(selector, options), this, 'current-user'); + return this.ready(); +} + +function publishCurrentUser(...args) { + const boundUsers = currentUser.bind(this); + return boundUsers(...args); +} + +Meteor.publish('current-user', publishCurrentUser); + +async function users() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing Users was requested by unauth connection ${this.connection.id}`); + return Users.find({ meetingId: '' }); + } + + if (!this.userId) { + return Users.find({ meetingId: '' }); + } + const { meetingId, userId } = tokenValidation; + + Logger.debug(`Publishing Users for ${meetingId} ${userId}`); + + const selector = { + $or: [ + { meetingId }, + ], + intId: { $exists: true }, + left: false, + }; + + const User = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1 } }); + if (!!User && User.role === ROLE_MODERATOR) { + selector.$or.push({ + 'breakoutProps.isBreakoutUser': true, + 'breakoutProps.parentId': meetingId, + }); + // Monitor this publication and stop it when user is not a moderator anymore + const comparisonFunc = async () => { + const user = await Users.findOneAsync({ userId, meetingId }, + { fields: { role: 1, userId: 1 } }); + const condition = user.role === ROLE_MODERATOR; + if (!condition) { + Logger.info(`conditions aren't filled anymore in publication ${this._name}: + user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`); + } + + return condition; + }; + publicationSafeGuard(comparisonFunc, this); + } + + const options = { + fields: { + authToken: false, + }, + }; + + Logger.debug('Publishing Users', { meetingId, userId }); + + return Users.find(selector, options); +} + +function publish(...args) { + const boundUsers = users.bind(this); + return boundUsers(...args); +} + +Meteor.publish('users', publish); diff --git a/src/2.7.12/imports/api/users/server/store/pendingAuthentications.js b/src/2.7.12/imports/api/users/server/store/pendingAuthentications.js new file mode 100644 index 00000000..ffc451ae --- /dev/null +++ b/src/2.7.12/imports/api/users/server/store/pendingAuthentications.js @@ -0,0 +1,47 @@ +import Logger from '/imports/startup/server/logger'; + +class PendingAuthentitcations { + constructor() { + Logger.debug('PendingAuthentitcations :: constructor'); + this.store = []; + } + + generateKey(meetingId, userId, authToken) { + // Protect against separator injection + meetingId = meetingId.replace(/ /g, ''); + userId = userId.replace(/ /g, ''); + authToken = authToken.replace(/ /g, ''); + + // Space separated key + return `${meetingId} ${userId} ${authToken}`; + } + + add(meetingId, userId, authToken, methodInvocationObject) { + Logger.debug('PendingAuthentitcations :: add', { meetingId, userId, authToken }); + this.store.push({ + key: this.generateKey(meetingId, userId, authToken), + meetingId, + userId, + authToken, + methodInvocationObject, + }); + } + + take(meetingId, userId, authToken) { + const key = this.generateKey(meetingId, userId, authToken); + Logger.debug('PendingAuthentitcations :: take', { + key, meetingId, userId, authToken, + }); + + // find matches + const matches = this.store.filter(e => e.key === key); + // remove matches (if any) + if (matches.length) { + this.store = this.store.filter(e => e.key !== key); + } + + // return matches + return matches; + } +} +export default new PendingAuthentitcations(); diff --git a/src/2.7.12/imports/api/video-streams/index.js b/src/2.7.12/imports/api/video-streams/index.js new file mode 100644 index 00000000..b997043b --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/index.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const VideoStreams = new Mongo.Collection('video-streams', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the video users: + // 2. meetingId + + VideoStreams.createIndexAsync({ meetingId: 1 }); +} + +export default VideoStreams; diff --git a/src/2.7.12/imports/api/video-streams/server/eventHandlers.js b/src/2.7.12/imports/api/video-streams/server/eventHandlers.js new file mode 100644 index 00000000..8d5b932a --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/eventHandlers.js @@ -0,0 +1,12 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleUserSharedHtml5Webcam from './handlers/userSharedHtml5Webcam'; +import handleUserUnsharedHtml5Webcam from './handlers/userUnsharedHtml5Webcam'; +import handleFloorChanged from './handlers/floorChanged'; +import handlePinnedChanged from './handlers/userPinChanged'; +import handleWebcamSync from './handlers/webcamSync'; + +RedisPubSub.on('UserBroadcastCamStartedEvtMsg', handleUserSharedHtml5Webcam); +RedisPubSub.on('UserBroadcastCamStoppedEvtMsg', handleUserUnsharedHtml5Webcam); +RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChanged); +RedisPubSub.on('UserPinStateChangedEvtMsg', handlePinnedChanged); +RedisPubSub.on('SyncGetWebcamInfoRespMsg', handleWebcamSync); diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js b/src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js new file mode 100644 index 00000000..853b835e --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js @@ -0,0 +1,12 @@ +import { check } from 'meteor/check'; +import floorChanged from '../modifiers/floorChanged'; + +export default async function handleFloorChanged({ header, body }, meetingId) { + const { intId, floor, lastFloorTime } = body; + check(meetingId, String); + check(intId, String); + check(floor, Boolean); + check(lastFloorTime, String); + const result = await floorChanged(meetingId, intId, floor, lastFloorTime); + return result; +} diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js b/src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js new file mode 100644 index 00000000..2b8d27a2 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js @@ -0,0 +1,13 @@ +import { check } from 'meteor/check'; +import changePin from '../modifiers/changePin'; + +export default async function userPinChanged({ body }, meetingId) { + const { userId, pin, changedBy } = body; + + check(meetingId, String); + check(userId, String); + check(pin, Boolean); + check(changedBy, String); + const result = await changePin(meetingId, userId, pin, changedBy); + return result; +} diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js b/src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js new file mode 100644 index 00000000..1079efdd --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js @@ -0,0 +1,16 @@ +import { check } from 'meteor/check'; +import sharedWebcam from '../modifiers/sharedWebcam'; +import { isValidStream } from '/imports/api/video-streams/server/helpers'; + +export default async function handleUserSharedHtml5Webcam({ header, body }, meetingId) { + const { userId, stream } = body; + + check(header, Object); + check(meetingId, String); + check(userId, String); + check(stream, String); + + if (!isValidStream(stream)) return false; + const result = await sharedWebcam(meetingId, userId, stream); + return result; +} diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js b/src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js new file mode 100644 index 00000000..2af25054 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js @@ -0,0 +1,16 @@ +import { check } from 'meteor/check'; +import unsharedWebcam from '../modifiers/unsharedWebcam'; +import { isValidStream } from '/imports/api/video-streams/server/helpers'; + +export default async function handleUserUnsharedHtml5Webcam({ header, body }, meetingId) { + const { userId, stream } = body; + + check(header, Object); + check(meetingId, String); + check(userId, String); + check(stream, String); + + if (!isValidStream(stream)) return false; + const result = await unsharedWebcam(meetingId, userId, stream); + return result; +} diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js b/src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js new file mode 100644 index 00000000..cffa6b1b --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js @@ -0,0 +1,51 @@ +import { check } from 'meteor/check'; +import { addWebcamSync } from '../modifiers/sharedWebcam'; +import VideoStreams from '/imports/api/video-streams/'; +import updatedVideoStream from '../modifiers/updatedVideoStream'; +import unsharedWebcam from '../modifiers/unsharedWebcam'; + +export default async function handleWebcamSync({ body }, meetingId) { + check(meetingId, String); + check(body, Object); + const { webcamListSync } = body; + check(webcamListSync, Array); + + const streamsIds = webcamListSync.map((webcam) => webcam.stream); + + const webcamStreams = VideoStreams.find({ + meetingId, + stream: { $in: streamsIds }, + }, { + fields: { + stream: 1, + }, + }).fetchAsync(); + + const webcamStreamsToUpdate = webcamStreams.map((m) => m.stream); + + await Promise.all(webcamListSync.map(async (webcam) => { + if (webcamStreamsToUpdate.indexOf(webcam.stream) >= 0) { + // stream already exist, then update + await updatedVideoStream(meetingId, webcam); + } else { + // stream doesn't exist yet, then add it + await addWebcamSync(meetingId, webcam); + } + })); + + // removing extra video streams already existing in Mongo + const videoStreamsToRemove = await VideoStreams.find({ + meetingId, + stream: { $nin: streamsIds }, + }, { + fields: { + stream: 1, + userId: 1, + }, + }).fetchAsynch(); + + await Promise.all(videoStreamsToRemove + .map(async (videoStream) => { + await unsharedWebcam(meetingId, videoStream.userId, videoStream.stream); + })); +} diff --git a/src/2.7.12/imports/api/video-streams/server/helpers.js b/src/2.7.12/imports/api/video-streams/server/helpers.js new file mode 100644 index 00000000..a1ec7cfa --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/helpers.js @@ -0,0 +1,28 @@ +import Logger from '/imports/startup/server/logger'; +import Users from '/imports/api/users'; + +const FLASH_STREAM_REGEX = /^([A-z0-9]+)-([A-z0-9]+)-([A-z0-9]+)(-recorded)?$/; +const TOKEN = '_'; + +const isValidStream = stream => !FLASH_STREAM_REGEX.test(stream); +const getDeviceId = (stream) => { + const splitStream = stream.split(TOKEN); + if (splitStream.length === 3) return splitStream[2]; + Logger.warn(`Could not get deviceId from stream=${stream}`); + return stream; +}; + +const getUserName = async (userId, meetingId) => { + const user = await Users.findOneAsync( + { userId, meetingId }, + { fields: { name: 1 } }, + ); + if (user) return user.name; + return userId; +}; + +export { + isValidStream, + getDeviceId, + getUserName, +}; diff --git a/src/2.7.12/imports/api/video-streams/server/index.js b/src/2.7.12/imports/api/video-streams/server/index.js new file mode 100644 index 00000000..eff5e3f6 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publisher'; diff --git a/src/2.7.12/imports/api/video-streams/server/methods.js b/src/2.7.12/imports/api/video-streams/server/methods.js new file mode 100644 index 00000000..a4e5900d --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/methods.js @@ -0,0 +1,10 @@ +import { Meteor } from 'meteor/meteor'; +import userShareWebcam from './methods/userShareWebcam'; +import userUnshareWebcam from './methods/userUnshareWebcam'; +import ejectUserCameras from './methods/ejectUserCameras'; + +Meteor.methods({ + userShareWebcam, + userUnshareWebcam, + ejectUserCameras, +}); diff --git a/src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js b/src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js new file mode 100644 index 00000000..56f8c231 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function ejectUserCameras(userId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'EjectUserCamerasCmdMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(userId, String); + + Logger.info(`Requesting ejection of cameras: userToEject=${userId} requesterUserId=${requesterUserId}`); + + const payload = { + userId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method ejectUserCameras ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js b/src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js new file mode 100644 index 00000000..82098a5e --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function userShareWebcam(stream) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserBroadcastCamStartMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(stream, String); + + Logger.info(`user sharing webcam: ${meetingId} ${requesterUserId}`); + + // const actionName = 'joinVideo'; + /* TODO throw an error if user has no permission to share webcam + if (!isAllowedTo(actionName, credentials)) { + throw new Meteor.Error('not-allowed', `You are not allowed to share webcam`); + } */ + + const payload = { + stream, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method userShareWebcam ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js b/src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js new file mode 100644 index 00000000..afae00c4 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js @@ -0,0 +1,34 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; + +export default function userUnshareWebcam(stream) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserBroadcastCamStopMsg'; + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(stream, String); + + Logger.info(`user unsharing webcam: ${meetingId} ${requesterUserId}`); + + // const actionName = 'joinVideo'; + /* TODO throw an error if user has no permission to share webcam + if (!isAllowedTo(actionName, credentials)) { + throw new Meteor.Error('not-allowed', `You are not allowed to share webcam`); + } */ + + const payload = { + stream, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method userUnshareWebcam ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js b/src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js new file mode 100644 index 00000000..0143738b --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; + +export default async function changePin(meetingId, userId, pin) { + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + pin, + }, + }; + + try { + const numberAffected = await VideoStreams.updateAsync(selector, modifier, { multi: true }); + + if (numberAffected) { + Logger.info(`Updated user streams pinned userId=${userId} pinned=${pin}`); + } + } catch (error) { + return Logger.error(`Error updating stream pinned status: ${error}`); + } + return null; +} diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js b/src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js new file mode 100644 index 00000000..7d806369 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; + +export default async function clearVideoStreams(meetingId) { + if (meetingId) { + try { + const numberAffected = await VideoStreams.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared VideoStreams in (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing VideoStreams (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await VideoStreams.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared VideoStreams in all meetings'); + } + } catch (err) { + Logger.error(`Error on clearing VideoStreams (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js b/src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js new file mode 100644 index 00000000..47b2b9d1 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js @@ -0,0 +1,37 @@ +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; +import { check } from 'meteor/check'; + +export default async function floorChanged( + meetingId, + userId, + floor, + lastFloorTime, +) { + check(meetingId, String); + check(userId, String); + check(floor, Boolean); + check(lastFloorTime, String); + + const selector = { + meetingId, + userId, + }; + + const modifier = { + $set: { + floor, + lastFloorTime: floor ? lastFloorTime : undefined, + }, + }; + + try { + const numberAffected = await VideoStreams.updateAsync(selector, modifier, { multi: true }); + + if (numberAffected) { + Logger.info(`Updated user streams floor times userId=${userId} floor=${floor} lastFloorTime=${lastFloorTime}`); + } + } catch (error) { + return Logger.error(`Error updating stream floor status: ${error}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js b/src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js new file mode 100644 index 00000000..4dabc98d --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js @@ -0,0 +1,103 @@ +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; +import { check } from 'meteor/check'; +import { + getDeviceId, + getUserName, +} from '/imports/api/video-streams/server/helpers'; +import VoiceUsers from '/imports/api/voice-users/'; +import Users from '/imports/api/users/'; +import { lowercaseTrim } from '/imports/utils/string-utils'; + +const BASE_FLOOR_TIME = "0"; + +export default async function sharedWebcam(meetingId, userId, stream) { + check(meetingId, String); + check(userId, String); + check(stream, String); + + const deviceId = getDeviceId(stream); + const name = await getUserName(userId, meetingId); + const vu = await VoiceUsers.findOneAsync( + { meetingId, intId: userId }, + { fields: { floor: 1, lastFloorTime: 1 }} + ) || {}; + const u = await Users.findOneAsync( + { meetingId, intId: userId }, + { fields: { pin: 1 } }, + ) || {}; + const floor = vu.floor || false; + const pin = u.pin || false; + + const lastFloorTime = vu.lastFloorTime || BASE_FLOOR_TIME; + + const selector = { + meetingId, + userId, + deviceId, + }; + + const modifier = { + $set: { + stream, + name, + sortName: lowercaseTrim(name), + lastFloorTime, + floor, + pin, + }, + }; + + try { + const { insertedId } = await VideoStreams.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Updated stream=${stream} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Error setting stream: ${err}`); + } +} + +export async function addWebcamSync(meetingId, videoStream) { + check(videoStream, { + userId: String, + stream: String, + name: String, + pin: Boolean, + floor: Boolean, + lastFloorTime: String, + }); + + const { + stream, userId, name, pin, floor, lastFloorTime, + } = videoStream; + + const deviceId = getDeviceId(stream); + + const selector = { + meetingId, + userId, + deviceId, + }; + + const modifier = { + $set: { + stream, + name, + lastFloorTime, + floor, + pin, + }, + }; + + try { + const { insertedId } = await VideoStreams.upsertAsync(selector, modifier); + + if (insertedId) { + Logger.info(`Synced stream=${stream} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Error setting sync stream: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js b/src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js new file mode 100644 index 00000000..f4d42ba3 --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; +import { check } from 'meteor/check'; +import { getDeviceId } from '/imports/api/video-streams/server/helpers'; + +export default async function unsharedWebcam(meetingId, userId, stream) { + check(meetingId, String); + check(userId, String); + check(stream, String); + + const deviceId = getDeviceId(stream); + + const selector = { + meetingId, + userId, + deviceId, + }; + + try { + await VideoStreams.removeAsync(selector); + + Logger.info(`Removed stream=${stream} meeting=${meetingId}`); + } catch (err) { + Logger.error(`Error removing stream: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js b/src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js new file mode 100644 index 00000000..3e994b5d --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js @@ -0,0 +1,40 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; +import flat from 'flat'; + +export default async function updateVideoStream(meetingId, videoStream) { + check(meetingId, String); + check(videoStream, { + userId: String, + stream: String, + name: String, + pin: Boolean, + floor: Boolean, + lastFloorTime: String, + }); + + const { userId, stream } = videoStream; + + const selector = { + meetingId, + stream, + userId, + }; + + const modifier = { + $set: Object.assign( + flat(videoStream), + ), + }; + + try { + const numberAffected = await VideoStreams.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.debug(`Update videoStream ${stream} for user ${userId} in ${meetingId}`); + } + } catch (err) { + Logger.error(`Error update videoStream ${stream} for user=${userId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/video-streams/server/publisher.js b/src/2.7.12/imports/api/video-streams/server/publisher.js new file mode 100644 index 00000000..de5f5aad --- /dev/null +++ b/src/2.7.12/imports/api/video-streams/server/publisher.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import VideoStreams from '/imports/api/video-streams'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function videoStreams() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing VideoStreams was requested by unauth connection ${this.connection.id}`); + return VideoStreams.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing VideoStreams', { meetingId, userId }); + + const selector = { + meetingId, + }; + + return VideoStreams.find(selector); +} + +function publish(...args) { + const boundVideoStreams = videoStreams.bind(this); + return boundVideoStreams(...args); +} + +Meteor.publish('video-streams', publish); diff --git a/src/2.7.12/imports/api/voice-call-states/index.js b/src/2.7.12/imports/api/voice-call-states/index.js new file mode 100644 index 00000000..d3481a86 --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/index.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; + +const VoiceCallStates = new Mongo.Collection('voiceCallStates'); + +if (Meteor.isServer) { + // types of queries for the voice users: + // 1. intId + // 2. meetingId, intId + + VoiceCallStates.createIndexAsync({ meetingId: 1, userId: 1 }); +} + +export default VoiceCallStates; diff --git a/src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js b/src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js new file mode 100644 index 00000000..4d7a7397 --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js @@ -0,0 +1,4 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleVoiceCallStateEvent from './handlers/voiceCallStateEvent'; + +RedisPubSub.on('VoiceCallStateEvtMsg', handleVoiceCallStateEvent); diff --git a/src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js b/src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js new file mode 100644 index 00000000..50d1d195 --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js @@ -0,0 +1,50 @@ +import { check } from 'meteor/check'; +import VoiceCallState from '/imports/api/voice-call-states'; +import Logger from '/imports/startup/server/logger'; + +// "CALL_STARTED", "IN_ECHO_TEST", "IN_CONFERENCE", "CALL_ENDED" + +export default async function handleVoiceCallStateEvent({ body }, meetingId) { + const { + voiceConf, + clientSession, + userId, + callerName, + callState, + } = body; + + check(meetingId, String); + check(voiceConf, String); + check(clientSession, String); + check(userId, String); + check(callerName, String); + check(callState, String); + + const selector = { + meetingId, + userId, + clientSession, + }; + + const modifier = { + $set: { + meetingId, + userId, + voiceConf, + clientSession, + callState, + }, + }; + + try { + const { numberAffected } = await VoiceCallState.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.debug('Update voice call', { + state: userId, meetingId, clientSession, callState, + }); + } + } catch (err) { + Logger.error(`Update voice call state=${userId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/voice-call-states/server/index.js b/src/2.7.12/imports/api/voice-call-states/server/index.js new file mode 100644 index 00000000..f993f38e --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/server/index.js @@ -0,0 +1,2 @@ +import './eventHandlers'; +import './publishers'; diff --git a/src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js b/src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js new file mode 100644 index 00000000..679d5d07 --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import VoiceCallStates from '/imports/api/voice-call-states'; + +export default async function clearVoiceCallStates(meetingId) { + if (meetingId) { + try { + const numberAffected = await VoiceCallStates.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared VoiceCallStates in (${meetingId})`); + } + } catch (err) { + Logger.info(`Error on clearing VoiceCallStates in (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await VoiceCallStates.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared VoiceCallStates in all meetings'); + } + } catch (err) { + Logger.error(`Error on clearing VoiceCallStates in all meetings. ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/voice-call-states/server/publishers.js b/src/2.7.12/imports/api/voice-call-states/server/publishers.js new file mode 100644 index 00000000..eedd18c2 --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/server/publishers.js @@ -0,0 +1,27 @@ +import VoiceCallStates from '/imports/api/voice-call-states'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function voiceCallStates() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing VoiceCallStates was requested by unauth connection ${this.connection.id}`); + return VoiceCallStates.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing Voice Call States', { meetingId, userId }); + + return VoiceCallStates.find({ meetingId, userId }); +} + +function publish(...args) { + const boundVoiceCallStates = voiceCallStates.bind(this); + return boundVoiceCallStates(...args); +} + +Meteor.publish('voice-call-states', publish); diff --git a/src/2.7.12/imports/api/voice-call-states/utils/callStates.js b/src/2.7.12/imports/api/voice-call-states/utils/callStates.js new file mode 100644 index 00000000..332ad303 --- /dev/null +++ b/src/2.7.12/imports/api/voice-call-states/utils/callStates.js @@ -0,0 +1,8 @@ +const CallStateOptions = { + CALL_STARTED: 'CALL_STARTED', + IN_ECHO_TEST: 'IN_ECHO_TEST', + IN_CONFERENCE: 'IN_CONFERENCE', + CALL_ENDED: 'CALL_ENDED', +}; + +export default CallStateOptions; diff --git a/src/2.7.12/imports/api/voice-users/index.js b/src/2.7.12/imports/api/voice-users/index.js new file mode 100644 index 00000000..2bfa1611 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/index.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const VoiceUsers = new Mongo.Collection('voiceUsers', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the voice users: + // 1. intId + // 2. meetingId, intId + + VoiceUsers.createIndexAsync({ intId: 1 }); + VoiceUsers.createIndexAsync({ meetingId: 1, intId: 1 }); +} + +export default VoiceUsers; diff --git a/src/2.7.12/imports/api/voice-users/server/eventHandlers.js b/src/2.7.12/imports/api/voice-users/server/eventHandlers.js new file mode 100644 index 00000000..1155ace8 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/eventHandlers.js @@ -0,0 +1,19 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers'; +import handleJoinVoiceUser from './handlers/joinVoiceUser'; +import handleLeftVoiceUser from './handlers/leftVoiceUser'; +import handleTalkingVoiceUser from './handlers/talkingVoiceUser'; +import handleMutedVoiceUser from './handlers/mutedVoiceUser'; +import handleGetVoiceUsers from './handlers/getVoiceUsers'; +import handleVoiceUsers from './handlers/voiceUsers'; +import handleMeetingMuted from './handlers/meetingMuted'; +import handleFloorChange from './handlers/floorChanged'; + +RedisPubSub.on('UserLeftVoiceConfToClientEvtMsg', handleLeftVoiceUser); +RedisPubSub.on('UserJoinedVoiceConfToClientEvtMsg', handleJoinVoiceUser); +RedisPubSub.on('UserTalkingVoiceEvtMsg', handleTalkingVoiceUser); +RedisPubSub.on('UserMutedVoiceEvtMsg', handleMutedVoiceUser); +RedisPubSub.on('GetVoiceUsersMeetingRespMsg', processForHTML5ServerOnly(handleGetVoiceUsers)); +RedisPubSub.on('SyncGetVoiceUsersRespMsg', handleVoiceUsers); +RedisPubSub.on('MeetingMutedEvtMsg', handleMeetingMuted); +RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChange); diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js b/src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js new file mode 100644 index 00000000..88326cd1 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js @@ -0,0 +1,10 @@ +import { check } from 'meteor/check'; +import updateVoiceUser from '../modifiers/updateVoiceUser'; + +export default function handleFloorChange({ header, body }, meetingId) { + const voiceUser = body; + + check(meetingId, String); + + return updateVoiceUser(meetingId, voiceUser); +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js b/src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js new file mode 100644 index 00000000..d121d76b --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js @@ -0,0 +1,64 @@ +import { check } from 'meteor/check'; +import VoiceUsers from '/imports/api/voice-users/'; +import Meetings from '/imports/api/meetings'; +import addVoiceUser from '../modifiers/addVoiceUser'; +import removeVoiceUser from '../modifiers/removeVoiceUser'; +import updateVoiceUser from '../modifiers/updateVoiceUser'; + +export default async function handleGetVoiceUsers({ body }, meetingId) { + const { users } = body; + + check(meetingId, String); + check(users, Array); + + const meeting = await Meetings.findOneAsync({ meetingId }, { fields: { 'voiceProp.voiceConf': 1 } }); + const usersIds = users.map((m) => m.intId); + + const voiceUsersFetch = await VoiceUsers.find({ + meetingId, + intId: { $in: usersIds }, + }, { fields: { intId: 1 } }).fetchAsync(); + + const voiceUsersIdsToUpdate = voiceUsersFetch.map((m) => m.intId); + + await Promise.all(users.map(async (user) => { + if (voiceUsersIdsToUpdate.indexOf(user.intId) >= 0) { + // user already exist, then update + await updateVoiceUser(meetingId, { + intId: user.intId, + voiceUserId: user.voiceUserId, + talking: user.talking, + muted: user.muted, + voiceConf: meeting.voiceProp.voiceConf, + joined: true, + }); + } else { + // user doesn't exist yet, then add it + await addVoiceUser(meetingId, { + voiceUserId: user.voiceUserId, + intId: user.intId, + callerName: user.callerName, + callerNum: user.callerNum, + muted: user.muted, + color: user.color, + talking: user.talking, + callingWith: user.callingWith, + listenOnly: user.listenOnly, + voiceConf: meeting.voiceProp.voiceConf, + joined: true, + }); + } + })); + // removing extra users already existing in Mongo + const voiceUsersToRemove = await VoiceUsers.find({ + meetingId, + intId: { $nin: usersIds }, + }).fetchAsync(); + await Promise.all(voiceUsersToRemove.map(async (user) => { + await removeVoiceUser(meetingId, { + voiceConf: meeting.voiceProp.voiceConf, + voiceUserId: user.voiceUserId, + intId: user.intId, + }); + })); +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js new file mode 100644 index 00000000..2781e3d2 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js @@ -0,0 +1,41 @@ +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; +import addDialInUser from '/imports/api/users/server/modifiers/addDialInUser'; +import addVoiceUser from '../modifiers/addVoiceUser'; + +export default async function handleJoinVoiceUser({ body }, meetingId) { + const voiceUser = body; + voiceUser.joined = true; + + check(meetingId, String); + check(voiceUser, { + voiceConf: String, + intId: String, + voiceUserId: String, + callerName: String, + callerNum: String, + color: String, + muted: Boolean, + talking: Boolean, + callingWith: String, + listenOnly: Boolean, + joined: Boolean, + }); + + const { + intId, + } = voiceUser; + + const User = await Users.findOneAsync({ + meetingId, + intId, + }); + + if (!User) { + /* voice-only user - called into the conference */ + await addDialInUser(meetingId, voiceUser); + } + + const result = await addVoiceUser(meetingId, voiceUser); + return result; +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js new file mode 100644 index 00000000..fadfab1a --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js @@ -0,0 +1,32 @@ +import { check } from 'meteor/check'; + +import removeVoiceUser from '/imports/api/voice-users/server/modifiers/removeVoiceUser'; +import removeUser from '/imports/api/users/server/modifiers/removeUser'; +import Users from '/imports/api/users'; + +export default async function handleVoiceUpdate({ body }, meetingId) { + const voiceUser = body; + + check(meetingId, String); + check(voiceUser, { + voiceConf: String, + intId: String, + voiceUserId: String, + }); + + const { + intId, + voiceUserId, + } = voiceUser; + + const isDialInUser = async (userId, meetingID) => { + const user = await Users.findOneAsync({ meetingId: meetingID, userId, clientType: 'dial-in-user' }); + return !!user; + }; + + // if the user is dial-in, leaving voice also means leaving userlist + if (await isDialInUser(voiceUserId, meetingId)) removeUser(voiceUser, meetingId); + + const result = await removeVoiceUser(meetingId, voiceUser); + return result; +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js b/src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js new file mode 100644 index 00000000..a508d148 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js @@ -0,0 +1,5 @@ +import changeMuteMeeting from '../modifiers/changeMuteMeeting'; + +export default async function handleMeetingMuted({ body }, meetingId) { + await changeMuteMeeting(meetingId, body); +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js new file mode 100644 index 00000000..01e9689a --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js @@ -0,0 +1,17 @@ +import { check } from 'meteor/check'; + +import updateVoiceUser from '../modifiers/updateVoiceUser'; + +export default async function handleVoiceUpdate({ body }, meetingId) { + const voiceUser = body; + + check(meetingId, String); + + // If a person is muted we have to force them to not talking + if (voiceUser.muted) { + voiceUser.talking = false; + } + + const result = await updateVoiceUser(meetingId, voiceUser); + return result; +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js new file mode 100644 index 00000000..79413ea7 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js @@ -0,0 +1,12 @@ +import { check } from 'meteor/check'; + +import updateVoiceUser from '../modifiers/updateVoiceUser'; + +export default async function handleVoiceUpdate({ body }, meetingId) { + const voiceUser = body; + + check(meetingId, String); + + const result = await updateVoiceUser(meetingId, voiceUser); + return result; +} diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js b/src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js new file mode 100644 index 00000000..e8c83621 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js @@ -0,0 +1,66 @@ +import VoiceUsers from '/imports/api/voice-users/'; +import Meetings from '/imports/api/meetings'; +import addDialInUser from '/imports/api/users/server/modifiers/addDialInUser'; +import removeVoiceUser from '../modifiers/removeVoiceUser'; +import updateVoiceUser from '../modifiers/updateVoiceUser'; +import addVoiceUser from '../modifiers/addVoiceUser'; + +export default async function handleVoiceUsers({ header, body }) { + const { voiceUsers } = body; + const { meetingId } = header; + + const meeting = await Meetings.findOneAsync({ meetingId }, { fields: { 'voiceProp.voiceConf': 1 } }); + const usersIds = voiceUsers.map((m) => m.intId); + + const voiceUsersFetch = await VoiceUsers.find({ + meetingId, + intId: { $in: usersIds }, + }, { fields: { intId: 1 } }).fetchAsync(); + + const voiceUsersIdsToUpdate = voiceUsersFetch.map((m) => m.intId); + + await Promise.all(voiceUsers.map(async (voice) => { + if (voiceUsersIdsToUpdate.indexOf(voice.intId) >= 0) { + // user already exist, then update + await updateVoiceUser(meetingId, { + intId: voice.intId, + voiceUserId: voice.voiceUserId, + talking: voice.talking, + muted: voice.muted, + voiceConf: meeting.voiceProp.voiceConf, + joined: true, + }); + } else { + // user doesn't exist yet, then add it + await addVoiceUser(meetingId, { + voiceUserId: voice.voiceUserId, + intId: voice.intId, + callerName: voice.callerName, + callerNum: voice.callerNum, + color: voice.color, + muted: voice.muted, + talking: voice.talking, + callingWith: voice.callingWith, + listenOnly: voice.listenOnly, + voiceConf: meeting.voiceProp.voiceConf, + joined: true, + }); + + await addDialInUser(meetingId, voice); + } + })); + + // removing extra users already existing in Mongo + const voiceUsersToRemove = await VoiceUsers.find({ + meetingId, + intId: { $nin: usersIds }, + }, { fields: { voiceUserId: 1, intId: 1 } }).fetchAsync(); + + await Promise.all(voiceUsersToRemove.map(async (user) => { + await removeVoiceUser(meetingId, { + voiceConf: meeting.voiceProp.voiceConf, + voiceUserId: user.voiceUserId, + intId: user.intId, + }); + })); +} diff --git a/src/2.7.12/imports/api/voice-users/server/index.js b/src/2.7.12/imports/api/voice-users/server/index.js new file mode 100644 index 00000000..af6a7345 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './publishers'; +import './methods'; diff --git a/src/2.7.12/imports/api/voice-users/server/methods.js b/src/2.7.12/imports/api/voice-users/server/methods.js new file mode 100644 index 00000000..6e40c349 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/methods.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import muteToggle from './methods/muteToggle'; +import muteAllToggle from './methods/muteAllToggle'; +import muteAllExceptPresenterToggle from './methods/muteAllExceptPresenterToggle'; +import ejectUserFromVoice from './methods/ejectUserFromVoice'; + +Meteor.methods({ + toggleVoice: muteToggle, + muteAllUsers: muteAllToggle, + muteAllExceptPresenter: muteAllExceptPresenterToggle, + ejectUserFromVoice, +}); diff --git a/src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js b/src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js new file mode 100644 index 00000000..5705adf9 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function ejectUserFromVoice(userId, banUser) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'EjectUserFromVoiceCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + check(userId, String); + check(banUser, Boolean); + + const payload = { + userId, + ejectedBy: requesterUserId, + banUser, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method ejectUserFromVoice ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js b/src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js new file mode 100644 index 00000000..c0cf474c --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import Meetings from '/imports/api/meetings'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default async function muteAllExceptPresenterToggle() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'MuteAllExceptPresentersCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const meeting = await Meetings.findOneAsync({ meetingId }); + const toggleMeetingMuted = !meeting.voiceProp.muteOnStart; + + const payload = { + mutedBy: requesterUserId, + mute: toggleMeetingMuted, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method muteAllExceptPresenterToggle ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js b/src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js new file mode 100644 index 00000000..bb65d3b5 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import RedisPubSub from '/imports/startup/server/redis'; +import Meetings from '/imports/api/meetings'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default async function muteAllToggle() { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'MuteMeetingCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const meeting = await Meetings.findOneAsync({ meetingId }); + const toggleMeetingMuted = !meeting.voiceProp.muteOnStart; + + const payload = { + mutedBy: requesterUserId, + mute: toggleMeetingMuted, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method muteAllToggle ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js b/src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js new file mode 100644 index 00000000..7c8101a0 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js @@ -0,0 +1,66 @@ +import { Meteor } from 'meteor/meteor'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import RedisPubSub from '/imports/startup/server/redis'; +import Users from '/imports/api/users'; +import VoiceUsers from '/imports/api/voice-users'; +import Meetings from '/imports/api/meetings'; +import Logger from '/imports/startup/server/logger'; +import { check } from 'meteor/check'; + +export default async function muteToggle(uId, toggle) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'MuteUserCmdMsg'; + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const userToMute = uId || requesterUserId; + + const requester = await Users.findOneAsync({ + meetingId, + userId: requesterUserId, + }); + + const voiceUser = await VoiceUsers.findOneAsync({ + intId: userToMute, + meetingId, + }); + + if (!requester || !voiceUser) return; + + const { listenOnly, muted } = voiceUser; + if (listenOnly) return; + + // if allowModsToUnmuteUsers is false, users will be kicked out for attempting to unmute others + if (requesterUserId !== userToMute && muted) { + const meeting = await Meetings.findOneAsync({ meetingId }, + { fields: { 'usersProp.allowModsToUnmuteUsers': 1 } }); + if (meeting.usersProp && !meeting.usersProp.allowModsToUnmuteUsers) { + Logger.warn(`Attempted unmuting by another user meetingId:${meetingId} requester: ${requesterUserId} userId: ${userToMute}`); + return; + } + } + + let _muted; + + if ((toggle === undefined) || (toggle === null)) { + _muted = !muted; + } else { + _muted = !!toggle; + } + + const payload = { + userId: userToMute, + mutedBy: requesterUserId, + mute: _muted, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method muteToggle ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js new file mode 100644 index 00000000..2f5dc626 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js @@ -0,0 +1,46 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import VoiceUsers from '/imports/api/voice-users'; +import flat from 'flat'; + +export default async function addVoiceUser(meetingId, voiceUser) { + check(meetingId, String); + check(voiceUser, { + voiceUserId: String, + intId: String, + callerName: String, + callerNum: String, + color: String, + muted: Boolean, + talking: Boolean, + callingWith: String, + listenOnly: Boolean, + voiceConf: String, + joined: Boolean, // This is a HTML5 only param. + }); + + const { intId, talking } = voiceUser; + + const selector = { + meetingId, + intId, + }; + + const modifier = { + $set: { + meetingId, + spoke: talking, + ...flat(voiceUser), + }, + }; + + try { + const { numberAffected } = await VoiceUsers.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Add voice user=${intId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Add voice user=${intId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js b/src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js new file mode 100644 index 00000000..f67caef1 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js @@ -0,0 +1,31 @@ +import Meetings from '/imports/api/meetings'; +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; + +export default async function changeMuteMeeting(meetingId, payload) { + check(meetingId, String); + check(payload, { + muted: Boolean, + mutedBy: String, + }); + + const selector = { + meetingId, + }; + + const modifier = { + $set: { + 'voiceProp.muteOnStart': payload.muted, + }, + }; + + try { + const { numberAffected } = await Meetings.upsertAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Changed meeting mute status meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Changing meeting mute status meeting={${meetingId}} ${err}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js new file mode 100644 index 00000000..b34db7f2 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js @@ -0,0 +1,20 @@ +import Logger from '/imports/startup/server/logger'; +import VoiceUsers from '/imports/api/voice-users'; + +export default async function clearVoiceUser(meetingId, intId) { + try { + check(meetingId, String); + check(intId, String); + + const numberAffected = await VoiceUsers.removeAsync({ meetingId, intId }); + + if (numberAffected) { + Logger.info(`Remove voiceUser=${intId} meeting=${meetingId} (clear)`); + } + + return numberAffected; + } catch (error) { + Logger.error(`Error on clearing voiceUser=${intId} meeting=${meetingId}. ${error}`); + return 0; + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js new file mode 100644 index 00000000..9c0c28d7 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import VoiceUsers from '/imports/api/voice-users'; + +export default async function clearVoiceUser(meetingId) { + if (meetingId) { + try { + const numberAffected = await VoiceUsers.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared VoiceUsers in (${meetingId})`); + } + } catch (err) { + Logger.error(`Error on clearing VoiceUsers in ${meetingId}. ${err}`); + } + } else { + try { + const numberAffected = await VoiceUsers.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared VoiceUsers in all meetings'); + } + } catch (err) { + Logger.error(`Error on clearing VoiceUsers. ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js new file mode 100644 index 00000000..bb5f30e9 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js @@ -0,0 +1,41 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import VoiceUsers from '/imports/api/voice-users'; +import { clearSpokeTimeout } from '/imports/api/common/server/helpers'; + +export default async function removeVoiceUser(meetingId, voiceUser) { + check(meetingId, String); + check(voiceUser, { + voiceConf: String, + voiceUserId: String, + intId: String, + }); + + const { intId } = voiceUser; + + const selector = { + meetingId, + intId, + }; + + const modifier = { + $set: { + muted: false, + talking: false, + listenOnly: false, + joined: false, + spoke: false, + }, + }; + + try { + await clearSpokeTimeout(meetingId, intId); + const numberAffected = await VoiceUsers.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.info(`Remove voiceUser=${intId} meeting=${meetingId}`); + } + } catch (err) { + Logger.error(`Remove voiceUser=${intId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js new file mode 100644 index 00000000..4d0b6548 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js @@ -0,0 +1,88 @@ +import { Match, check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import VoiceUsers from '/imports/api/voice-users'; +import flat from 'flat'; +import { spokeTimeoutHandles, clearSpokeTimeout } from '/imports/api/common/server/helpers'; + +const TALKING_TIMEOUT = 6000; + +export default async function updateVoiceUser(meetingId, voiceUser) { + check(meetingId, String); + check(voiceUser, { + intId: String, + voiceUserId: String, + talking: Match.Maybe(Boolean), + muted: Match.Maybe(Boolean), + voiceConf: String, + joined: Match.Maybe(Boolean), + floor: Match.Maybe(Boolean), + lastFloorTime: Match.Maybe(String), + }); + + const { intId } = voiceUser; + + const selector = { + meetingId, + intId, + }; + + const modifier = { + $set: Object.assign( + flat(voiceUser), + ), + }; + + if (voiceUser.talking) { + const user = await VoiceUsers.findOneAsync({ meetingId, intId }, { + fields: { + startTime: 1, + }, + }); + + if (user && !user.startTime) modifier.$set.startTime = Date.now(); + modifier.$set.spoke = true; + modifier.$set.endTime = null; + clearSpokeTimeout(meetingId, intId); + } + + if (!voiceUser.talking) { + const timeoutHandle = Meteor.setTimeout(async () => { + const user = await VoiceUsers.findOneAsync({ meetingId, intId }, { + fields: { + endTime: 1, + talking: 1, + }, + }); + + if (user) { + const { endTime, talking } = user; + const spokeDelay = ((Date.now() - endTime) < TALKING_TIMEOUT); + if (talking || spokeDelay) return; + modifier.$set.spoke = false; + modifier.$set.startTime = null; + try { + const numberAffected = await VoiceUsers.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.debug('Update voiceUser', { voiceUser: intId, meetingId }); + } + } catch (err) { + Logger.error(`Update voiceUser=${intId}: ${err}`); + } + } + }, TALKING_TIMEOUT); + + spokeTimeoutHandles[`${meetingId}-${intId}`] = timeoutHandle; + modifier.$set.endTime = Date.now(); + } + + try { + const numberAffected = await VoiceUsers.updateAsync(selector, modifier); + + if (numberAffected) { + Logger.debug('Update voiceUser', { voiceUser: intId, meetingId }); + } + } catch (err) { + Logger.error(`Update voiceUser=${intId}: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/voice-users/server/publishers.js b/src/2.7.12/imports/api/voice-users/server/publishers.js new file mode 100644 index 00000000..942cf229 --- /dev/null +++ b/src/2.7.12/imports/api/voice-users/server/publishers.js @@ -0,0 +1,26 @@ +import VoiceUsers from '/imports/api/voice-users'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +async function voiceUser() { + const tokenValidation = await AuthTokenValidation + .findOneAsync({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing VoiceUsers was requested by unauth connection ${this.connection.id}`); + return VoiceUsers.find({ meetingId: '' }); + } + + const { meetingId, userId: requesterUserId } = tokenValidation; + Logger.debug('Publishing Voice User', { meetingId, requesterUserId }); + + return VoiceUsers.find({ meetingId }); +} + +function publish(...args) { + const boundVoiceUser = voiceUser.bind(this); + return boundVoiceUser(...args); +} + +Meteor.publish('voiceUsers', publish); diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/index.js b/src/2.7.12/imports/api/whiteboard-multi-user/index.js new file mode 100644 index 00000000..1ab39312 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/index.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; + +const collectionOptions = Meteor.isClient ? { + connection: null, +} : {}; + +const WhiteboardMultiUser = new Mongo.Collection('whiteboard-multi-user', collectionOptions); + +if (Meteor.isServer) { + // types of queries for the whiteboard-multi-user: + // 1. meetingId + + WhiteboardMultiUser._ensureIndex({ meetingId: 1 }); +} + +export default WhiteboardMultiUser; diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js new file mode 100644 index 00000000..7aca7caa --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js @@ -0,0 +1,7 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers'; +import handleGetWhiteboardAccess from './handlers/modifyWhiteboardAccess'; + +RedisPubSub.on('GetWhiteboardAccessRespMsg', processForHTML5ServerOnly(handleGetWhiteboardAccess)); +RedisPubSub.on('SyncGetWhiteboardAccessRespMsg', handleGetWhiteboardAccess); +RedisPubSub.on('ModifyWhiteboardAccessEvtMsg', handleGetWhiteboardAccess); diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js new file mode 100644 index 00000000..5d9b93fe --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js @@ -0,0 +1,12 @@ +import { check } from 'meteor/check'; +import modifyWhiteboardAccess from '../modifiers/modifyWhiteboardAccess'; + +export default async function handleModifyWhiteboardAccess({ body }, meetingId) { + const { multiUser, whiteboardId } = body; + + check(multiUser, Array); + check(whiteboardId, String); + check(meetingId, String); + const result = await modifyWhiteboardAccess(meetingId, whiteboardId, multiUser); + return result; +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js new file mode 100644 index 00000000..52e6be82 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js @@ -0,0 +1,31 @@ +import Users from '/imports/api/users'; +import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/'; + +const getMultiUser = async (meetingId, whiteboardId) => { + const data = await WhiteboardMultiUser.findOneAsync( + { + meetingId, + whiteboardId, + }, { fields: { multiUser: 1 } }, + ); + + if (!data || !data.multiUser || !Array.isArray(data.multiUser)) return []; + + return data.multiUser; +}; + +const getUsers = async (meetingId) => { + const data = await Users.find( + { meetingId }, + { fields: { userId: 1 } }, + ).fetchAsync(); + + if (!data) return []; + + return data.map(user => user.userId); +}; + +export { + getMultiUser, + getUsers, +}; diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/index.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/index.js new file mode 100644 index 00000000..92451ac7 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/index.js @@ -0,0 +1,3 @@ +import './eventHandlers'; +import './methods'; +import './publishers'; diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js new file mode 100644 index 00000000..4400535e --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import addGlobalAccess from './methods/addGlobalAccess'; +import addIndividualAccess from './methods/addIndividualAccess'; +import removeGlobalAccess from './methods/removeGlobalAccess'; +import removeIndividualAccess from './methods/removeIndividualAccess'; + +Meteor.methods({ + addGlobalAccess, + addIndividualAccess, + removeGlobalAccess, + removeIndividualAccess, +}); diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js new file mode 100644 index 00000000..4c31959c --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js @@ -0,0 +1,32 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { getUsers } from '/imports/api/whiteboard-multi-user/server/helpers'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function addGlobalAccess(whiteboardId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg'; + + check(whiteboardId, String); + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const multiUser = await getUsers(meetingId); + + const payload = { + multiUser, + whiteboardId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method addGlobalAccess ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js new file mode 100644 index 00000000..3bd78929 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js @@ -0,0 +1,37 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { getMultiUser } from '/imports/api/whiteboard-multi-user/server/helpers'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function addIndividualAccess(whiteboardId, userId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg'; + + check(whiteboardId, String); + check(userId, String); + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const multiUser = await getMultiUser(meetingId, whiteboardId); + + if (!multiUser.includes(userId)) { + multiUser.push(userId); + + const payload = { + multiUser, + whiteboardId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } + } catch (err) { + Logger.error(`Exception while invoking method addIndividualAccess ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js new file mode 100644 index 00000000..f54659fa --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js @@ -0,0 +1,29 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default function removeGlobalAccess(whiteboardId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg'; + + check(whiteboardId, String); + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const payload = { + multiUser: [], + whiteboardId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (err) { + Logger.error(`Exception while invoking method removeGlobalAccess ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js new file mode 100644 index 00000000..b7159664 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js @@ -0,0 +1,35 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { getMultiUser } from '/imports/api/whiteboard-multi-user/server/helpers'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import Logger from '/imports/startup/server/logger'; + +export default async function removeIndividualAccess(whiteboardId, userId) { + try { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg'; + + check(whiteboardId, String); + check(userId, String); + + const { meetingId, requesterUserId } = extractCredentials(this.userId); + + check(meetingId, String); + check(requesterUserId, String); + + const multiUser = await getMultiUser(meetingId, whiteboardId); + + if (multiUser.includes(userId)) { + const payload = { + multiUser: multiUser.filter(id => id !== userId), + whiteboardId, + }; + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } + } catch (err) { + Logger.error(`Exception while invoking method removeIndividualAccess ${err.stack}`); + } +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js new file mode 100644 index 00000000..34f97845 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js @@ -0,0 +1,26 @@ +import Logger from '/imports/startup/server/logger'; +import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user'; + +export default async function clearWhiteboardMultiUser(meetingId) { + if (meetingId) { + try { + const numberAffected = WhiteboardMultiUser.removeAsync({ meetingId }); + + if (numberAffected) { + Logger.info(`Cleared WhiteboardMultiUser (${meetingId})`); + } + } catch (err) { + Logger.info(`Error clearing WhiteboardMultiUser (${meetingId}). ${err}`); + } + } else { + try { + const numberAffected = await WhiteboardMultiUser.removeAsync({}); + + if (numberAffected) { + Logger.info('Cleared WhiteboardMultiUser (all)'); + } + } catch (err) { + Logger.info(`Error clearing WhiteboardMultiUser (all). ${err}`); + } + } +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js new file mode 100644 index 00000000..d236611b --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js @@ -0,0 +1,31 @@ +import { check } from 'meteor/check'; +import Logger from '/imports/startup/server/logger'; +import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/'; + +export default async function modifyWhiteboardAccess(meetingId, whiteboardId, multiUser) { + check(meetingId, String); + check(whiteboardId, String); + check(multiUser, Array); + + const selector = { + meetingId, + whiteboardId, + }; + + const modifier = { + meetingId, + whiteboardId, + multiUser, + }; + + try { + const { insertedId } = await WhiteboardMultiUser.upsertAsync(selector, modifier); + if (insertedId) { + Logger.info(`Added multiUser flag=${multiUser} meetingId=${meetingId} whiteboardId=${whiteboardId}`); + } else { + Logger.info(`Upserted multiUser flag=${multiUser} meetingId=${meetingId} whiteboardId=${whiteboardId}`); + } + } catch (err) { + Logger.error(`Error while adding an entry to Multi-User collection: ${err}`); + } +} diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js new file mode 100644 index 00000000..5d4b8dd9 --- /dev/null +++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js @@ -0,0 +1,27 @@ +import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/'; +import { Meteor } from 'meteor/meteor'; +import Logger from '/imports/startup/server/logger'; +import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation'; + +function whiteboardMultiUser() { + const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id }); + + if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) { + Logger.warn(`Publishing WhiteboardMultiUser was requested by unauth connection ${this.connection.id}`); + return WhiteboardMultiUser.find({ meetingId: '' }); + } + + const { meetingId, userId } = tokenValidation; + + Logger.debug('Publishing WhiteboardMultiUser', { meetingId, userId }); + + return WhiteboardMultiUser.find({ meetingId }); +} + + +function publish(...args) { + const boundMultiUser = whiteboardMultiUser.bind(this); + return boundMultiUser(...args); +} + +Meteor.publish('whiteboard-multi-user', publish); diff --git a/src/2.7.12/imports/startup/client/base.jsx b/src/2.7.12/imports/startup/client/base.jsx new file mode 100644 index 00000000..dfc1bd09 --- /dev/null +++ b/src/2.7.12/imports/startup/client/base.jsx @@ -0,0 +1,438 @@ +import React, { Component } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import PropTypes from 'prop-types'; +import Auth from '/imports/ui/services/auth'; +import AppContainer from '/imports/ui/components/app/container'; +import ErrorScreen from '/imports/ui/components/error-screen/component'; +import MeetingEnded from '/imports/ui/components/meeting-ended/component'; +import LoadingScreen from '/imports/ui/components/common/loading-screen/component'; +import Settings from '/imports/ui/services/settings'; +import logger from '/imports/startup/client/logger'; +import Users from '/imports/api/users'; +import { Session } from 'meteor/session'; +import { Meteor } from 'meteor/meteor'; +import Meetings from '/imports/api/meetings'; +import AppService from '/imports/ui/components/app/service'; +import deviceInfo from '/imports/utils/deviceInfo'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import { layoutSelectInput, layoutDispatch } from '../../ui/components/layout/context'; +import VideoService from '/imports/ui/components/video-provider/service'; +import DebugWindow from '/imports/ui/components/debug-window/component'; +import { ACTIONS, PANELS } from '../../ui/components/layout/enums'; +import { isChatEnabled } from '/imports/ui/services/features'; +import { makeCall } from '/imports/ui/services/api'; +import BBBStorage from '/imports/ui/services/storage'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; +const USER_WAS_EJECTED = 'userWasEjected'; +const CAPTIONS_ALWAYS_VISIBLE = Meteor.settings.public.app.audioCaptions.alwaysVisible; + +const HTML = document.getElementsByTagName('html')[0]; + +let checkedUserSettings = false; + +const propTypes = { + subscriptionsReady: PropTypes.bool, + approved: PropTypes.bool, + meetingHasEnded: PropTypes.bool.isRequired, + meetingExist: PropTypes.bool, +}; + +const defaultProps = { + approved: false, + meetingExist: false, + subscriptionsReady: false, +}; + +const fullscreenChangedEvents = [ + 'fullscreenchange', + 'webkitfullscreenchange', + 'mozfullscreenchange', + 'MSFullscreenChange', +]; + +class Base extends Component { + constructor(props) { + super(props); + + this.state = { + loading: false, + meetingExisted: false, + userRemoved: false, + }; + this.updateLoadingState = this.updateLoadingState.bind(this); + this.handleFullscreenChange = this.handleFullscreenChange.bind(this); + } + + handleFullscreenChange() { + const { layoutContextDispatch } = this.props; + + if (document.fullscreenElement + || document.webkitFullscreenElement + || document.mozFullScreenElement + || document.msFullscreenElement) { + Session.set('isFullscreen', true); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_FULLSCREEN_ELEMENT, + value: { + element: '', + group: '', + }, + }); + Session.set('isFullscreen', false); + } + } + + componentDidMount() { + const { animations, usersVideo, layoutContextDispatch } = this.props; + + layoutContextDispatch({ + type: ACTIONS.SET_NUM_CAMERAS, + value: usersVideo.length, + }); + + if (animations) HTML.classList.add('animationsEnabled'); + if (!animations) HTML.classList.add('animationsDisabled'); + + fullscreenChangedEvents.forEach((event) => { + document.addEventListener(event, this.handleFullscreenChange); + }); + Session.set('audioCaptions', CAPTIONS_ALWAYS_VISIBLE); + Session.set('isFullscreen', false); + } + + componentDidUpdate(prevProps, prevState) { + const { + approved, + meetingExist, + animations, + ejected, + isMeteorConnected, + subscriptionsReady, + layoutContextDispatch, + sidebarContentPanel, + usersVideo, + User, + } = this.props; + const { + loading, + meetingExisted, + } = this.state; + + if (usersVideo !== prevProps.usersVideo) { + layoutContextDispatch({ + type: ACTIONS.SET_NUM_CAMERAS, + value: usersVideo.length, + }); + } + + if (!prevProps.subscriptionsReady && subscriptionsReady) { + logger.info({ logCode: 'startup_client_subscriptions_ready' }, 'Subscriptions are ready'); + } + + if (prevProps.meetingExist && !meetingExist && !meetingExisted) { + this.setMeetingExisted(true); + } + + // In case the meteor restart avoid error log + if (isMeteorConnected && (prevState.meetingExisted !== meetingExisted) && meetingExisted) { + this.setMeetingExisted(false); + } + + // In case the meeting delayed to load + if (!subscriptionsReady || !meetingExist) return; + + if (approved && loading) this.updateLoadingState(false); + + if (prevProps.ejected || ejected) { + Session.set('codeError', '403'); + Session.set('isMeetingEnded', true); + } + + if (prevProps.User && !User) { + this.setUserRemoved(true); + } + + // In case the meteor restart avoid error log + if (isMeteorConnected && (prevState.meetingExisted !== meetingExisted)) { + this.setMeetingExisted(false); + } + + if ((prevProps.isMeteorConnected !== isMeteorConnected) && !isMeteorConnected) { + Session.set('globalIgnoreDeletes', true); + } + + const enabled = HTML.classList.contains('animationsEnabled'); + const disabled = HTML.classList.contains('animationsDisabled'); + + if (animations && animations !== prevProps.animations) { + if (disabled) HTML.classList.remove('animationsDisabled'); + HTML.classList.add('animationsEnabled'); + } else if (!animations && animations !== prevProps.animations) { + if (enabled) HTML.classList.remove('animationsEnabled'); + HTML.classList.add('animationsDisabled'); + } + + if (Session.equals('layoutReady', true) && (sidebarContentPanel === PANELS.NONE || Session.equals('subscriptionsReady', true))) { + if (!checkedUserSettings) { + const showAnimationsDefault = getFromUserSettings( + 'bbb_show_animations_default', + Meteor.settings.public.app.defaultSettings.application.animations + ); + + Settings.application.animations = showAnimationsDefault; + Settings.save(); + + if (getFromUserSettings('bbb_show_participants_on_login', Meteor.settings.public.layout.showParticipantsOnLogin) && !deviceInfo.isPhone) { + if (isChatEnabled() && getFromUserSettings('bbb_show_public_chat_on_login', !Meteor.settings.public.chat.startClosed)) { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.CHAT, + }); + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: PUBLIC_CHAT_ID, + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + } + } else { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + } + + if (Session.equals('subscriptionsReady', true)) { + checkedUserSettings = true; + } + } + } + } + + componentWillUnmount() { + fullscreenChangedEvents.forEach((event) => { + document.removeEventListener(event, this.handleFullscreenChange); + }); + } + + setMeetingExisted(meetingExisted) { + this.setState({ meetingExisted }); + } + + setUserRemoved(userRemoved) { + this.setState({ userRemoved }); + } + + updateLoadingState(loading = false) { + this.setState({ + loading, + }); + } + + static async setExitReason(reason) { + return await makeCall('setExitReason', reason); + } + + renderByState() { + const { loading, userRemoved } = this.state; + const { + codeError, + ejected, + ejectedReason, + meetingExist, + meetingHasEnded, + meetingEndedReason, + meetingIsBreakout, + subscriptionsReady, + userWasEjected, + } = this.props; + + if ((loading || !subscriptionsReady) && !meetingHasEnded && meetingExist) { + return ({loading}); + } + + if (( meetingHasEnded || ejected || userRemoved ) && meetingIsBreakout) { + Base.setExitReason('breakoutEnded').finally(() => { + Meteor.disconnect(); + window.close(); + }); + return null; + } + + if (ejected || userWasEjected) { + return ( + Base.setExitReason('ejected')} + /> + ); + } + + if ((meetingHasEnded && !meetingIsBreakout) || userWasEjected) { + return ( + Base.setExitReason('meetingEnded')} + /> + ); + } + + if ((codeError && !meetingHasEnded) || userWasEjected) { + // 680 is set for the codeError when the user requests a logout. + if (codeError !== '680') { + return ( Base.setExitReason('error')} />); + } + return ( Base.setExitReason('logout')} />); + } + + return (); + } + + render() { + const { + meetingExist, + codeError, + } = this.props; + const { meetingExisted } = this.state; + + return ( + <> + {meetingExist && Auth.loggedIn && } + { + (!meetingExisted && !meetingExist && Auth.loggedIn && !codeError) + ? + : this.renderByState() + } + + ); + } +} + +Base.propTypes = propTypes; +Base.defaultProps = defaultProps; + +const BaseContainer = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + + return ; +}; + +export default withTracker(() => { + const { + animations, + } = Settings.application; + + const { + credentials, + loggedIn, + } = Auth; + + const { meetingId } = credentials; + let breakoutRoomSubscriptionHandler; + let meetingModeratorSubscriptionHandler; + + const fields = { + approved: 1, + authed: 1, + ejected: 1, + ejectedReason: 1, + color: 1, + effectiveConnectionType: 1, + extId: 1, + guest: 1, + intId: 1, + locked: 1, + loggedOut: 1, + meetingId: 1, + userId: 1, + inactivityCheck: 1, + responseDelay: 1, + currentConnectionId: 1, + connectionIdUpdateTime: 1, + }; + const User = Users.findOne({ intId: credentials.requesterUserId }, { fields }); + const meeting = Meetings.findOne({ meetingId }, { + fields: { + meetingEnded: 1, + meetingEndedReason: 1, + meetingProp: 1, + }, + }); + + if (meeting && meeting.meetingEnded) { + Session.set('codeError', '410'); + } + + const approved = User?.approved && User?.guest; + const ejected = User?.ejected; + const ejectedReason = User?.ejectedReason; + const meetingEndedReason = meeting?.meetingEndedReason; + const currentConnectionId = User?.currentConnectionId; + const { connectionID, connectionAuthTime } = Auth; + const connectionIdUpdateTime = User?.connectionIdUpdateTime; + + if (ejected) { + // use the connectionID to block users, so we can detect if the user was + // blocked by the current connection. This is the case when a a user is + // ejected from a meeting but not permanently ejected. Permanent ejects are + // managed by the server, not by the client. + BBBStorage.setItem(USER_WAS_EJECTED, connectionID); + } + + if (currentConnectionId && currentConnectionId !== connectionID && connectionIdUpdateTime > connectionAuthTime) { + Session.set('codeError', '409'); + Session.set('errorMessageDescription', 'joined_another_window_reason') + } + + let userSubscriptionHandler; + + const codeError = Session.get('codeError'); + const { streams: usersVideo } = VideoService.getVideoStreams(); + + return { + userWasEjected: (BBBStorage.getItem(USER_WAS_EJECTED) == connectionID), + approved, + ejected, + ejectedReason, + userSubscriptionHandler, + breakoutRoomSubscriptionHandler, + meetingModeratorSubscriptionHandler, + animations, + User, + isMeteorConnected: Meteor.status().connected, + meetingExist: !!meeting, + meetingHasEnded: !!meeting && meeting.meetingEnded, + meetingEndedReason, + meetingIsBreakout: AppService.meetingIsBreakout(), + subscriptionsReady: Session.get('subscriptionsReady'), + loggedIn, + codeError, + usersVideo, + }; +})(BaseContainer); diff --git a/src/2.7.12/imports/startup/client/intl.jsx b/src/2.7.12/imports/startup/client/intl.jsx new file mode 100644 index 00000000..37eb639a --- /dev/null +++ b/src/2.7.12/imports/startup/client/intl.jsx @@ -0,0 +1,198 @@ +import React, { Component } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import PropTypes from 'prop-types'; +import { IntlProvider } from 'react-intl'; +import Settings from '/imports/ui/services/settings'; +import LoadingScreen from '/imports/ui/components/common/loading-screen/component'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import { Session } from 'meteor/session'; +import Logger from '/imports/startup/client/logger'; +import { formatLocaleCode } from '/imports/utils/string-utils'; +import Intl from '/imports/ui/services/locale'; +import { isEmpty } from 'radash'; + +const propTypes = { + locale: PropTypes.string, + overrideLocaleFromPassedParameter: PropTypes.string, + children: PropTypes.element.isRequired, +}; + +const DEFAULT_LANGUAGE = Meteor.settings.public.app.defaultSettings.application.fallbackLocale; +const CLIENT_VERSION = Meteor.settings.public.app.html5ClientBuild; +const FALLBACK_ON_EMPTY_STRING = Meteor.settings.public.app.fallbackOnEmptyLocaleString; + +const RTL_LANGUAGES = ['ar', 'dv', 'fa', 'he']; +const LARGE_FONT_LANGUAGES = ['te', 'km']; + +const defaultProps = { + locale: DEFAULT_LANGUAGE, + overrideLocaleFromPassedParameter: null, +}; + +class IntlStartup extends Component { + constructor(props) { + super(props); + + this.state = { + messages: {}, + normalizedLocale: null, + fetching: true, + }; + + if (RTL_LANGUAGES.includes(props.locale)) { + document.body.parentNode.setAttribute('dir', 'rtl'); + } + + this.fetchLocalizedMessages = this.fetchLocalizedMessages.bind(this); + } + + componentDidMount() { + const { locale, overrideLocaleFromPassedParameter } = this.props; + this.fetchLocalizedMessages(overrideLocaleFromPassedParameter || locale, true); + } + + componentDidUpdate(prevProps) { + const { fetching, messages, normalizedLocale } = this.state; + const { locale, overrideLocaleFromPassedParameter } = this.props; + + if (overrideLocaleFromPassedParameter !== prevProps.overrideLocaleFromPassedParameter) { + this.fetchLocalizedMessages(overrideLocaleFromPassedParameter); + } else { + const shouldFetch = (!fetching && isEmpty(messages)) || ((locale !== prevProps.locale) && (normalizedLocale && (locale !== normalizedLocale))); + if (shouldFetch) this.fetchLocalizedMessages(locale); + } + } + + fetchLocalizedMessages(locale, init = false) { + const url = `./locale?locale=${locale}&init=${init}`; + const localesPath = 'locales'; + + Intl.fetching = true; + this.setState({ fetching: true }, () => { + fetch(url) + .then((response) => { + if (!response.ok) { + return false; + } + return response.json(); + }) + .then(({ normalizedLocale, regionDefaultLocale }) => { + const fetchFallbackMessages = new Promise((resolve, reject) => { + fetch(`${localesPath}/${DEFAULT_LANGUAGE}.json?v=${CLIENT_VERSION}`) + .then((response) => { + if (!response.ok) { + return reject(); + } + return resolve(response.json()); + }); + }); + + const fetchRegionMessages = new Promise((resolve) => { + if (!regionDefaultLocale) { + return resolve(false); + } + fetch(`${localesPath}/${regionDefaultLocale}.json?v=${CLIENT_VERSION}`) + .then((response) => { + if (!response.ok) { + return resolve(false); + } + return response.json() + .then((jsonResponse) => resolve(jsonResponse)) + .catch(() => { + Logger.error({ logCode: 'intl_parse_locale_SyntaxError' }, `Could not parse locale file ${regionDefaultLocale}.json, invalid json`); + resolve(false); + }); + }); + }); + + const fetchSpecificMessages = new Promise((resolve) => { + if (!normalizedLocale || normalizedLocale === DEFAULT_LANGUAGE || normalizedLocale === regionDefaultLocale) { + return resolve(false); + } + fetch(`${localesPath}/${normalizedLocale}.json?v=${CLIENT_VERSION}`) + .then((response) => { + if (!response.ok) { + return resolve(false); + } + return response.json() + .then((jsonResponse) => resolve(jsonResponse)) + .catch(() => { + Logger.error({ logCode: 'intl_parse_locale_SyntaxError' }, `Could not parse locale file ${normalizedLocale}.json, invalid json`); + resolve(false); + }); + }); + }); + + Promise.all([fetchFallbackMessages, fetchRegionMessages, fetchSpecificMessages]) + .then((values) => { + let mergedMessages = Object.assign({}, values[0]); + + if (!values[1] && !values[2]) { + normalizedLocale = DEFAULT_LANGUAGE; + } else { + if (values[1]) { + mergedMessages = Object.assign(mergedMessages, values[1]); + } + if (values[2]) { + mergedMessages = Object.assign(mergedMessages, values[2]); + } + } + + const dasherizedLocale = normalizedLocale.replace('_', '-'); + const { language, formattedLocale } = formatLocaleCode(dasherizedLocale); + Intl.setLocale(formattedLocale, mergedMessages); + + this.setState({ messages: mergedMessages, fetching: false, normalizedLocale: dasherizedLocale }, () => { + Settings.application.locale = dasherizedLocale; + if (RTL_LANGUAGES.includes(dasherizedLocale.substring(0, 2))) { + document.body.parentNode.setAttribute('dir', 'rtl'); + Settings.application.isRTL = true; + } else { + document.body.parentNode.setAttribute('dir', 'ltr'); + Settings.application.isRTL = false; + } + Session.set('isLargeFont', LARGE_FONT_LANGUAGES.includes(dasherizedLocale.substring(0, 2))); + window.dispatchEvent(new Event('localeChanged')); + document.getElementsByTagName('html')[0].lang = formattedLocale; + document.body.classList.add(`lang-${language}`); + Settings.save(); + }); + }); + }); + }); + } + + render() { + const { fetching, normalizedLocale, messages } = this.state; + const { children } = this.props; + const { formattedLocale } = formatLocaleCode(normalizedLocale); + + return ( + <> + {(fetching || !normalizedLocale) && } + + {normalizedLocale + && ( + + {children} + + ) + } + + ); + } +} + +const IntlStartupContainer = withTracker(() => { + const { locale } = Settings.application; + const overrideLocaleFromPassedParameter = getFromUserSettings('bbb_override_default_locale', null); + return { + locale, + overrideLocaleFromPassedParameter, + }; +})(IntlStartup); + +export default IntlStartupContainer; + +IntlStartup.propTypes = propTypes; +IntlStartup.defaultProps = defaultProps; diff --git a/src/2.7.12/imports/startup/client/logger.js b/src/2.7.12/imports/startup/client/logger.js new file mode 100644 index 00000000..c08ce869 --- /dev/null +++ b/src/2.7.12/imports/startup/client/logger.js @@ -0,0 +1,126 @@ +import Auth from '/imports/ui/services/auth'; +import { Meteor } from 'meteor/meteor'; +import { createLogger, stdSerializers } from 'browser-bunyan'; +import { ConsoleFormattedStream } from '@browser-bunyan/console-formatted-stream'; +import { ConsoleRawStream } from '@browser-bunyan/console-raw-stream'; +import { ServerStream } from '@browser-bunyan/server-stream'; +import { nameFromLevel } from '@browser-bunyan/levels'; + +// The logger accepts "console","server", and "external" as targets +// Multiple targets can be set as an array in the settings under public.log +// To add more targets use the format { "target": "server", "level": "info" }, +// and add it to the public.log array +// The accepted levels are "debug", "info", "warn", "error" +// To send to URL, use the format {"target": "external","level": "info", +// "url": "","method": ""} +// externalURL is the end-point that logs will be sent to +// Call the logger by doing a function call with the level name, I.e, logger.warn('Hi on warn') + +const LOG_CONFIG = Meteor.settings.public.clientLog || { console: { enabled: true, level: 'info' } }; + +// Custom stream that logs to an end-point +class ServerLoggerStream extends ServerStream { + constructor(params) { + super(params); + + if (params.logTag) { + this.logTagString = params.logTag; + } + } + + write(rec) { + const { fullInfo } = Auth; + + this.rec = rec; + if (fullInfo.meetingId != null) { + this.rec.userInfo = fullInfo; + } + this.rec.clientBuild = Meteor.settings.public.app.html5ClientBuild; + this.rec.connectionId = Meteor.connection._lastSessionId; + if (this.logTagString) { + this.rec.logTag = this.logTagString; + } + return super.write(this.rec); + } +} + +// Custom stream to log to the meteor server +class MeteorStream { + write(rec) { + const { fullInfo } = Auth; + const clientURL = window.location.href; + + this.rec = rec; + if (fullInfo.meetingId != null) { + if (!this.rec.extraInfo) { + this.rec.extraInfo = {}; + } + + this.rec.extraInfo.clientURL = clientURL; + + Meteor.call( + 'logClient', + nameFromLevel[this.rec.level], + this.rec.msg, + this.rec.logCode, + this.rec.extraInfo, + fullInfo, + ); + } else { + Meteor.call( + 'logClient', + nameFromLevel[this.rec.level], + this.rec.msg, + this.rec.logCode, + { ...rec.extraInfo, clientURL }, + ); + } + } +} + + +function createStreamForTarget(target, options) { + const TARGET_EXTERNAL = 'external'; + const TARGET_CONSOLE = 'console'; + const TARGET_SERVER = 'server'; + + let Stream = ConsoleRawStream; + switch (target) { + case TARGET_EXTERNAL: + Stream = ServerLoggerStream; + break; + case TARGET_CONSOLE: + Stream = ConsoleFormattedStream; + break; + case TARGET_SERVER: + Stream = MeteorStream; + break; + default: + Stream = ConsoleFormattedStream; + } + + return new Stream(options); +} + +function generateLoggerStreams(config) { + let result = []; + Object.keys(config).forEach((key) => { + const logOption = config[key]; + if (logOption && logOption.enabled) { + const { level, ...streamOptions } = logOption; + result = result.concat({ level, stream: createStreamForTarget(key, streamOptions) }); + } + }); + return result; +} + +// Creates the logger with the array of streams of the chosen targets +const logger = createLogger({ + name: 'clientLogger', + streams: generateLoggerStreams(LOG_CONFIG), + serializers: stdSerializers, + src: true, +}); + + +export default logger; diff --git a/src/2.7.12/imports/startup/server/ClientConnections.js b/src/2.7.12/imports/startup/server/ClientConnections.js new file mode 100644 index 00000000..2e575bee --- /dev/null +++ b/src/2.7.12/imports/startup/server/ClientConnections.js @@ -0,0 +1,180 @@ +import Logger from './logger'; +import userLeaving from '/imports/api/users/server/methods/userLeaving'; +import { extractCredentials } from '/imports/api/common/server/helpers'; +import AuthTokenValidation from '/imports/api/auth-token-validation'; +import Users from '/imports/api/users'; +import { check } from 'meteor/check'; + +const { enabled, syncInterval } = Meteor.settings.public.syncUsersWithConnectionManager; + +class ClientConnections { + constructor() { + Logger.debug('Initializing client connections structure', { logCode: 'client_connections_init' }); + this.connections = new Map(); + + setInterval(() => { + this.print(); + }, 30000); + + if (enabled) { + const syncConnections = Meteor.bindEnvironment(() => { + this.syncConnectionsWithServer(); + }); + + setInterval(() => { + syncConnections(); + }, syncInterval); + } + } + + add(sessionId, connection) { + Logger.info('Client connections add called', { logCode: 'client_connections_add', extraInfo: { sessionId, connection } }); + if (!sessionId || !connection) { + Logger.error(`Error on add new client connection. sessionId=${sessionId} connection=${connection.id}`, + { logCode: 'client_connections_add_error', extraInfo: { sessionId, connection } } + ); + + return; + } + + const { meetingId, requesterUserId: userId } = extractCredentials(sessionId); + + check(meetingId, String); + check(userId, String); + + if (!meetingId) { + Logger.error(`Error on add new client connection. sessionId=${sessionId} connection=${connection.id}`, + { logCode: 'client_connections_add_error_meeting_id_null', extraInfo: { meetingId, userId } } + ); + return false; + } + + if (!this.exists(meetingId)) { + Logger.info(`Meeting not found in connections: meetingId=${meetingId}`); + this.createMeetingConnections(meetingId); + } + + const sessionConnections = this.connections.get(meetingId); + + if (sessionConnections.has(userId) && sessionConnections.get(userId).includes(connection.id)) { + Logger.info(`Connection already exists for user. userId=${userId} connectionId=${connection.id}`); + + return false; + } + + connection.onClose(Meteor.bindEnvironment(() => { + userLeaving(meetingId, userId, connection.id); + })); + + Logger.info(`Adding new connection for sessionId=${sessionId} connection=${connection.id}`); + + if (!sessionConnections.has(userId)) { + Logger.info(`Creating connections poll for ${userId}`); + + sessionConnections.set(userId, []); + return sessionConnections.get(userId).push(connection.id); + } else { + return sessionConnections.get(userId).push(connection.id); + } + } + + createMeetingConnections(meetingId) { + Logger.info(`Creating meeting in connections. meetingId=${meetingId}`); + + if (!this.exists(meetingId)) + return this.connections.set(meetingId, new Map()); + } + + exists(meetingId) { + return this.connections.has(meetingId); + } + + getConnectionsForClient(sessionId) { + const { meetingId, requesterUserId: userId } = extractCredentials(sessionId); + + check(meetingId, String); + check(userId, String); + + return this.connections.get(meetingId)?.get(userId); + } + + print() { + const mapConnectionsObj = {}; + this.connections.forEach((value, key) => { + mapConnectionsObj[key] = {}; + + value.forEach((v, k) => { + mapConnectionsObj[key][k] = v; + }); + + }); + Logger.info('Active connections', mapConnectionsObj); + } + + removeClientConnection(sessionId, connectionId = null) { + Logger.info(`Removing connectionId for user. sessionId=${sessionId} connectionId=${connectionId}`); + const { meetingId, requesterUserId: userId } = extractCredentials(sessionId); + + check(meetingId, String); + check(userId, String); + + const meetingConnections = this.connections.get(meetingId); + + if (meetingConnections?.has(userId)) { + const filteredConnections = meetingConnections.get(userId).filter(c => c !== connectionId); + + return connectionId && filteredConnections.length ? meetingConnections.set(userId, filteredConnections) : meetingConnections.delete(userId); + } + + return false; + } + + removeMeeting(meetingId) { + Logger.debug(`Removing connections for meeting=${meetingId}`); + return this.connections.delete(meetingId); + } + + syncConnectionsWithServer() { + Logger.info('Syncing ClientConnections with server'); + const activeConnections = Array.from(Meteor.server.sessions.keys()); + + Logger.debug(`Found ${activeConnections.length} active connections in server`); + + const onlineUsers = AuthTokenValidation + .find( + { connectionId: { $in: activeConnections } }, + { fields: { meetingId: 1, userId: 1 } } + ) + .fetch(); + + const onlineUsersId = onlineUsers.map(({ userId }) => userId); + + const usersQuery = { userId: { $nin: onlineUsersId } }; + + const userWithoutConnectionIds = Users.find(usersQuery, { fields: { meetingId: 1, userId: 1 } }).fetch(); + + const removedUsersWithoutConnection = Users.remove(usersQuery); + + if (removedUsersWithoutConnection) { + Logger.info(`Removed ${removedUsersWithoutConnection} users that are not connected`); + Logger.info(`Clearing connections`); + try { + userWithoutConnectionIds + .forEach(({ meetingId, userId }) => { + this.removeClientConnection(`${meetingId}--${userId}`); + }); + } catch (err) { + Logger.error('Error on sync ClientConnections', err); + } + } + } + +} + +if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') { + Logger.info("ClientConnectionsSingleton was created") + + const ClientConnectionsSingleton = new ClientConnections(); + + export default ClientConnectionsSingleton; +} diff --git a/src/2.7.12/imports/startup/server/index.js b/src/2.7.12/imports/startup/server/index.js new file mode 100644 index 00000000..00c37184 --- /dev/null +++ b/src/2.7.12/imports/startup/server/index.js @@ -0,0 +1,360 @@ +import { Meteor } from 'meteor/meteor'; +import { WebAppInternals } from 'meteor/webapp'; +import Langmap from 'langmap'; +import fs from 'fs'; +import Users from '/imports/api/users'; +import './settings'; +import { check } from 'meteor/check'; +import Logger from './logger'; +import Redis from './redis'; + +import setMinBrowserVersions from './minBrowserVersion'; +import { PrometheusAgent, METRIC_NAMES } from './prom-metrics/index.js' + +let guestWaitHtml = ''; + +const env = Meteor.isDevelopment ? 'development' : 'production'; + +const meteorRoot = fs.realpathSync(`${process.cwd()}/../`); + +const applicationRoot = (env === 'development') + ? fs.realpathSync(`${meteorRoot}'/../../../../public/locales/`) + : fs.realpathSync(`${meteorRoot}/../programs/web.browser/app/locales/`); + +const AVAILABLE_LOCALES = fs.readdirSync(`${applicationRoot}`); +const FALLBACK_LOCALES = JSON.parse(Assets.getText('config/fallbackLocales.json')); + +process.on('uncaughtException', (err) => { + Logger.error(`uncaughtException: ${err}`); + process.exit(1); +}); + +const formatMemoryUsage = (data) => `${Math.round(data / 1024 / 1024 * 100) / 100} MB` + +const serverHealth = () => { + const memoryData = process.memoryUsage(); + const memoryUsage = { + rss: formatMemoryUsage(memoryData.rss), + heapTotal: formatMemoryUsage(memoryData.heapTotal), + heapUsed: formatMemoryUsage(memoryData.heapUsed), + external: formatMemoryUsage(memoryData.external), + } + + const cpuData = process.cpuUsage(); + const cpuUsage = { + system: formatMemoryUsage(cpuData.system), + user: formatMemoryUsage(cpuData.user), + } + + Logger.info('Server health', {memoryUsage, cpuUsage}); +}; + +Meteor.startup(() => { + const APP_CONFIG = Meteor.settings.public.app; + const CDN_URL = APP_CONFIG.cdn; + const instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1; + + Logger.warn(`Started bbb-html5 process with instanceId=${instanceId}`); + + const LOG_CONFIG = Meteor.settings.private.serverLog; + const { healthChecker } = LOG_CONFIG; + const { enable: enableHealthCheck, intervalMs: healthCheckInterval } = healthChecker; + + if (enableHealthCheck) { + Meteor.setInterval(() => { + serverHealth(); + }, healthCheckInterval); + } + + const { customHeartbeat, customHeartbeatUseDataFrames } = APP_CONFIG; + + if (customHeartbeat) { + Logger.warn('Custom heartbeat functions are enabled'); + // https://github.com/sockjs/sockjs-node/blob/1ef08901f045aae7b4df0f91ef598d7a11e82897/lib/transport/websocket.js#L74-L82 + const heartbeatFactory = function ({ heartbeatTimeoutCallback }) { + return function () { + const currentTime = new Date().getTime(); + + if (customHeartbeatUseDataFrames) { + // Skipping heartbeat, because websocket is sending data + if (currentTime - this.ws.lastSentFrameTimestamp < 10000) { + try { + Logger.debug('Skipping heartbeat, because websocket is sending data', { + currentTime, + lastSentFrameTimestamp: this.ws.lastSentFrameTimestamp, + userId: this.session?.connection?._meteorSession?.userId, + }); + return; + } catch (err) { + Logger.error(`Skipping heartbeat error: ${err}`); + } + } + } + + const supportsHeartbeats = this.ws.ping(null, () => { + clearTimeout(this.hto_ref); + }); + + if (supportsHeartbeats) { + this.hto_ref = setTimeout(() => { + try { + Logger.warn('Heartbeat timeout', { userId: this.session?.connection?._meteorSession?.userId, sentAt: currentTime, now: new Date().getTime() }); + } catch (err) { + Logger.error(`Heartbeat timeout error: ${err}`); + } finally { + if (typeof heartbeatTimeoutCallback === 'function') { + heartbeatTimeoutCallback(); + } + } + }, Meteor.server.options.heartbeatTimeout); + } else { + Logger.error('Unexpected error supportsHeartbeats=false'); + } + }; + }; + + // https://github.com/davhani/hagty/blob/6a5c78e9ae5a5e4ade03e747fb4cc8ea2df4be0c/faye-websocket/lib/faye/websocket/api.js#L84-L88 + const newSend = function send(data) { + try { + this.lastSentFrameTimestamp = new Date().getTime(); + + if (this.meteorHeartbeat) { + // Call https://github.com/meteor/meteor/blob/1e7e56eec8414093cd0c1c70750b894069fc972a/packages/ddp-common/heartbeat.js#L80-L88 + this.meteorHeartbeat._seenPacket = true; + if (this.meteorHeartbeat._heartbeatTimeoutHandle) { + this.meteorHeartbeat._clearHeartbeatTimeoutTimer(); + } + } + + if (this.readyState > 1/* API.OPEN = 1 */) return false; + if (!(data instanceof Buffer)) data = String(data); + return this._driver.messages.write(data); + } catch (err) { + console.error('Error on send data', err); + return false; + } + }; + + Meteor.setInterval(() => { + for (const session of Meteor.server.sessions.values()) { + const { socket } = session; + const recv = socket._session.recv; + + if (session.bbbFixApplied || !recv || !recv.ws) { + continue; + } + + recv.ws.meteorHeartbeat = session.heartbeat; + recv.heartbeat = heartbeatFactory({ + heartbeatTimeoutCallback: recv.heartbeat_cb + }); + recv.ws.send = newSend; + session.bbbFixApplied = true; + } + }, 5000); + } + if (CDN_URL.trim()) { + // Add CDN + BrowserPolicy.content.disallowEval(); + BrowserPolicy.content.allowInlineScripts(); + BrowserPolicy.content.allowInlineStyles(); + BrowserPolicy.content.allowImageDataUrl(CDN_URL); + BrowserPolicy.content.allowFontDataUrl(CDN_URL); + BrowserPolicy.content.allowOriginForAll(CDN_URL); + WebAppInternals.setBundledJsCssPrefix(CDN_URL + APP_CONFIG.basename + Meteor.settings.public.app.instanceId); + + const fontRegExp = /\.(eot|ttf|otf|woff|woff2)$/; + + WebApp.rawConnectHandlers.use('/', (req, res, next) => { + if (fontRegExp.test(req._parsedUrl.pathname)) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Vary', 'Origin'); + res.setHeader('Pragma', 'public'); + res.setHeader('Cache-Control', '"public"'); + } + return next(); + }); + } + + setMinBrowserVersions(); + + Meteor.onMessage(event => { + const { method } = event; + if (method) { + const methodName = method.includes('stream-cursor') ? 'stream-cursor' : method; + PrometheusAgent.increment(METRIC_NAMES.METEOR_METHODS, { methodName }); + } + }); + + Logger.warn(`SERVER STARTED. + ENV=${env} + nodejs version=${process.version} + BBB_HTML5_ROLE=${process.env.BBB_HTML5_ROLE} + INSTANCE_ID=${instanceId} + PORT=${process.env.PORT} + CDN=${CDN_URL}\n`, APP_CONFIG); +}); + + +const generateLocaleOptions = () => { + try { + Logger.warn('Calculating aggregateLocales (heavy)'); + + + // remove duplicated locales (always remove more generic if same name) + const tempAggregateLocales = AVAILABLE_LOCALES + .map(file => file.replace('.json', '')) + .map(file => file.replace('_', '-')) + .map((locale) => { + const localeName = (Langmap[locale] || {}).nativeName + || (FALLBACK_LOCALES[locale] || {}).nativeName + || locale; + return { + locale, + name: localeName, + }; + }).reverse() + .filter((item, index, self) => index === self.findIndex(i => ( + i.name === item.name + ))) + .reverse(); + + Logger.warn(`Total locales: ${tempAggregateLocales.length}`, tempAggregateLocales); + + return tempAggregateLocales; + } catch (e) { + Logger.error(`'Could not process locales error: ${e}`); + return []; + } +}; + +let avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions()); + +WebApp.connectHandlers.use('/check', (req, res) => { + const payload = { html5clientStatus: 'running' }; + + res.setHeader('Content-Type', 'application/json'); + res.writeHead(200); + res.end(JSON.stringify(payload)); +}); + +WebApp.connectHandlers.use('/locale', (req, res) => { + const APP_CONFIG = Meteor.settings.public.app; + const fallback = APP_CONFIG.defaultSettings.application.fallbackLocale; + const override = APP_CONFIG.defaultSettings.application.overrideLocale; + const browserLocale = override && req.query.init === 'true' + ? override.split(/[-_]/g) : req.query.locale.split(/[-_]/g); + + let localeFile = fallback; + + const usableLocales = AVAILABLE_LOCALES + .map(file => file.replace('.json', '')) + .reduce((locales, locale) => (locale.match(browserLocale[0]) + ? [...locales, locale] + : locales), []); + + let normalizedLocale; + + const regionDefault = usableLocales.find(locale => browserLocale[0] === locale); + + if (browserLocale.length > 1) { + // browser asks for specific locale + normalizedLocale = `${browserLocale[0]}_${browserLocale[1]?.toUpperCase()}`; + + const normDefault = usableLocales.find(locale => normalizedLocale === locale); + if (normDefault) { + localeFile = normDefault; + } else { + if (regionDefault) { + localeFile = regionDefault; + } else { + const specFallback = usableLocales.find(locale => browserLocale[0] === locale.split("_")[0]); + if (specFallback) localeFile = specFallback; + } + } + } else { + // browser asks for region default locale + if (regionDefault && localeFile === fallback && regionDefault !== localeFile) { + localeFile = regionDefault; + } else { + const normFallback = usableLocales.find(locale => browserLocale[0] === locale.split("_")[0]); + if (normFallback) localeFile = normFallback; + } + } + + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + normalizedLocale: localeFile, + regionDefaultLocale: (regionDefault && regionDefault !== localeFile) ? regionDefault : '', + })); +}); + +WebApp.connectHandlers.use('/locale-list', (req, res) => { + if (!avaibleLocalesNamesJSON) { + avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions()); + } + + res.setHeader('Content-Type', 'application/json'); + res.writeHead(200); + res.end(avaibleLocalesNamesJSON); +}); + +WebApp.connectHandlers.use('/feedback', (req, res) => { + req.on('data', Meteor.bindEnvironment((data) => { + const body = JSON.parse(data); + const { + meetingId, + userId, + authToken, + userName: reqUserName, + comment, + rating, + } = body; + + check(meetingId, String); + check(userId, String); + check(authToken, String); + check(reqUserName, String); + check(comment, String); + check(rating, Number); + + const user = Users.findOne({ + meetingId, + userId, + authToken, + }); + + if (!user) { + Logger.warn('Couldn\'t find user for feedback'); + } + + res.setHeader('Content-Type', 'application/json'); + res.writeHead(200); + res.end(JSON.stringify({ status: 'ok' })); + + body.userName = user ? user.name : `[unconfirmed] ${reqUserName}`; + + const feedback = { + ...body, + }; + Logger.info('FEEDBACK LOG:', feedback); + })); +}); + +WebApp.connectHandlers.use('/guestWait', (req, res) => { + if (!guestWaitHtml) { + try { + guestWaitHtml = Assets.getText('static/guest-wait/guest-wait.html'); + } catch (e) { + Logger.warn(`Could not process guest wait html file: ${e}`); + } + } + + res.setHeader('Content-Type', 'text/html'); + res.writeHead(200); + res.end(guestWaitHtml); +}); + +export const eventEmitter = Redis.emitter; + +export const redisPubSub = Redis; diff --git a/src/2.7.12/imports/startup/server/logger.js b/src/2.7.12/imports/startup/server/logger.js new file mode 100644 index 00000000..7241b3db --- /dev/null +++ b/src/2.7.12/imports/startup/server/logger.js @@ -0,0 +1,45 @@ +import { Meteor } from 'meteor/meteor'; +import { createLogger, format, transports } from 'winston'; +import WinstonPromTransport from './prom-metrics/winstonPromTransport'; + +const LOG_CONFIG = Meteor?.settings?.private?.serverLog || {}; +const { level, includeServerInfo } = LOG_CONFIG; + +const serverInfoFormat = format.printf(({ level, message, timestamp, ...metadata }) => { + const instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1; + const role = process.env.BBB_HTML5_ROLE; + const server = includeServerInfo && !Meteor?.isDevelopment ? `${role}-${instanceId} ` : ""; + + const msg = `${timestamp} ${server}[${level}] : ${message}`; + const meta = Object.keys(metadata).length ? JSON.stringify(metadata) : ''; + return `${msg} ${meta}`; +}); + +const Logger = createLogger({ + level, + format: format.combine( + format.colorize({ level: true }), + format.splat(), + format.simple(), + format.timestamp(), + serverInfoFormat, + ), + transports: [ + // console logging + new transports.Console({ + prettyPrint: false, + humanReadableUnhandledException: true, + colorize: true, + handleExceptions: true, + level, + }), + // export error logs to prometheus + new WinstonPromTransport({ + level: 'error', + }), + ], +}); + +export default Logger; + +export const logger = Logger; diff --git a/src/2.7.12/imports/startup/server/metrics.js b/src/2.7.12/imports/startup/server/metrics.js new file mode 100644 index 00000000..b9ce7252 --- /dev/null +++ b/src/2.7.12/imports/startup/server/metrics.js @@ -0,0 +1,146 @@ +/* eslint-disable no-prototype-builtins */ + +import fs from 'fs'; +import path from 'path'; +import { Meteor } from 'meteor/meteor'; +import Logger from './logger'; + +const { + metricsDumpIntervalMs, + metricsFolderPath, + removeMeetingOnEnd, +} = Meteor.settings.private.redis.metrics; + +class Metrics { + constructor() { + this.metrics = {}; + } + + addEvent(meetingId, eventName, messageLength) { + if (!this.metrics.hasOwnProperty(meetingId)) { + this.metrics[meetingId] = { + currentlyInQueue: {}, + wasInQueue: {}, + }; + } + + const { currentlyInQueue } = this.metrics[meetingId]; + + if (!currentlyInQueue.hasOwnProperty(eventName)) { + currentlyInQueue[eventName] = { + count: 1, + payloadSize: messageLength, + }; + } else { + currentlyInQueue[eventName].count += 1; + currentlyInQueue[eventName].payloadSize += messageLength; + } + } + + processEvent(meetingId, eventName, size, processingStartTimestamp) { + const currentProcessingTimestamp = Date.now(); + const processTime = currentProcessingTimestamp - processingStartTimestamp; + + this.addEvent(meetingId, eventName, size); + + if (!this.metrics[meetingId].wasInQueue.hasOwnProperty(eventName)) { + this.metrics[meetingId].wasInQueue[eventName] = { + count: 1, + payloadSize: { + min: size, + max: size, + last: size, + total: size, + avg: size, + }, + processingTime: { + min: processTime, + max: processTime, + last: processTime, + total: processTime, + avg: processTime, + }, + }; + this.metrics[meetingId].currentlyInQueue[eventName].count -= 1; + + if (!this.metrics[meetingId].currentlyInQueue[eventName].count) { + delete this.metrics[meetingId].currentlyInQueue[eventName]; + } + } else { + const { currentlyInQueue, wasInQueue } = this.metrics[meetingId]; + + currentlyInQueue[eventName].count -= 1; + + if (!currentlyInQueue[eventName].count) { + delete currentlyInQueue[eventName]; + } + + const { payloadSize, processingTime } = wasInQueue[eventName]; + + wasInQueue[eventName].count += 1; + + payloadSize.last = size; + payloadSize.total += size; + + if (payloadSize.min > size) payloadSize.min = size; + if (payloadSize.max < size) payloadSize.max = size; + + payloadSize.avg = payloadSize.total / wasInQueue[eventName].count; + + if (processingTime.min > processTime) processingTime.min = processTime; + if (processingTime.max < processTime) processingTime.max = processTime; + + processingTime.last = processTime; + processingTime.total += processTime; + processingTime.avg = processingTime.total / wasInQueue[eventName].count; + } + } + + setAnnotationQueueLength(meetingId, size) { + this.metrics[meetingId].annotationQueueLength = size; + } + + startDumpFile() { + Meteor.setInterval(() => { + try { + const fileDate = new Date(); + const fullYear = fileDate.getFullYear(); + const month = (fileDate.getMonth() + 1).toString().padStart(2, '0'); + const day = fileDate.getDate().toString().padStart(2, '0'); + const hour = fileDate.getHours().toString().padStart(2, '0'); + const minutes = fileDate.getMinutes().toString().padStart(2, '0'); + const seconds = fileDate.getSeconds().toString().padStart(2, '0'); + + const folderName = `${fullYear}${month}${day}_${hour}`; + const fileName = `${folderName}${minutes}${seconds}_metrics.json`; + + const folderPath = path.join(metricsFolderPath, folderName); + const fullFilePath = path.join(folderPath, fileName); + + if (!fs.existsSync(folderPath)) { + Logger.debug(`Creating folder: ${folderPath}`); + fs.mkdirSync(folderPath); + } + + fs.writeFileSync(fullFilePath, JSON.stringify(this.metrics)); + + Logger.info('Metric file successfully written'); + } catch (err) { + Logger.error('Error on writing metrics to disk.', err); + } + }, metricsDumpIntervalMs); + } + + removeMeeting(meetingId) { + if (removeMeetingOnEnd) { + Logger.info(`Removing meeting ${meetingId} from metrics`); + delete this.metrics[meetingId]; + } else { + Logger.info(`Skipping remove of meeting ${meetingId} from metrics`); + } + } +} + +const metricsSingleton = new Metrics(); + +export default metricsSingleton; diff --git a/src/2.7.12/imports/startup/server/minBrowserVersion.js b/src/2.7.12/imports/startup/server/minBrowserVersion.js new file mode 100644 index 00000000..1bcec1ad --- /dev/null +++ b/src/2.7.12/imports/startup/server/minBrowserVersion.js @@ -0,0 +1,19 @@ +import { Meteor } from 'meteor/meteor'; +import { setMinimumBrowserVersions } from 'meteor/modern-browsers'; + +const setMinBrowserVersions = () => { + const { minBrowserVersions } = Meteor.settings.private; + + const versions = {}; + + minBrowserVersions.forEach((elem) => { + let { version } = elem; + if (version === 'Infinity') version = Infinity; + + versions[elem.browser] = version; + }); + + setMinimumBrowserVersions(versions, 'bbb-min'); +}; + +export default setMinBrowserVersions; diff --git a/src/2.7.12/imports/startup/server/prom-metrics/index.js b/src/2.7.12/imports/startup/server/prom-metrics/index.js new file mode 100644 index 00000000..76d2efad --- /dev/null +++ b/src/2.7.12/imports/startup/server/prom-metrics/index.js @@ -0,0 +1,35 @@ +import Agent from './promAgent.js'; + +import { + METRICS_PREFIX, + METRIC_NAMES, + buildMetrics +} from './metrics.js'; + +const { + enabled: METRICS_ENABLED, + path: METRICS_PATH, + collectDefaultMetrics: COLLECT_DEFAULT_METRICS, + } = Meteor.settings.private.prometheus + ? Meteor.settings.private.prometheus + : { enabled: false }; + +const PrometheusAgent = new Agent({ + path: METRICS_PATH, + prefix: METRICS_PREFIX, + collectDefaultMetrics: COLLECT_DEFAULT_METRICS, + role: process.env.BBB_HTML5_ROLE, + instanceId: parseInt(process.env.INSTANCE_ID, 10) || 1, +}); + +if (METRICS_ENABLED) { + PrometheusAgent.injectMetrics(buildMetrics()); + PrometheusAgent.start(); +} + +export { + METRIC_NAMES, + METRICS_PREFIX, + Agent, + PrometheusAgent, +}; diff --git a/src/2.7.12/imports/startup/server/prom-metrics/metrics.js b/src/2.7.12/imports/startup/server/prom-metrics/metrics.js new file mode 100644 index 00000000..903cbe52 --- /dev/null +++ b/src/2.7.12/imports/startup/server/prom-metrics/metrics.js @@ -0,0 +1,68 @@ +const { + Counter, + Gauge, + Histogram +} = require('prom-client'); + +const METRICS_PREFIX = 'html5_' +const METRIC_NAMES = { + METEOR_METHODS: 'meteorMethods', + METEOR_ERRORS_TOTAL: 'meteorErrorsTotal', + METEOR_RTT: 'meteorRtt', + REDIS_MESSAGE_QUEUE: 'redisMessageQueue', + REDIS_PAYLOAD_SIZE: 'redisPayloadSize', + REDIS_PROCESSING_TIME: 'redisProcessingTime' +} + +let METRICS; +const buildMetrics = () => { + if (METRICS == null) { + METRICS = { + [METRIC_NAMES.METEOR_METHODS]: new Counter({ + name: `${METRICS_PREFIX}meteor_methods`, + help: 'Total number of meteor methods processed in html5', + labelNames: ['methodName', 'role', 'instanceId'], + }), + + [METRIC_NAMES.METEOR_ERRORS_TOTAL]: new Counter({ + name: `${METRICS_PREFIX}meteor_errors_total`, + help: 'Total number of errors logs in meteor', + labelNames: ['errorMessage', 'role', 'instanceId'], + }), + + [METRIC_NAMES.METEOR_RTT]: new Histogram({ + name: `${METRICS_PREFIX}meteor_rtt_seconds`, + help: 'Round-trip time of meteor client-server connections in seconds', + buckets: [0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1, 1.5, 2, 2.5, 5], + labelNames: ['role', 'instanceId'], + }), + + [METRIC_NAMES.REDIS_MESSAGE_QUEUE]: new Gauge({ + name: `${METRICS_PREFIX}redis_message_queue`, + help: 'Message queue size in redis', + labelNames: ['meetingId', 'role', 'instanceId'], + }), + + [METRIC_NAMES.REDIS_PAYLOAD_SIZE]: new Histogram({ + name: `${METRICS_PREFIX}redis_payload_size`, + help: 'Redis events payload size', + labelNames: ['eventName', 'role', 'instanceId'], + }), + + [METRIC_NAMES.REDIS_PROCESSING_TIME]: new Histogram({ + name: `${METRICS_PREFIX}redis_processing_time`, + help: 'Redis events processing time in milliseconds', + labelNames: ['eventName', 'role', 'instanceId'], + }), + } + } + + return METRICS; +}; + +export { + METRICS_PREFIX, + METRIC_NAMES, + METRICS, + buildMetrics, +}; diff --git a/src/2.7.12/imports/startup/server/prom-metrics/promAgent.js b/src/2.7.12/imports/startup/server/prom-metrics/promAgent.js new file mode 100644 index 00000000..b7c668a8 --- /dev/null +++ b/src/2.7.12/imports/startup/server/prom-metrics/promAgent.js @@ -0,0 +1,96 @@ +import { + register, + collectDefaultMetrics, +} from 'prom-client'; + +import Logger from '../logger'; +const LOG_PREFIX = '[prom-scrape-agt]'; + +class PrometheusScrapeAgent { + constructor(options) { + this.metrics = {}; + this.started = false; + + this.path = options.path || '/metrics'; + this.collectDefaultMetrics = options.collectDefaultMetrics || false; + this.metricsPrefix = options.prefix || ''; + this.collectionTimeout = options.collectionTimeout || 10000; + this.roleAndInstanceLabels = + options.role + ? { role: options.role, instanceId: options.instanceId } + : {}; + } + + async collect(response) { + try { + response.writeHead(200, { 'Content-Type': register.contentType }); + const content = await register.metrics(); + response.end(content); + Logger.debug(`${LOG_PREFIX} Collected prometheus metrics:\n${content}`); + } catch (error) { + response.writeHead(500) + response.end(error.message); + Logger.error(`${LOG_PREFIX} Collecting prometheus metrics: ${error.message}`); + } + } + + start() { + if (this.collectDefaultMetrics) collectDefaultMetrics({ + prefix: this.metricsPrefix, + timeout: this.collectionTimeout, + labels: this.roleAndInstanceLabels, + }); + + WebApp.connectHandlers.use(this.path, (req, res) => { + return this.collect(res); + }); + + this.started = true; + }; + + injectMetrics(metricsDictionary) { + this.metrics = { ...this.metrics, ...metricsDictionary } + } + + increment(metricName, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels }; + metric.inc(labelsObject) + } + } + + decrement(metricName, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels }; + metric.dec(labelsObject) + } + } + + set(metricName, value, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels }; + metric.set(labelsObject, value) + } + } + + observe(metricName, value, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels }; + metric.observe(labelsObject, value) + } + } +} + +export default PrometheusScrapeAgent; diff --git a/src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js b/src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js new file mode 100644 index 00000000..44669b19 --- /dev/null +++ b/src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js @@ -0,0 +1,19 @@ +const Transport = require('winston-transport'); +import { PrometheusAgent, METRIC_NAMES } from './index.js' + +module.exports = class WinstonPromTransport extends Transport { + constructor(opts) { + super(opts); + + } + + log(info, callback) { + setImmediate(() => { + this.emit('logged', info); + }); + + PrometheusAgent.increment(METRIC_NAMES.METEOR_ERRORS_TOTAL, { errorMessage: info.message }); + + callback(); + } +}; diff --git a/src/2.7.12/imports/startup/server/redis.js b/src/2.7.12/imports/startup/server/redis.js new file mode 100644 index 00000000..0e3c7740 --- /dev/null +++ b/src/2.7.12/imports/startup/server/redis.js @@ -0,0 +1,416 @@ +import Redis from 'redis'; +import { Meteor } from 'meteor/meteor'; +import { EventEmitter2 } from 'eventemitter2'; +import { check } from 'meteor/check'; +import Logger from './logger'; +import Metrics from './metrics'; +import queue from 'queue'; +import { PrometheusAgent, METRIC_NAMES } from './prom-metrics/index.js' + +// Fake meetingId used for messages that have no meetingId +const NO_MEETING_ID = '_'; + +const { queueMetrics } = Meteor.settings.private.redis.metrics; +const { collectRedisMetrics: PROM_METRICS_ENABLED } = Meteor.settings.private.prometheus; + +const makeEnvelope = (channel, eventName, header, body, routing) => { + const envelope = { + envelope: { + name: eventName, + routing: routing || { + sender: 'html5-server', + }, + timestamp: Date.now(), + }, + core: { + header, + body, + }, + }; + + return JSON.stringify(envelope); +}; + +const getInstanceIdFromMessage = (parsedMessage) => { + // End meeting message does not seem to have systemProps + let instanceIdFromMessage = parsedMessage.core.body.props?.systemProps?.html5InstanceId; + + return instanceIdFromMessage; +}; + +class MeetingMessageQueue { + constructor(eventEmitter, asyncMessages = [], redisDebugEnabled = false) { + this.asyncMessages = asyncMessages; + this.emitter = eventEmitter; + this.queue = queue({ autostart: true, concurrency: 1 }); + this.redisDebugEnabled = redisDebugEnabled; + + this.handleTask = this.handleTask.bind(this); + this.queue.taskHandler = this.handleTask; + } + + handleTask(data, next) { + const { channel } = data; + const { envelope } = data.parsedMessage; + const { header } = data.parsedMessage.core; + const { body } = data.parsedMessage.core; + const { meetingId } = header; + const eventName = header.name; + const isAsync = this.asyncMessages.includes(channel) + || this.asyncMessages.includes(eventName); + + const beginHandleTimestamp = Date.now(); + let called = false; + + check(eventName, String); + check(body, Object); + + const callNext = () => { + if (called) return; + if (this.redisDebugEnabled) { + Logger.debug(`Redis: ${eventName} completed ${isAsync ? 'async' : 'sync'}`); + } + called = true; + + if (queueMetrics) { + const queueId = meetingId || NO_MEETING_ID; + const dataLength = JSON.stringify(data).length; + + Metrics.processEvent(queueId, eventName, dataLength, beginHandleTimestamp); + } + + const queueLength = this.queue.length; + + if (PROM_METRICS_ENABLED) { + const dataLength = JSON.stringify(data).length; + const currentTimestamp = Date.now(); + const processTime = currentTimestamp - beginHandleTimestamp; + PrometheusAgent.observe(METRIC_NAMES.REDIS_PROCESSING_TIME, processTime, { eventName }); + PrometheusAgent.observe(METRIC_NAMES.REDIS_PAYLOAD_SIZE, dataLength, { eventName }); + meetingId && PrometheusAgent.set(METRIC_NAMES.REDIS_MESSAGE_QUEUE, queueLength, { meetingId }); + } + + if (queueLength > 100) { + Logger.warn(`Redis: MeetingMessageQueue for meetingId=${meetingId} has queue size=${queueLength} `); + } + next(); + }; + + const onError = (reason) => { + Logger.error(`${eventName}: ${reason.stack ? reason.stack : reason}`); + callNext(); + }; + + try { + if (this.redisDebugEnabled) { + if (!Meteor.settings.private.analytics.includeChat && eventName === 'GroupChatMessageBroadcastEvtMsg') { + return; + } + Logger.debug(`Redis: ${JSON.stringify(data.parsedMessage.core)} emitted`); + } + + if (isAsync) { + callNext(); + } + + this.emitter + .emitAsync(eventName, { envelope, header, body }, meetingId) + .then(callNext) + .catch(onError); + } catch (reason) { + onError(reason); + } + } + + add(...args) { + const { taskHandler } = this.queue; + + this.queue.push(function (next) { + taskHandler(...args, next); + }) + + } +} + +class RedisPubSub { + static handlePublishError(err) { + if (err) { + Logger.error(err); + } + } + + constructor(config = {}) { + this.config = config; + + this.didSendRequestEvent = false; + const host = process.env.REDIS_HOST || Meteor.settings.private.redis.host; + const redisConf = Meteor.settings.private.redis; + this.instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1; // 1 also handles running in dev mode + this.role = process.env.BBB_HTML5_ROLE; + this.customRedisChannel = `to-html5-redis-channel${this.instanceId}`; + + const { password, port } = redisConf; + + if (password) { + this.pub = Redis.createClient({ host, port, password }); + this.sub = Redis.createClient({ host, port, password }); + this.pub.auth(password); + this.sub.auth(password); + } else { + this.pub = Redis.createClient({ host, port }); + this.sub = Redis.createClient({ host, port }); + } + + if (queueMetrics) { + Metrics.startDumpFile(); + } + + this.emitter = new EventEmitter2(); + this.meetingsQueues = {}; + // We create this _ meeting queue because we need to be able to handle system messages (no meetingId in core.header) + this.meetingsQueues[NO_MEETING_ID] = new MeetingMessageQueue(this.emitter, this.config.async, this.config.debug); + + this.handleSubscribe = this.handleSubscribe.bind(this); + this.handleMessage = this.handleMessage.bind(this); + } + + init() { + this.sub.on('psubscribe', Meteor.bindEnvironment(this.handleSubscribe)); + this.sub.on('pmessage', Meteor.bindEnvironment(this.handleMessage)); + + const channelsToSubscribe = this.config.subscribeTo; + + channelsToSubscribe.push(this.customRedisChannel); + + switch (this.role) { + case 'frontend': + this.sub.psubscribe('from-akka-apps-frontend-redis-channel'); + if (this.redisDebugEnabled) { + Logger.debug(`Redis: NodeJSPool:${this.instanceId} Role: frontend. Subscribed to 'from-akka-apps-frontend-redis-channel'`); + } + break; + case 'backend': + channelsToSubscribe.forEach((channel) => { + this.sub.psubscribe(channel); + if (this.redisDebugEnabled) { + Logger.debug(`Redis: NodeJSPool:${this.instanceId} Role: backend. Subscribed to '${channelsToSubscribe}'`); + } + }); + break; + default: + this.sub.psubscribe('from-akka-apps-frontend-redis-channel'); + channelsToSubscribe.forEach((channel) => { + this.sub.psubscribe(channel); + if (this.redisDebugEnabled) { + Logger.debug(`Redis: NodeJSPool:${this.instanceId} Role:${this.role} (likely only one nodejs running, doing both frontend and backend. Dev env? ). Subscribed to '${channelsToSubscribe}'`); + } + }); + + break; + } + } + + updateConfig(config) { + this.config = Object.assign({}, this.config, config); + this.redisDebugEnabled = this.config.debug; + } + + + // TODO: Move this out of this class, maybe pass as a callback to init? + handleSubscribe() { + if (this.didSendRequestEvent || this.role === 'frontend') return; + + // populate collections with pre-existing data + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'GetAllMeetingsReqMsg'; + + const body = { + requesterId: 'nodeJSapp', + html5InstanceId: this.instanceId, + }; + + this.publishSystemMessage(CHANNEL, EVENT_NAME, body); + this.didSendRequestEvent = true; + } + + handleMessage(pattern, channel, message) { + const parsedMessage = JSON.parse(message); + const { ignored: ignoredMessages, async } = this.config; + const eventName = parsedMessage.core.header.name; + + if (ignoredMessages.includes(channel) + || ignoredMessages.includes(eventName)) { + if (eventName === 'CheckAlivePongSysMsg') { + return; + } + if (this.redisDebugEnabled) { + Logger.debug(`Redis: ${eventName} skipped`); + } + return; + } + + if (this.redisDebugEnabled) { + Logger.warn('Received event to handle', { date: new Date().toISOString(), eventName }); + } + + // System messages like Create / Destroy Meeting, etc do not have core.header.meetingId. + // Process them in MeetingQueue['_'] --- the NO_MEETING queueId + const meetingIdFromMessageCoreHeader = parsedMessage.core.header.meetingId || NO_MEETING_ID; + + if (this.role === 'frontend') { + // receiving this message means we need to look at it. Frontends do not have instanceId. + if (meetingIdFromMessageCoreHeader === NO_MEETING_ID) { // if this is a system message + if (eventName === 'MeetingCreatedEvtMsg' || eventName === 'SyncGetMeetingInfoRespMsg') { + const meetingIdFromMessageMeetingProp = parsedMessage.core.body.props.meetingProp.intId; + this.meetingsQueues[meetingIdFromMessageMeetingProp] = new MeetingMessageQueue(this.emitter, async, this.redisDebugEnabled); + if (this.redisDebugEnabled) { + Logger.warn('Created frontend queue for meeting', { date: new Date().toISOString(), eventName, meetingIdFromMessageMeetingProp }); + } + } + } + + if (eventName === 'SendWhiteboardAnnotationsEvtMsg') { + // we need the instanceId in the handler to avoid calling the same upsert on the + // Annotations collection multiple times + parsedMessage.core.body.myInstanceId = this.instanceId; + } + + if (!this.meetingsQueues[meetingIdFromMessageCoreHeader]) { + Logger.warn(`Frontend meeting queue had not been initialized ${message}`, { eventName, meetingIdFromMessageCoreHeader }); + this.meetingsQueues[NO_MEETING_ID].add({ + pattern, + channel, + eventName, + parsedMessage, + }); + } else { + // process the event - whether it's a system message or not, the meetingIdFromMessageCoreHeader value is adjusted + this.meetingsQueues[meetingIdFromMessageCoreHeader].add({ + pattern, + channel, + eventName, + parsedMessage, + }); + } + } else { // backend + if (meetingIdFromMessageCoreHeader === NO_MEETING_ID) { // if this is a system message + const meetingIdFromMessageMeetingProp = parsedMessage.core.body.props?.meetingProp?.intId; + const instanceIdFromMessage = getInstanceIdFromMessage(parsedMessage); + + if (this.instanceId === instanceIdFromMessage) { + // create queue or destroy queue + if (eventName === 'MeetingCreatedEvtMsg' || eventName === 'SyncGetMeetingInfoRespMsg') { + this.meetingsQueues[meetingIdFromMessageMeetingProp] = new MeetingMessageQueue(this.emitter, async, this.redisDebugEnabled); + if (this.redisDebugEnabled) { + Logger.warn('Created backend queue for meeting', { date: new Date().toISOString(), eventName, meetingIdFromMessageMeetingProp }); + } + } + this.meetingsQueues[NO_MEETING_ID].add({ + pattern, + channel, + eventName, + parsedMessage, + }); + } else { + if (eventName === 'MeetingEndedEvtMsg' || eventName === 'MeetingDestroyedEvtMsg') { + // MeetingEndedEvtMsg does not follow the system message pattern for meetingId + // but we still need to process it on the backend which is processing the rest of the events + // for this meetingId (it does not contain instanceId either, so we cannot compare that) + const meetingIdForMeetingEnded = parsedMessage.core.body.meetingId; + if (!!this.meetingsQueues[meetingIdForMeetingEnded]) { + this.meetingsQueues[NO_MEETING_ID].add({ + pattern, + channel, + eventName, + parsedMessage, + }); + } + } + // ignore + } + } else { + // add to existing queue + if (!!this.meetingsQueues[meetingIdFromMessageCoreHeader]) { + // only handle message if we have a queue for the meeting. If we don't have a queue, it means it's for a different instanceId + this.meetingsQueues[meetingIdFromMessageCoreHeader].add({ + pattern, + channel, + eventName, + parsedMessage, + }); + } else { + // If we reach this line, this means that there is no existing queue for this redis "backend" message + // which means that the meeting is fully handled by another bbb-html5-backend. + // Logger.warn('Backend meeting queue had not been initialized', { eventName, meetingIdFromMessageCoreHeader }) + } + } + } + } + + destroyMeetingQueue(id) { + delete this.meetingsQueues[id]; + } + + on(...args) { + return this.emitter.on(...args); + } + + publishVoiceMessage(channel, eventName, voiceConf, payload) { + const header = { + name: eventName, + voiceConf, + }; + + const envelope = makeEnvelope(channel, eventName, header, payload); + + return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError); + } + + publishSystemMessage(channel, eventName, payload) { + const header = { + name: eventName, + }; + + const envelope = makeEnvelope(channel, eventName, header, payload); + + return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError); + } + + publishMeetingMessage(channel, eventName, meetingId, payload) { + const header = { + name: eventName, + meetingId, + }; + + const envelope = makeEnvelope(channel, eventName, header, payload); + + return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError); + } + + publishUserMessage(channel, eventName, meetingId, userId, payload) { + const header = { + name: eventName, + meetingId, + userId, + }; + + if (!meetingId || !userId) { + Logger.warn(`Publishing ${eventName} with potentially missing data userId=${userId} meetingId=${meetingId}`); + } + const envelope = makeEnvelope(channel, eventName, header, payload, { meetingId, userId }); + + return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError); + } +} + +const RedisPubSubSingleton = new RedisPubSub(); + +Meteor.startup(() => { + const REDIS_CONFIG = Meteor.settings.private.redis; + + RedisPubSubSingleton.updateConfig(REDIS_CONFIG); + RedisPubSubSingleton.init(); +}); + +export default RedisPubSubSingleton; diff --git a/src/2.7.12/imports/startup/server/settings.js b/src/2.7.12/imports/startup/server/settings.js new file mode 100644 index 00000000..bdab48a5 --- /dev/null +++ b/src/2.7.12/imports/startup/server/settings.js @@ -0,0 +1,35 @@ +/* global __meteor_runtime_config__ */ +import { Meteor } from 'meteor/meteor'; +import fs from 'fs'; +import YAML from 'yaml'; +import { defaultsDeep } from '/imports/utils/array-utils'; + +const DEFAULT_SETTINGS_FILE_PATH = process.env.BBB_HTML5_SETTINGS || 'assets/app/config/settings.yml'; +const LOCAL_SETTINGS_FILE_PATH = process.env.BBB_HTML5_LOCAL_SETTINGS || '/etc/bigbluebutton/bbb-html5.yml'; + +try { + if (fs.existsSync(DEFAULT_SETTINGS_FILE_PATH)) { + let SETTINGS = YAML.parse(fs.readFileSync(DEFAULT_SETTINGS_FILE_PATH, 'utf-8')); + + if (fs.existsSync(LOCAL_SETTINGS_FILE_PATH)) { + console.log('Local configuration found! Merging with default configuration...'); + const LOCAL_CONFIG = YAML.parse(fs.readFileSync(LOCAL_SETTINGS_FILE_PATH, 'utf-8')); + SETTINGS = defaultsDeep(LOCAL_CONFIG, SETTINGS); + } else console.log('Local Configuration not found! Loading default configuration...'); + + Meteor.settings = SETTINGS; + Meteor.settings.public.app.instanceId = ''; // no longer use instanceId in URLs. Likely permanent change + // Meteor.settings.public.app.instanceId = `/${INSTANCE_ID}`; + + Meteor.settings.public.packages = { + 'dynamic-import': { useLocationOrigin: true }, + }; + + __meteor_runtime_config__.PUBLIC_SETTINGS = SETTINGS.public; + } else { + throw new Error('File doesn\'t exists'); + } +} catch (error) { + // eslint-disable-next-line no-console + console.error('Error on load configuration file.', error); +} diff --git a/src/2.7.12/imports/ui/components/about/component.jsx b/src/2.7.12/imports/ui/components/about/component.jsx new file mode 100644 index 00000000..a240891d --- /dev/null +++ b/src/2.7.12/imports/ui/components/about/component.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import ModalSimple from '/imports/ui/components/common/modal/simple/component'; + +const intlMessages = defineMessages({ + title: { + id: 'app.about.title', + description: 'About title label', + }, + version: { + id: 'app.about.version', + description: 'Client version label', + }, + copyright: { + id: 'app.about.copyright', + defaultMessage: (new Date().getFullYear()), + description: 'Client copyright label', + }, + confirmLabel: { + id: 'app.about.confirmLabel', + description: 'Confirmation button label', + }, + confirmDesc: { + id: 'app.about.confirmDesc', + description: 'adds descriptive context to confirmLabel', + }, + dismissLabel: { + id: 'app.about.dismissLabel', + description: 'Dismiss button label', + }, + dismissDesc: { + id: 'app.about.dismissDesc', + description: 'adds descriptive context to dissmissLabel', + }, + version_label: { + id: 'app.about.version_label', + description: 'label for version bbb', + }, +}); + +const AboutComponent = (props) => { + const { intl, settings, isOpen, onRequestClose, priority, } = props; + const { + html5ClientBuild, + copyright, + bbbServerVersion, + displayBbbServerVersion, + } = settings; + + const showLabelVersion = () => ( + <> +
+ {`${intl.formatMessage(intlMessages.version_label)} ${bbbServerVersion}`} + + ); + + return ( + + {`${intl.formatMessage(intlMessages.copyright)} ${copyright}`} +
+ {`${intl.formatMessage(intlMessages.version)} ${html5ClientBuild}`} + {displayBbbServerVersion ? showLabelVersion() : null} + +
+ ); +}; + +export default injectIntl(AboutComponent); diff --git a/src/2.7.12/imports/ui/components/about/container.jsx b/src/2.7.12/imports/ui/components/about/container.jsx new file mode 100644 index 00000000..70b0c54a --- /dev/null +++ b/src/2.7.12/imports/ui/components/about/container.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; + +import AboutComponent from './component'; + +const AboutContainer = (props) => { + const { children } = props; + return ( + + {children} + + ); +}; + +const getClientBuildInfo = () => ( + { + settings: Meteor.settings.public.app, + + } +); + +export default withTracker(() => getClientBuildInfo())(AboutContainer); diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx new file mode 100644 index 00000000..6ad3ba64 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx @@ -0,0 +1,564 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages } from 'react-intl'; +import withShortcutHelper from '/imports/ui/components/shortcut-help/service'; +import ExternalVideoModal from '/imports/ui/components/external-video-player/modal/container'; +import RandomUserSelectContainer from '/imports/ui/components/common/modal/random-user/container'; +import LayoutModalContainer from '/imports/ui/components/layout/modal/container'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import Styled from './styles'; +import TimerService from '/imports/ui/components/timer/service'; +import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette'; +import { PANELS, ACTIONS, LAYOUT_TYPE } from '../../layout/enums'; +import { uniqueId } from '/imports/utils/string-utils'; +import { isPresentationEnabled, isLayoutsEnabled } from '/imports/ui/services/features'; +import VideoPreviewContainer from '/imports/ui/components/video-preview/container'; +import { screenshareHasEnded } from '/imports/ui/components/screenshare/service'; +import StreamingModal from '/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/container'; +import { stopStreaming } from './streaming-modal/service'; + +const propTypes = { + amIPresenter: PropTypes.bool.isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + amIModerator: PropTypes.bool.isRequired, + shortcuts: PropTypes.string, + handleTakePresenter: PropTypes.func.isRequired, + isTimerActive: PropTypes.bool.isRequired, + isTimerEnabled: PropTypes.bool.isRequired, + allowExternalVideo: PropTypes.bool.isRequired, + stopExternalVideoShare: PropTypes.func.isRequired, + isMobile: PropTypes.bool.isRequired, + setMeetingLayout: PropTypes.func.isRequired, + setPushLayout: PropTypes.func.isRequired, + showPushLayout: PropTypes.bool.isRequired, + isTimerFeatureEnabled: PropTypes.bool.isRequired, + isCameraAsContentEnabled: PropTypes.bool.isRequired, +}; + +const defaultProps = { + shortcuts: '', + settingsLayout: LAYOUT_TYPE.SMART_LAYOUT, +}; + +const intlMessages = defineMessages({ + actionsLabel: { + id: 'app.actionsBar.actionsDropdown.actionsLabel', + description: 'Actions button label', + }, + activateTimerStopwatchLabel: { + id: 'app.actionsBar.actionsDropdown.activateTimerStopwatchLabel', + description: 'Activate timer/stopwatch label', + }, + deactivateTimerStopwatchLabel: { + id: 'app.actionsBar.actionsDropdown.deactivateTimerStopwatchLabel', + description: 'Deactivate timer/stopwatch label', + }, + presentationLabel: { + id: 'app.actionsBar.actionsDropdown.presentationLabel', + description: 'Upload a presentation option label', + }, + presentationDesc: { + id: 'app.actionsBar.actionsDropdown.presentationDesc', + description: 'adds context to upload presentation option', + }, + desktopShareDesc: { + id: 'app.actionsBar.actionsDropdown.desktopShareDesc', + description: 'adds context to desktop share option', + }, + stopDesktopShareDesc: { + id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc', + description: 'adds context to stop desktop share option', + }, + pollBtnLabel: { + id: 'app.actionsBar.actionsDropdown.pollBtnLabel', + description: 'poll menu toggle button label', + }, + pollBtnDesc: { + id: 'app.actionsBar.actionsDropdown.pollBtnDesc', + description: 'poll menu toggle button description', + }, + takePresenter: { + id: 'app.actionsBar.actionsDropdown.takePresenter', + description: 'Label for take presenter role option', + }, + takePresenterDesc: { + id: 'app.actionsBar.actionsDropdown.takePresenterDesc', + description: 'Description of take presenter role option', + }, + startExternalVideoLabel: { + id: 'app.actionsBar.actionsDropdown.shareExternalVideo', + description: 'Start sharing external video button', + }, + stopExternalVideoLabel: { + id: 'app.actionsBar.actionsDropdown.stopShareExternalVideo', + description: 'Stop sharing external video button', + }, + selectRandUserLabel: { + id: 'app.actionsBar.actionsDropdown.selectRandUserLabel', + description: 'Label for selecting a random user', + }, + selectRandUserDesc: { + id: 'app.actionsBar.actionsDropdown.selectRandUserDesc', + description: 'Description for select random user option', + }, + layoutModal: { + id: 'app.actionsBar.actionsDropdown.layoutModal', + description: 'Label for layouts selection button', + }, + shareCameraAsContent: { + id: 'app.actionsBar.actionsDropdown.shareCameraAsContent', + description: 'Label for share camera as content', + }, + unshareCameraAsContent: { + id: 'app.actionsBar.actionsDropdown.unshareCameraAsContent', + description: 'Label for unshare camera as content', + }, +}); + +const handlePresentationClick = () => Session.set('showUploadPresentationView', true); + +class ActionsDropdown extends PureComponent { + constructor(props) { + super(props); + + this.presentationItemId = uniqueId('action-item-'); + this.pollId = uniqueId('action-item-'); + this.takePresenterId = uniqueId('action-item-'); + this.timerId = uniqueId('action-item-'); + this.selectUserRandId = uniqueId('action-item-'); + this.state = { + isExternalVideoModalOpen: false, + isRandomUserSelectModalOpen: false, + isLayoutModalOpen: false, + isCameraAsContentModalOpen: false, + isStreamingModalOpen: false, + isStreaming: false, + }; + + this.handleExternalVideoClick = this.handleExternalVideoClick.bind(this); + this.makePresentationItems = this.makePresentationItems.bind(this); + this.setExternalVideoModalIsOpen = this.setExternalVideoModalIsOpen.bind(this); + this.setRandomUserSelectModalIsOpen = this.setRandomUserSelectModalIsOpen.bind(this); + this.setLayoutModalIsOpen = this.setLayoutModalIsOpen.bind(this); + this.setCameraAsContentModalIsOpen = this.setCameraAsContentModalIsOpen.bind(this); + this.setPropsToPassModal = this.setPropsToPassModal.bind(this); + this.setForceOpen = this.setForceOpen.bind(this); + this.handleTimerClick = this.handleTimerClick.bind(this); + this.handleStreamingClick = this.handleStreamingClick.bind(this); + this.setStreamingModalIsOpen = this.setStreamingModalIsOpen.bind(this); + this.handleStreamingStatus = this.handleStreamingStatus.bind(this); + this.handleStopStreaming = this.handleStopStreaming.bind(this); + } + + componentDidUpdate(prevProps) { + const { amIPresenter: wasPresenter } = prevProps; + const { amIPresenter: isPresenter } = this.props; + if (wasPresenter && !isPresenter) { + this.setExternalVideoModalIsOpen(false); + } + } + + handleExternalVideoClick() { + this.setExternalVideoModalIsOpen(true); + } + + handleStreamingClick() { + this.setStreamingModalIsOpen(true); + } + + handleStreamingStatus() { + this.setState({ + ...this.state, + isStreaming: true, + }); + } + + async handleStopStreaming() { + try { + const response = await stopStreaming(); + + if (response.status == 200) { + this.setState({ + ...this.state, + isStreaming: false, + }); + } + } catch (error) { + console.error(error); + } + } + + handleTimerClick() { + const { isTimerActive, layoutContextDispatch } = this.props; + if (!isTimerActive) { + TimerService.activateTimer(layoutContextDispatch); + } else { + TimerService.deactivateTimer(); + } + } + + getAvailableActions() { + const { + intl, + amIPresenter, + allowExternalVideo, + handleTakePresenter, + isSharingVideo, + isPollingEnabled, + isSelectRandomUserEnabled, + stopExternalVideoShare, + isTimerActive, + isTimerEnabled, + layoutContextDispatch, + setMeetingLayout, + setPushLayout, + showPushLayout, + amIModerator, + isMobile, + hasCameraAsContent, + isCameraAsContentEnabled, + isTimerFeatureEnabled, + isPresentationManagementDisabled, + } = this.props; + + const { pollBtnLabel, presentationLabel, takePresenter } = intlMessages; + + const { formatMessage } = intl; + + const actions = []; + + if (amIPresenter && !isPresentationManagementDisabled && isPresentationEnabled()) { + actions.push({ + icon: 'upload', + dataTest: 'managePresentations', + label: formatMessage(presentationLabel), + key: this.presentationItemId, + onClick: handlePresentationClick, + dividerTop: this.props?.presentations?.length > 1 ? true : false, + }); + } + + if (amIPresenter && !this.state.isStreaming) { + actions.push({ + icon: 'external-video', + dataTest: 'streaming', + label: 'Start Streaming', + key: 'streaming', + onClick: this.handleStreamingClick, + }); + } + + if (amIPresenter && this.state.isStreaming) { + actions.push({ + icon: 'external-video_off', + dataTest: 'streaming', + label: 'Stop Streaming', + key: 'streaming', + onClick: this.handleStopStreaming, + }); + } + + if (amIPresenter && isPollingEnabled) { + actions.push({ + icon: 'polling', + dataTest: 'polling', + label: formatMessage(pollBtnLabel), + key: this.pollId, + onClick: () => { + if (Session.equals('pollInitiated', true)) { + Session.set('resetPollPanel', true); + } + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.POLL, + }); + Session.set('forcePollOpen', true); + }, + }); + } + + if (!amIPresenter && amIModerator) { + actions.push({ + icon: 'presentation', + label: formatMessage(takePresenter), + key: this.takePresenterId, + onClick: () => handleTakePresenter(), + }); + } + + if (amIPresenter && allowExternalVideo) { + actions.push({ + icon: !isSharingVideo ? 'external-video' : 'external-video_off', + label: !isSharingVideo + ? intl.formatMessage(intlMessages.startExternalVideoLabel) + : intl.formatMessage(intlMessages.stopExternalVideoLabel), + key: 'external-video', + onClick: isSharingVideo ? stopExternalVideoShare : this.handleExternalVideoClick, + dataTest: 'shareExternalVideo', + }); + } + + if (amIPresenter && isSelectRandomUserEnabled) { + actions.push({ + icon: 'user', + label: intl.formatMessage(intlMessages.selectRandUserLabel), + key: this.selectUserRandId, + onClick: () => this.setRandomUserSelectModalIsOpen(true), + dataTest: 'selectRandomUser', + }); + } + + if (amIModerator && isTimerEnabled && isTimerFeatureEnabled) { + actions.push({ + icon: 'time', + label: isTimerActive + ? intl.formatMessage(intlMessages.deactivateTimerStopwatchLabel) + : intl.formatMessage(intlMessages.activateTimerStopwatchLabel), + key: this.timerId, + onClick: () => this.handleTimerClick(), + }); + } + + if (isLayoutsEnabled() && (amIModerator || amIPresenter)) { + actions.push({ + icon: 'manage_layout', + label: intl.formatMessage(intlMessages.layoutModal), + key: 'layoutModal', + onClick: () => this.setLayoutModalIsOpen(true), + dataTest: 'manageLayoutBtn', + }); + } + + if (isCameraAsContentEnabled && amIPresenter) { + actions.push({ + icon: hasCameraAsContent ? 'video_off' : 'video', + label: hasCameraAsContent + ? intl.formatMessage(intlMessages.unshareCameraAsContent) + : intl.formatMessage(intlMessages.shareCameraAsContent), + key: 'camera as content', + onClick: hasCameraAsContent + ? screenshareHasEnded + : () => { + screenshareHasEnded(); + this.setCameraAsContentModalIsOpen(true); + }, + dataTest: 'shareCameraAsContent', + }); + } + + return actions; + } + + makePresentationItems() { + const { presentations, setPresentation, podIds, setPresentationFitToWidth } = this.props; + + if (!podIds || podIds.length < 1) return []; + + // We still have code for other pods from the Flash client. This intentionally only cares + // about the first one because it's the default. + const { podId } = podIds[0]; + + const presentationItemElements = presentations + .sort((a, b) => a.name.localeCompare(b.name)) + .map((p) => { + const customStyles = { color: colorPrimary }; + + return { + customStyles: p.current ? customStyles : null, + icon: 'file', + iconRight: p.current ? 'check' : null, + selected: p.current ? true : false, + label: p.name, + description: 'uploaded presentation file', + key: `uploaded-presentation-${p.id}`, + onClick: () => { + setPresentationFitToWidth(false); + setPresentation(p.id, podId); + }, + }; + }); + return presentationItemElements; + } + + setStreamingModalIsOpen(value) { + this.setState({ isStreamingModalOpen: value }); + } + + setExternalVideoModalIsOpen(value) { + this.setState({ isExternalVideoModalOpen: value }); + } + + setRandomUserSelectModalIsOpen(value) { + this.setState({ isRandomUserSelectModalOpen: value }); + } + + setLayoutModalIsOpen(value) { + this.setState({ isLayoutModalOpen: value }); + } + + setCameraAsContentModalIsOpen(value) { + this.setState({ isCameraAsContentModalOpen: value }); + } + + setPropsToPassModal(value) { + this.setState({ propsToPassModal: value }); + } + setForceOpen(value) { + this.setState({ forceOpen: value }); + } + + renderModal(isOpen, setIsOpen, priority, Component) { + return isOpen ? ( + setIsOpen(false), + priority, + setIsOpen, + isOpen, + }} + /> + ) : null; + } + + render() { + const { + intl, + amIPresenter, + shortcuts: OPEN_ACTIONS_AK, + isMeteorConnected, + isDropdownOpen, + isMobile, + isRTL, + isSelectRandomUserEnabled, + propsToPassModal, + } = this.props; + + const { + isExternalVideoModalOpen, + isRandomUserSelectModalOpen, + isLayoutModalOpen, + isCameraAsContentModalOpen, + isStreamingModalOpen, + } = this.state; + + const availableActions = this.getAvailableActions(); + const availablePresentations = this.makePresentationItems(); + const children = + availablePresentations.length > 1 && amIPresenter + ? availablePresentations.concat(availableActions) + : availableActions; + + const customStyles = { top: '-1rem' }; + + if (availableActions.length === 0 || !isMeteorConnected) { + return null; + } + + return ( + <> + null} + /> + } + actions={children} + opts={{ + id: 'actions-dropdown-menu', + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getcontentanchorel: null, + fullwidth: 'true', + anchorOrigin: { + vertical: 'top', + horizontal: isRTL ? 'right' : 'left', + }, + transformOrigin: { + vertical: 'bottom', + horizontal: isRTL ? 'right' : 'left', + }, + }} + /> + {this.renderModal(isStreamingModalOpen, this.setStreamingModalIsOpen, 'high', () => ( + { + this.setPropsToPassModal({}); + this.setForceOpen(false); + }, + priority: 'high', + setIsOpen: this.setStreamingModalIsOpen, + isOpen: isStreamingModalOpen, + }} + {...propsToPassModal} + /> + ))} + {this.renderModal( + isExternalVideoModalOpen, + this.setExternalVideoModalIsOpen, + 'low', + ExternalVideoModal + )} + {amIPresenter && isSelectRandomUserEnabled + ? this.renderModal( + isRandomUserSelectModalOpen, + this.setRandomUserSelectModalIsOpen, + 'low', + RandomUserSelectContainer + ) + : null} + {this.renderModal( + isLayoutModalOpen, + this.setLayoutModalIsOpen, + 'low', + LayoutModalContainer + )} + {this.renderModal( + isCameraAsContentModalOpen, + this.setCameraAsContentModalIsOpen, + 'low', + () => ( + { + this.setPropsToPassModal({}); + this.setForceOpen(false); + }, + priority: 'low', + setIsOpen: this.setCameraAsContentModalIsOpen, + isOpen: isCameraAsContentModalOpen, + }} + {...propsToPassModal} + /> + ) + )} + + ); + } +} + +ActionsDropdown.propTypes = propTypes; +ActionsDropdown.defaultProps = defaultProps; + +export default withShortcutHelper(ActionsDropdown, 'openActions'); diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx new file mode 100644 index 00000000..db0489c6 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Presentations from '/imports/api/presentations'; +import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service'; +import PresentationPodService from '/imports/ui/components/presentation-pod/service'; +import AppService from '/imports/ui/components/app/service'; +import ActionsDropdown from './component'; +import { layoutSelectInput, layoutDispatch, layoutSelect } from '../../layout/context'; +import { SMALL_VIEWPORT_BREAKPOINT } from '../../layout/enums'; +import { isCameraAsContentEnabled, isTimerFeatureEnabled } from '/imports/ui/services/features'; + +const ActionsDropdownContainer = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation); + const { width: browserWidth } = layoutSelectInput((i) => i.browser); + const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT; + const layoutContextDispatch = layoutDispatch(); + const isRTL = layoutSelect((i) => i.isRTL); + + return ( + + ); +}; + +export default withTracker(() => { + const presentations = Presentations.find({ 'conversion.done': true }).fetch(); + const { allowPresentationManagementInBreakouts } = Meteor.settings.public.app.breakouts; + const isPresentationManagementDisabled = AppService.meetingIsBreakout() + && !allowPresentationManagementInBreakouts; + + return { + presentations, + isTimerFeatureEnabled: isTimerFeatureEnabled(), + isDropdownOpen: Session.get('dropdownOpen'), + setPresentation: PresentationUploaderService.setPresentation, + podIds: PresentationPodService.getPresentationPodIds(), + isCameraAsContentEnabled: isCameraAsContentEnabled(), + isPresentationManagementDisabled, + }; +})(ActionsDropdownContainer); diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/component.jsx new file mode 100644 index 00000000..f6f6802e --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/component.jsx @@ -0,0 +1,175 @@ +import React, { Component } from 'react'; +import Settings from '/imports/ui/services/settings'; +import Styled from './styles'; +import { startStreaming } from './service'; +import CircularProgress from '@material-ui/core/CircularProgress' + + + +class StreamingModal extends Component { + constructor(props) { + super(props); + + this.state = { + streamUrl: '', + streamKey: '', + errorMsg: '', + isLoading: false, + }; + + this.renderUrlError = this.renderUrlError.bind(this); + this.handleChangeInput = this.handleChangeInput.bind(this); + this.startStreamingHandler = this.startStreamingHandler.bind(this); + } + + handleChangeInput(ev) { + const { name, value } = ev.target + + this.setState({ + ...this.state, + [name]: value, + errorMsg: "" + }) + + } + + async startStreamingHandler() { + const { + handleStreamingStatus, + setIsOpen, + callbackToClose + } = this.props; + + const { streamUrl, streamKey } = this.state; + + if (!streamUrl && !streamKey) { + this.setState({ + ...this.state, + errorMsg: "RTMP URL & Stream Key is required" + }) + } else if (!streamUrl) { + this.setState({ + ...this.state, + errorMsg: "RTMP URL is required" + }) + } else if (!/^rtmp(s)?:\/\//.test(streamUrl)) { + this.setState({ + ...this.state, + errorMsg: "Invalid RTMP URL" + }) + + } + else if (!streamKey) { + this.setState({ + ...this.state, + errorMsg: "Stream Key is required" + }) + } else { + try { + this.setState({ + ...this.state, + isLoading: true + }) + + const response = await startStreaming(this.state.streamUrl, this.state.streamKey) + if (response.status == 200) { + handleStreamingStatus(); + callbackToClose(); + setIsOpen(false); + } + + } catch (error) { + console.error(error) + + this.setState({ + ...this.state, + errorMsg: error, + isLoading: false + }) + + } + + } + + } + + renderUrlError() { + + const { animations } = Settings.application; + return ( + this.state.errorMsg + ? ( + + {this.state.errorMsg} + + ) + : null + ); + } + + render() { + const { setIsOpen, isOpen, onRequestClose, priority, } = this.props; + const { animations } = Settings.application; + + + + return ( + setIsOpen(false)} + contentLabel={"Streaming"} + title={"Start streaming the class"} + {...{ + isOpen, + priority, + }} + > + + + +
+ +
+ +
+ {this.renderUrlError()} +
+ { + this.state.isLoading ? : + } + +
+
+ ); + } +} + +export default StreamingModal; diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/container.jsx new file mode 100644 index 00000000..9d46d591 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/container.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import StreamingModal from './component'; +import { startWatching, getVideoUrl } from '/imports/ui/components/external-video-player/service'; + +const StreamingModalContainer = props => ; + +export default withTracker(({ setIsOpen, callbackToClose }) => ({ + closeModal: () => { + callbackToClose(); + setIsOpen(false); + }, +}))(StreamingModalContainer); diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/service.js b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/service.js new file mode 100644 index 00000000..33b7ff71 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/service.js @@ -0,0 +1,66 @@ + +import Auth from '/imports/ui/services/auth'; +import axios from 'axios'; + +const URL = window.location.origin + +const config = { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +} + +const startStreaming = async (streamUrl, streamKey) => { + try { + const data = { + meetingId: Auth._meetingID, + showPresentation: true, + hideUserListAndChat: false, + rtmpUrl: `${streamUrl}/${streamKey}` + }; + + + const response = await axios.post(`${URL}/bot/start`, data, config); + + return response; + } catch (error) { + if (error.response && error.response.status === 500) { + throw (error.response.data.error); + } else if (error.response && error.response.status === 502) { + throw ("Something went wrong, please contact your server administrator."); + } + else { + console.error('inside Error', error.response.status); + throw new Error('An error occurred'); + } + } +}; + +const stopStreaming = async () => { + try { + const data = { + meetingId: Auth._meetingID + }; + const response = await axios.post(`${URL}/bot/stop`, data, config) + return response; + } catch (error) { + if (error.response && error.response.status === 500) { + throw (error.response.data.error); + } else if (error.response && error.response.status === 502) { + throw ("Something went wrong, please contact your server administrator."); + } + else { + console.error('inside Error', error); + throw new Error('An error occurred'); + } + } + +} + + + +export { + startStreaming, + stopStreaming +}; diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/styles.js b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/styles.js new file mode 100644 index 00000000..23f8b861 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/streaming-modal/styles.js @@ -0,0 +1,116 @@ +import styled from 'styled-components'; +import { + borderSize, + borderRadius, + smPaddingY, + mdPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorText, + colorGrayLighter, + colorGray, + colorBlueLight, + colorPrimary, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography'; +import Modal from '/imports/ui/components/common/modal/simple/component'; +import Button from '/imports/ui/components/common/button/component'; + +const UrlError = styled.div` + color: red; + padding: 1em 0 2.5em 0; + + ${({ animations }) => animations && ` + transition: 1s; + `} +`; + +const StreamVideoModal = styled(Modal)` + padding: 1rem; + min-height: 25rem; +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + padding: 0; + margin-right: auto; + margin-left: auto; + width: 100%; +`; + +const InputBox = styled.div` + margin: 0 ${borderSize} 0 ${borderSize}; + + & > label { + display: block; + } + + & > label input { + display: block; + margin: 10px 0 10px 0; + padding: 0.4em; + color: ${colorText}; + line-height: 2rem; + width: 100%; + font-family: inherit; + font-weight: inherit; + border: 1px solid ${colorGrayLighter}; + border-radius: ${borderRadius}; + + ${({ animations }) => animations && ` + transition: box-shadow .2s; + `} + + &:focus { + outline: none; + border-radius: ${borderSize}; + box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + } + } + + & > span { + font-weight: 600; + } +`; + +const ExternalVideoNote = styled.div` + color: ${colorGray}; + font-size: ${fontSizeSmall}; + font-style: italic; + padding-top: ${smPaddingY}; +`; + +const StartButton = styled(Button)` + display: flex; + align-self: center; + + &:focus { + outline: none !important; + } + + & > i { + color: #3c5764; + } + + margin: 0; + display: block; + position: absolute; + bottom: ${mdPaddingX}; +`; + +const Loader = styled.div` + display: flex; + align-self: center; +`; + +export default { + UrlError, + StreamVideoModal, + Content, + InputBox, + ExternalVideoNote, + StartButton, + Loader +}; diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js new file mode 100644 index 00000000..ebd8ec77 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import Button from '/imports/ui/components/common/button/component'; + +const HideDropdownButton = styled(Button)` + ${({ open }) => open && ` + @media ${smallOnly} { + display:none; + } + `} +`; + +export default { + HideDropdownButton, +}; diff --git a/src/2.7.12/imports/ui/components/actions-bar/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/component.jsx new file mode 100644 index 00000000..55f98269 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/component.jsx @@ -0,0 +1,176 @@ +import React, { PureComponent } from 'react'; +import CaptionsButtonContainer from '/imports/ui/components/captions/button/container'; +import deviceInfo from '/imports/utils/deviceInfo'; +import Styled from './styles'; +import ActionsDropdown from './actions-dropdown/container'; +import AudioCaptionsButtonContainer from '/imports/ui/components/audio/captions/button/container'; +import CaptionsReaderMenuContainer from '/imports/ui/components/captions/reader-menu/container'; +import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container'; +import ReactionsButtonContainer from './reactions-button/container'; +import AudioControlsContainer from '../audio/audio-controls/container'; +import JoinVideoOptionsContainer from '../video-provider/video-button/container'; +import PresentationOptionsContainer from './presentation-options/component'; +import RaiseHandDropdownContainer from './raise-hand/container'; +import { isPresentationEnabled } from '/imports/ui/services/features'; + +class ActionsBar extends PureComponent { + constructor(props) { + super(props); + + this.state = { + isCaptionsReaderMenuModalOpen: false, + }; + + this.setCaptionsReaderMenuModalIsOpen = this.setCaptionsReaderMenuModalIsOpen.bind(this); + this.setRenderRaiseHand = this.renderRaiseHand.bind(this); + this.actionsBarRef = React.createRef(); + } + + setCaptionsReaderMenuModalIsOpen(value) { + this.setState({ isCaptionsReaderMenuModalOpen: value }) + } + + renderRaiseHand() { + const { + isReactionsButtonEnabled, isRaiseHandButtonEnabled, setEmojiStatus, currentUser, intl, + } = this.props; + + return (<> + {isReactionsButtonEnabled ? + <> + + + : + isRaiseHandButtonEnabled ? + : null} + ); + } + + render() { + const { + amIPresenter, + amIModerator, + enableVideo, + presentationIsOpen, + setPresentationIsOpen, + handleTakePresenter, + intl, + isSharingVideo, + isSharedNotesPinned, + hasScreenshare, + hasGenericContent, + hasCameraAsContent, + stopExternalVideoShare, + isTimerActive, + isTimerEnabled, + isCaptionsAvailable, + isMeteorConnected, + isPollingEnabled, + isSelectRandomUserEnabled, + isRaiseHandButtonCentered, + isThereCurrentPresentation, + allowExternalVideo, + layoutContextDispatch, + actionsBarStyle, + setMeetingLayout, + showPushLayout, + setPushLayout, + setPresentationFitToWidth, + } = this.props; + + const { isCaptionsReaderMenuModalOpen } = this.state; + + const shouldShowOptionsButton = (isPresentationEnabled() && isThereCurrentPresentation) + || isSharingVideo || hasScreenshare || isSharedNotesPinned; + return ( + + + + {isCaptionsAvailable + ? ( + <> + + { + isCaptionsReaderMenuModalOpen ? this.setCaptionsReaderMenuModalIsOpen(false), + priority: "low", + setIsOpen: this.setCaptionsReaderMenuModalIsOpen, + isOpen: isCaptionsReaderMenuModalOpen, + }} + /> : null + } + + ) + : null} + { !deviceInfo.isMobile + ? ( + + ) + : null } + + + + {enableVideo + ? ( + + ) + : null} + + {isRaiseHandButtonCentered && this.renderRaiseHand()} + + + { shouldShowOptionsButton ? + + : null + } + {!isRaiseHandButtonCentered && this.renderRaiseHand()} + + + ); + } +} + +export default ActionsBar; diff --git a/src/2.7.12/imports/ui/components/actions-bar/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/container.jsx new file mode 100644 index 00000000..2075d3c0 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/container.jsx @@ -0,0 +1,85 @@ +import React, { useContext } from 'react'; +import { Meteor } from 'meteor/meteor'; +import { withTracker } from 'meteor/react-meteor-data'; +import { injectIntl } from 'react-intl'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import Auth from '/imports/ui/services/auth'; +import PresentationService from '/imports/ui/components/presentation/service'; +import Presentations from '/imports/api/presentations'; +import { UsersContext } from '../components-data/users-context/context'; +import ActionsBar from './component'; +import Service from './service'; +import UserListService from '/imports/ui/components/user-list/service'; +import ExternalVideoService from '/imports/ui/components/external-video-player/service'; +import CaptionsService from '/imports/ui/components/captions/service'; +import TimerService from '/imports/ui/components/timer/service'; +import { layoutSelectOutput, layoutDispatch } from '../layout/context'; +import { isExternalVideoEnabled, isPollingEnabled, isPresentationEnabled } from '/imports/ui/services/features'; +import { isScreenBroadcasting, isCameraAsContentBroadcasting } from '/imports/ui/components/screenshare/service'; + +import MediaService from '../media/service'; + +const ActionsBarContainer = (props) => { + const actionsBarStyle = layoutSelectOutput((i) => i.actionBar); + const layoutContextDispatch = layoutDispatch(); + + const usingUsersContext = useContext(UsersContext); + const { users } = usingUsersContext; + + const currentUser = { userId: Auth.userID, emoji: users[Auth.meetingID][Auth.userID].emoji }; + + const amIPresenter = users[Auth.meetingID][Auth.userID].presenter; + + if (actionsBarStyle.display === false) return null; + + return ( + + ); +}; + +const SELECT_RANDOM_USER_ENABLED = Meteor.settings.public.selectRandomUser.enabled; +const RAISE_HAND_BUTTON_ENABLED = Meteor.settings.public.app.raiseHandActionButton.enabled; +const RAISE_HAND_BUTTON_CENTERED = Meteor.settings.public.app.raiseHandActionButton.centered; + +const isReactionsButtonEnabled = () => { + const USER_REACTIONS_ENABLED = Meteor.settings.public.userReaction.enabled; + const REACTIONS_BUTTON_ENABLED = Meteor.settings.public.app.reactionsButton.enabled; + + return USER_REACTIONS_ENABLED && REACTIONS_BUTTON_ENABLED; +}; + +export default withTracker(() => ({ + amIModerator: Service.amIModerator(), + stopExternalVideoShare: ExternalVideoService.stopWatching, + enableVideo: getFromUserSettings('bbb_enable_video', Meteor.settings.public.kurento.enableVideo), + setPresentationIsOpen: MediaService.setPresentationIsOpen, + handleTakePresenter: Service.takePresenterRole, + currentSlidHasContent: PresentationService.currentSlidHasContent(), + parseCurrentSlideContent: PresentationService.parseCurrentSlideContent, + isSharingVideo: Service.isSharingVideo(), + isSharedNotesPinned: Service.isSharedNotesPinned(), + hasScreenshare: isScreenBroadcasting(), + hasCameraAsContent: isCameraAsContentBroadcasting(), + isCaptionsAvailable: CaptionsService.isCaptionsAvailable(), + isTimerActive: TimerService.isActive(), + isTimerEnabled: TimerService.isEnabled(), + isMeteorConnected: Meteor.status().connected, + isPollingEnabled: isPollingEnabled() && isPresentationEnabled(), + isSelectRandomUserEnabled: SELECT_RANDOM_USER_ENABLED, + isRaiseHandButtonEnabled: RAISE_HAND_BUTTON_ENABLED, + isRaiseHandButtonCentered: RAISE_HAND_BUTTON_CENTERED, + isReactionsButtonEnabled: isReactionsButtonEnabled(), + isThereCurrentPresentation: Presentations.findOne({ meetingId: Auth.meetingID, current: true }, + { fields: {} }), + allowExternalVideo: isExternalVideoEnabled(), + setEmojiStatus: UserListService.setEmojiStatus, +}))(injectIntl(ActionsBarContainer)); diff --git a/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx new file mode 100644 index 00000000..1859fac1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx @@ -0,0 +1,1481 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { range } from '/imports/utils/array-utils'; +import deviceInfo from '/imports/utils/deviceInfo'; +import Button from '/imports/ui/components/common/button/component'; +import { Session } from 'meteor/session'; +import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component'; +import SortList from './sort-user-list/component'; +import Styled from './styles'; +import Icon from '/imports/ui/components/common/icon/component'; +import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features'; +import { addNewAlert } from '/imports/ui/components/screenreader-alert/service'; +import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service'; +import { uniqueId } from '/imports/utils/string-utils'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +const intlMessages = defineMessages({ + breakoutRoomTitle: { + id: 'app.createBreakoutRoom.title', + description: 'modal title', + }, + breakoutRoomDesc: { + id: 'app.createBreakoutRoom.modalDesc', + description: 'modal description', + }, + breakoutRoomUpdateDesc: { + id: 'app.updateBreakoutRoom.modalDesc', + description: 'update modal description', + }, + cancelLabel: { + id: 'app.updateBreakoutRoom.cancelLabel', + description: 'used in the button that close update modal', + }, + updateTitle: { + id: 'app.updateBreakoutRoom.title', + description: 'update breakout title', + }, + updateConfirm: { + id: 'app.updateBreakoutRoom.confirm', + description: 'Update to breakout confirm button label', + }, + resetUserRoom: { + id: 'app.update.resetRoom', + description: 'Reset user room button label', + }, + confirmButton: { + id: 'app.createBreakoutRoom.confirm', + description: 'confirm button label', + }, + dismissLabel: { + id: 'app.presentationUploder.dismissLabel', + description: 'used in the button that close modal', + }, + numberOfRooms: { + id: 'app.createBreakoutRoom.numberOfRooms', + description: 'number of rooms label', + }, + duration: { + id: 'app.createBreakoutRoom.durationInMinutes', + description: 'duration time label', + }, + resetAssignments: { + id: 'app.createBreakoutRoom.resetAssignments', + description: 'reset assignments label', + }, + resetAssignmentsDesc: { + id: 'app.createBreakoutRoom.resetAssignmentsDesc', + description: 'reset assignments label description', + }, + randomlyAssign: { + id: 'app.createBreakoutRoom.randomlyAssign', + description: 'randomly assign label', + }, + randomlyAssignDesc: { + id: 'app.createBreakoutRoom.randomlyAssignDesc', + description: 'randomly assign label description', + }, + breakoutRoom: { + id: 'app.createBreakoutRoom.room', + description: 'breakout room', + }, + freeJoinLabel: { + id: 'app.createBreakoutRoom.freeJoin', + description: 'free join label', + }, + captureNotesLabel: { + id: 'app.createBreakoutRoom.captureNotes', + description: 'capture shared notes label', + }, + captureSlidesLabel: { + id: 'app.createBreakoutRoom.captureSlides', + description: 'capture slides label', + }, + captureNotesType: { + id: 'app.notes.label', + description: 'indicates notes have been captured', + }, + captureSlidesType: { + id: 'app.shortcut-help.whiteboard', + description: 'indicates the whiteboard has been captured', + }, + roomLabel: { + id: 'app.createBreakoutRoom.room', + description: 'Room label', + }, + leastOneWarnBreakout: { + id: 'app.createBreakoutRoom.leastOneWarnBreakout', + description: 'warn message label', + }, + notAssigned: { + id: 'app.createBreakoutRoom.notAssigned', + description: 'Not assigned label', + }, + breakoutRoomLabel: { + id: 'app.createBreakoutRoom.breakoutRoomLabel', + description: 'breakout room label', + }, + addParticipantLabel: { + id: 'app.createBreakoutRoom.addParticipantLabel', + description: 'add Participant label', + }, + nextLabel: { + id: 'app.createBreakoutRoom.nextLabel', + description: 'Next label', + }, + backLabel: { + id: 'app.audio.backLabel', + description: 'Back label', + }, + minusRoomTime: { + id: 'app.createBreakoutRoom.minusRoomTime', + description: 'aria label for btn to decrease room time', + }, + addRoomTime: { + id: 'app.createBreakoutRoom.addRoomTime', + description: 'aria label for btn to increase room time', + }, + record: { + id: 'app.createBreakoutRoom.record', + description: 'label for checkbox to allow record', + }, + roomTime: { + id: 'app.createBreakoutRoom.roomTime', + description: 'used to provide current room time for aria label', + }, + numberOfRoomsIsValid: { + id: 'app.createBreakoutRoom.numberOfRoomsError', + description: 'Label an error message', + }, + roomNameEmptyIsValid: { + id: 'app.createBreakoutRoom.emptyRoomNameError', + description: 'Label an error message', + }, + roomNameDuplicatedIsValid: { + id: 'app.createBreakoutRoom.duplicatedRoomNameError', + description: 'Label an error message', + }, + you: { + id: 'app.userList.you', + description: 'Text for identifying your user', + }, + minimumDurationWarnBreakout: { + id: 'app.createBreakoutRoom.minimumDurationWarnBreakout', + description: 'minimum duration warning message label', + }, + roomNameInputDesc: { + id: 'app.createBreakoutRoom.roomNameInputDesc', + description: 'aria description for room name change', + }, + movedUserLabel: { + id: 'app.createBreakoutRoom.movedUserLabel', + description: 'screen reader alert when users are moved to rooms', + }, + manageRooms: { + id: 'app.createBreakoutRoom.manageRoomsLabel', + description: 'Label for manage rooms', + }, + sendInvitationToMods: { + id: 'app.createBreakoutRoom.sendInvitationToMods', + description: 'label for checkbox send invitation to moderators', + }, + currentSlide: { + id: 'app.createBreakoutRoom.currentSlideLabel', + description: 'label for current slide', + }, +}); + +const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit; +const MIN_BREAKOUT_ROOMS = 2; +const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS; +const MIN_BREAKOUT_TIME = 5; +const CURRENT_SLIDE_PREFIX = 'current-'; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + isMe: PropTypes.func.isRequired, + meetingName: PropTypes.string.isRequired, + users: PropTypes.arrayOf(PropTypes.object).isRequired, + createBreakoutRoom: PropTypes.func.isRequired, + getUsersNotJoined: PropTypes.func.isRequired, + getBreakouts: PropTypes.func.isRequired, + sendInvitation: PropTypes.func.isRequired, + isBreakoutRecordable: PropTypes.bool, +}; + +const setPresentationVisibility = (state) => { + const presentationInnerWrapper = document.getElementById('presentationInnerWrapper'); + if (presentationInnerWrapper) { + presentationInnerWrapper.style.display = state; + } +}; + +class BreakoutRoom extends PureComponent { + constructor(props) { + super(props); + this.changeNumberOfRooms = this.changeNumberOfRooms.bind(this); + this.changeDurationTime = this.changeDurationTime.bind(this); + this.changeUserRoom = this.changeUserRoom.bind(this); + this.increaseDurationTime = this.increaseDurationTime.bind(this); + this.decreaseDurationTime = this.decreaseDurationTime.bind(this); + this.onCreateBreakouts = this.onCreateBreakouts.bind(this); + this.setRoomUsers = this.setRoomUsers.bind(this); + this.setFreeJoin = this.setFreeJoin.bind(this); + this.getUserByRoom = this.getUserByRoom.bind(this); + this.onAssignRandomly = this.onAssignRandomly.bind(this); + this.onAssignReset = this.onAssignReset.bind(this); + this.onInviteBreakout = this.onInviteBreakout.bind(this); + this.renderUserItemByRoom = this.renderUserItemByRoom.bind(this); + this.renderRoomsGrid = this.renderRoomsGrid.bind(this); + this.renderBreakoutForm = this.renderBreakoutForm.bind(this); + this.renderCheckboxes = this.renderUnassingUsers.bind(this); + this.renderRoomSortList = this.renderRoomSortList.bind(this); + this.renderDesktop = this.renderDesktop.bind(this); + this.renderMobile = this.renderMobile.bind(this); + this.renderButtonSetLevel = this.renderButtonSetLevel.bind(this); + this.renderSelectUserScreen = this.renderSelectUserScreen.bind(this); + this.renderTitle = this.renderTitle.bind(this); + this.handleDismiss = this.handleDismiss.bind(this); + this.setInvitationConfig = this.setInvitationConfig.bind(this); + this.setRecord = this.setRecord.bind(this); + this.setCaptureNotes = this.setCaptureNotes.bind(this); + this.setInviteMods = this.setInviteMods.bind(this); + this.setCaptureSlides = this.setCaptureSlides.bind(this); + this.blurDurationTime = this.blurDurationTime.bind(this); + this.removeRoomUsers = this.removeRoomUsers.bind(this); + this.renderErrorMessages = this.renderErrorMessages.bind(this); + this.onUpdateBreakouts = this.onUpdateBreakouts.bind(this); + this.getRoomPresentation = this.getRoomPresentation.bind(this); + + this.state = { + numberOfRooms: MIN_BREAKOUT_ROOMS, + selectedId: '', + users: [], + durationTime: 15, + freeJoin: false, + formFillLevel: 1, + roomNamesChanged: [], + roomSelected: 0, + preventClosing: true, + leastOneUserIsValid: null, + numberOfRoomsIsValid: true, + roomNameDuplicatedIsValid: true, + roomNameEmptyIsValid: true, + record: false, + captureNotes: false, + inviteMods: false, + captureSlides: false, + durationIsValid: true, + breakoutJoinedUsers: null, + enableUnassingUsers: null, + roomPresentations: [], + }; + + this.btnLevelId = uniqueId('btn-set-level-'); + + this.handleMoveEvent = this.handleMoveEvent.bind(this); + this.handleShiftUser = this.handleShiftUser.bind(this); + } + + componentDidMount() { + const { + breakoutJoinedUsers, getLastBreakouts, groups, isUpdate, + allowUserChooseRoomByDefault, captureSharedNotesByDefault, + captureWhiteboardByDefault, inviteModsByDefault, + } = this.props; + setPresentationVisibility('none'); + this.setRoomUsers(); + if (isUpdate) { + const usersToMerge = [] + breakoutJoinedUsers.forEach((breakout) => { + breakout.joinedUsers.forEach((user) => { + usersToMerge.push({ + userId: user.userId, + userName: user.name, + from: breakout.sequence, + room: breakout.sequence, + isModerator: user.role === ROLE_MODERATOR, + joined: true, + }); + }); + }); + this.setState((prevState) => { + return { + users: [ + ...prevState.users, + ...usersToMerge, + ], + }; + }); + } + this.setState({ + freeJoin: allowUserChooseRoomByDefault, + captureSlides: captureWhiteboardByDefault, + captureNotes: captureSharedNotesByDefault, + inviteMods: inviteModsByDefault, + }); + + const lastBreakouts = getLastBreakouts(); + if (lastBreakouts.length > 0) { + this.populateWithLastBreakouts(lastBreakouts); + } else if (groups && groups.length > 0) { + this.populateWithGroups(groups); + } + } + + componentDidUpdate(prevProps, prevstate) { + if (this.listOfUsers) { + for (let i = 0; i < this.listOfUsers.children.length; i += 1) { + const roomWrapperChildren = this.listOfUsers.children[i].getElementsByTagName('div'); + const roomList = roomWrapperChildren[roomWrapperChildren.length > 1 ? 1 : 0]; + roomList.addEventListener('keydown', this.handleMoveEvent, true); + } + } + + const unassignedUsers = document.getElementById('breakoutBox-0'); + if (unassignedUsers) { + unassignedUsers.addEventListener('keydown', this.handleMoveEvent, true); + } + + const { numberOfRooms } = this.state; + const { users } = this.props; + const { users: prevUsers } = prevProps; + if (numberOfRooms < prevstate.numberOfRooms) { + this.resetUserWhenRoomsChange(numberOfRooms); + } + const usersCount = users.length; + const prevUsersCount = prevUsers.length; + if (usersCount > prevUsersCount) { + this.setRoomUsers(); + } + + if (usersCount < prevUsersCount) { + this.removeRoomUsers(); + } + } + + componentWillUnmount() { + if (this.listOfUsers) { + for (let i = 0; i < this.listOfUsers.children.length; i += 1) { + const roomList = this.listOfUsers.children[i].getElementsByTagName('div')[0]; + roomList.removeEventListener('keydown', this.handleMoveEvent, true); + } + } + const unassignedUsers = document.getElementById('breakoutBox-0'); + if (unassignedUsers) { + unassignedUsers.removeEventListener('keydown', this.handleMoveEvent, true); + } + } + + handleShiftUser(activeListSibling) { + const { users } = this.state; + if (activeListSibling) { + const text = activeListSibling.getElementsByTagName('input')[0].value; + const roomNumber = text.match(/\d/g).join(''); + users.forEach((u, index) => { + if (`roomUserItem-${u.userId}` === document.activeElement.id) { + users[index].room = text.substr(text.length - 1).includes(')') ? 0 : parseInt(roomNumber, 10); + this.changeUserRoom(u.userId, users[index].room); + } + }); + } else { + users.forEach((u, index) => { + if (`roomUserItem-${u.userId}` === document.activeElement.id) { + this.changeUserRoom(u.userId, 0); + } + }); + } + } + + handleMoveEvent(event) { + if (this.listOfUsers) { + const { activeElement } = document; + + if (event.key.includes('ArrowDown')) { + const { + nextElementSibling, id, childNodes, parentElement, + } = activeElement; + if (id.includes('breakoutBox')) return childNodes[0].focus(); + + if (id.includes('roomUserItem')) { + if (!nextElementSibling) { + return parentElement.firstElementChild.focus(); + } + return nextElementSibling.focus(); + } + } + + if (event.key.includes('ArrowUp')) { + const { + previousElementSibling, id, childNodes, parentElement, + } = activeElement; + if (id.includes('breakoutBox')) return childNodes[childNodes.length - 1].focus(); + + if (id.includes('roomUserItem')) { + if (!previousElementSibling) { + return parentElement.lastElementChild.focus(); + } + return previousElementSibling.focus(); + } + } + + if (event.key.includes('ArrowRight')) { + const { parentElement: listContainer } = activeElement; + if (listContainer.id.includes('breakoutBox')) { + this.handleShiftUser(listContainer.parentElement.nextSibling); + } + } + + if (event.key.includes('ArrowLeft')) { + const { parentElement: listContainer } = activeElement; + if (listContainer.id.includes('breakoutBox')) { + this.handleShiftUser(listContainer.parentElement.previousSibling); + } + } + + this.setRoomUsers(); + } + return true; + } + + handleDismiss() { + const { setIsOpen } = this.props; + setPresentationVisibility('block'); + return new Promise((resolve) => { + setIsOpen(false); + + this.setState({ + preventClosing: false, + }, resolve); + }); + } + + onCreateBreakouts() { + setPresentationVisibility('block'); + const { + createBreakoutRoom, + } = this.props; + const { + users, + freeJoin, + record, + captureNotes, + captureSlides, + numberOfRoomsIsValid, + numberOfRooms, + durationTime, + durationIsValid, + inviteMods, + } = this.state; + + if ((durationTime || 0) < MIN_BREAKOUT_TIME) { + this.setState({ durationIsValid: false }); + return; + } + + if (users.length === this.getUserByRoom(0).length && !freeJoin) { + this.setState({ leastOneUserIsValid: false, enableUnassingUsers: false }); + return; + } + + if (!numberOfRoomsIsValid || !durationIsValid) { + return; + } + + const duplicatedNames = range(1, numberOfRooms + 1).filter((n) => this.hasNameDuplicated(n)); + if (duplicatedNames.length > 0) { + this.setState({ roomNameDuplicatedIsValid: false }); + return; + } + + const emptyNames = range(1, numberOfRooms + 1) + .filter((n) => this.getRoomName(n).length === 0); + if (emptyNames.length > 0) { + this.setState({ roomNameEmptyIsValid: false }); + return; + } + + this.handleDismiss(); + + const rooms = range(1, numberOfRooms + 1).map((seq) => ({ + users: this.getUserByRoom(seq).map((u) => u.userId), + name: this.getFullName(seq), + captureNotesFilename: this.getCaptureFilename(seq, false), + captureSlidesFilename: this.getCaptureFilename(seq), + shortName: this.getRoomName(seq), + isDefaultName: !this.hasNameChanged(seq), + freeJoin, + sequence: seq, + allPages: !this.getRoomPresentation(seq).startsWith(CURRENT_SLIDE_PREFIX), + presId: this.getRoomPresentation(seq).replace(CURRENT_SLIDE_PREFIX, ''), + })); + + createBreakoutRoom(rooms, durationTime, record, captureNotes, captureSlides, inviteMods); + Session.set('isUserListOpen', true); + } + + onInviteBreakout() { + const { getBreakouts, sendInvitation } = this.props; + const { users, freeJoin } = this.state; + const breakouts = getBreakouts(); + if (users.length === this.getUserByRoom(0).length && !freeJoin) { + this.setState({ leastOneUserIsValid: false }); + return; + } + if (users.length === this.getUserByRoom(0).length) { + this.setState({ enableUnassingUsers: false }); + return; + } + + breakouts.forEach((breakout) => { + const { breakoutId } = breakout; + const breakoutUsers = this.getUserByRoom(breakout.sequence); + breakoutUsers.forEach((user) => sendInvitation(breakoutId, user.userId)); + }); + + this.handleDismiss(); + } + + getBreakoutBySequence(sequence) { + const { getBreakouts } = this.props; + const breakouts = getBreakouts(); + + return breakouts.find((breakout) => breakout.sequence === sequence); + } + + changeUserBreakout(fromBreakoutId, toBreakoutId, userId) { + const { moveUser } = this.props; + + moveUser(fromBreakoutId, toBreakoutId, userId); + } + + onUpdateBreakouts() { + setPresentationVisibility('block'); + const { users } = this.state; + const leastOneUserIsValid = users.some((user) => user.from !== user.room); + + if (!leastOneUserIsValid) { + this.setState({ leastOneUserIsValid }); + } + + users.forEach((user) => { + const { from, room } = user; + let { userId } = user; + + if (from === room) return; + + let toBreakoutId = ''; + if (room !== 0) { + const toBreakout = this.getBreakoutBySequence(room); + toBreakoutId = toBreakout.breakoutId; + } + + let fromBreakoutId = ''; + if (from !== 0) { + [userId] = userId.split('-'); + const fromBreakout = this.getBreakoutBySequence(from); + fromBreakoutId = fromBreakout.breakoutId; + } + + this.changeUserBreakout(fromBreakoutId, toBreakoutId, userId); + }); + + this.handleDismiss(); + } + + onAssignRandomly() { + const { numberOfRooms } = this.state; + this.setState({ hasUsersAssign: false }); + const { users } = this.state; + // We only want to assign viewers so filter out the moderators. We also want to get + // all users each run so that clicking the button again will reshuffle + const viewers = users.filter((user) => !user.isModerator); + // We want to keep assigning users until all viewers have been assigned a room + while (viewers.length > 0) { + // We cycle through the rooms picking one user for each room so that the rooms + // will have an equal number of people in each one + for (let i = 1; i <= numberOfRooms && viewers.length > 0; i += 1) { + // Select a random user for the room + const userIdx = Math.floor(Math.random() * (viewers.length)); + this.changeUserRoom(viewers[userIdx].userId, i); + // Remove the picked user so they aren't selected again + viewers.splice(userIdx, 1); + } + } + } + + onAssignReset() { + const { users } = this.state; + this.setState({ hasUsersAssign: true }); + + users.forEach((u) => { + if (u.room !== null && u.room > 0) { + this.changeUserRoom(u.userId, 0); + } + }); + } + + setInvitationConfig() { + const { getBreakouts } = this.props; + this.setState({ + numberOfRooms: getBreakouts().length, + formFillLevel: 2, + }); + } + + setRoomUsers() { + const { users, getUsersNotJoined } = this.props; + const { users: stateUsers } = this.state; + const stateUsersId = stateUsers.map((user) => user.userId); + const roomUsers = getUsersNotJoined(users) + .filter((user) => !stateUsersId.includes(user.userId)) + .map((user) => ({ + userId: user.userId, + extId: user.extId, + userName: user.name, + isModerator: user.role === ROLE_MODERATOR, + from: 0, + room: 0, + })); + + this.setState({ + users: [ + ...stateUsers, + ...roomUsers, + ], + }); + } + + setFreeJoin(e) { + this.setState({ + freeJoin: e.target.checked, + leastOneUserIsValid: true, + }); + } + + setRecord(e) { + this.setState({ record: e.target.checked }); + } + + setCaptureNotes(e) { + this.setState({ captureNotes: e.target.checked }); + } + + setInviteMods(e) { + this.setState({ inviteMods: e.target.checked }); + } + + setCaptureSlides(e) { + this.setState({ captureSlides: e.target.checked }); + } + + getUserByRoom(room) { + const { users } = this.state; + return users.filter((user) => user.room === room); + } + + getUsersByRoomSequence(sequence) { + const { breakoutJoinedUsers } = this.state; + if (!breakoutJoinedUsers) return []; + return breakoutJoinedUsers.filter((room) => room.sequence === sequence)[0].joinedUsers || []; + } + + getRoomName(position, padWithZeroes = false) { + const { intl } = this.props; + const { roomNamesChanged } = this.state; + + if (this.hasNameChanged(position)) { + return roomNamesChanged[position]; + } + + return intl.formatMessage(intlMessages.breakoutRoom, { + 0: padWithZeroes ? `${position}`.padStart(2, '0') : position + }); + } + + getRoomPresentation(position) { + const { roomPresentations } = this.state; + const { presentations } = this.props; + + const currentPresentation = presentations.find((presentation) => presentation.current); + + return roomPresentations[position] || `${CURRENT_SLIDE_PREFIX}${currentPresentation?.id}`; + } + + getFullName(position) { + const { meetingName } = this.props; + + return `${meetingName} (${this.getRoomName(position)})`; + } + + getCaptureFilename(position, slides = true) { + const { intl } = this.props; + const presentations = PresentationUploaderService.getPresentations(); + + const captureType = slides + ? intl.formatMessage(intlMessages.captureSlidesType) + : intl.formatMessage(intlMessages.captureNotesType); + + const fileName = `${this.getRoomName(position, true)}_${captureType}`.replace(/ /g, '_'); + + const fileNameDuplicatedCount = presentations.filter((pres) => pres.filename?.startsWith(fileName) + || pres.name?.startsWith(fileName)).length; + + return fileNameDuplicatedCount === 0 ? fileName : `${fileName}(${fileNameDuplicatedCount + 1})`; + } + + resetUserWhenRoomsChange(rooms) { + const { users } = this.state; + const filtredUsers = users.filter((u) => u.room > rooms); + filtredUsers.forEach((u) => this.changeUserRoom(u.userId, 0)); + } + + changeUserRoom(userId, room) { + const { intl } = this.props; + const { users, freeJoin } = this.state; + + const idxUser = users.findIndex((user) => user.userId === userId.replace('roomUserItem-', '')); + + const usersCopy = [...users]; + let userName = null; + + if (idxUser >= 0) { + usersCopy[idxUser].room = room; + userName = usersCopy[idxUser].userName; + }; + + this.setState({ + users: usersCopy, + leastOneUserIsValid: (this.getUserByRoom(0).length !== users.length || freeJoin), + enableUnassingUsers: (this.getUserByRoom(0).length !== users.length), + }, () => { + addNewAlert(intl.formatMessage(intlMessages.movedUserLabel, { 0: userName, 1: room })) + }); + } + + increaseDurationTime() { + const { durationTime } = this.state; + const number = ((1 * durationTime) + 1); + const newDurationTime = number > MIN_BREAKOUT_TIME ? number : MIN_BREAKOUT_TIME; + + this.setState({ durationTime: newDurationTime, durationIsValid: true }); + } + + decreaseDurationTime() { + const { durationTime } = this.state; + const number = ((1 * durationTime) - 1); + const newDurationTime = number > MIN_BREAKOUT_TIME ? number : MIN_BREAKOUT_TIME; + + this.setState({ durationTime: newDurationTime, durationIsValid: true }); + } + + changeDurationTime(event) { + const durationTime = Number.parseInt(event.target.value, 10) || ''; + const durationIsValid = durationTime >= MIN_BREAKOUT_TIME; + + this.setState({ durationTime, durationIsValid }); + } + + blurDurationTime(event) { + const value = Number.parseInt(event.target.value, 10); + this.setState({ durationTime: !(value <= 0) ? value : 1 }); + } + + changeNumberOfRooms(event) { + const numberOfRooms = Number.parseInt(event.target.value, 10); + this.setState({ + numberOfRooms, + numberOfRoomsIsValid: numberOfRooms <= MAX_BREAKOUT_ROOMS + && numberOfRooms >= MIN_BREAKOUT_ROOMS, + }); + } + + removeRoomUsers() { + const { users } = this.props; + const { users: stateUsers } = this.state; + const userIds = users.map((user) => user.userId); + const removeUsers = stateUsers.filter((user) => userIds.includes(user.userId)); + + this.setState({ + users: removeUsers, + }); + } + + hasNameChanged(position) { + const { intl } = this.props; + const { roomNamesChanged } = this.state; + + if (typeof roomNamesChanged[position] !== 'undefined' + && roomNamesChanged[position] !== intl + .formatMessage(intlMessages.breakoutRoom, { 0: position })) { + return true; + } + return false; + } + + hasNameDuplicated(position) { + const { numberOfRooms } = this.state; + const currName = this.getRoomName(position).trim(); + const equals = range(1, numberOfRooms + 1) + .filter((n) => this.getRoomName(n).trim() === currName); + if (equals.length > 1) return true; + + return false; + } + + populateWithLastBreakouts(lastBreakouts) { + const { getBreakoutUserWasIn, users, intl } = this.props; + + const changedNames = []; + lastBreakouts.forEach((breakout) => { + if (breakout.isDefaultName === false) { + changedNames[breakout.sequence] = breakout.shortName; + } + }); + + this.setState({ + roomNamesChanged: changedNames, + numberOfRooms: lastBreakouts.length, + roomNameDuplicatedIsValid: true, + roomNameEmptyIsValid: true, + }, () => { + const rooms = range(1, lastBreakouts.length + 1).map((seq) => this.getRoomName(seq)); + + users.forEach((u) => { + const lastUserBreakout = getBreakoutUserWasIn(u.userId, u.extId); + if (lastUserBreakout !== null) { + const lastUserBreakoutName = lastUserBreakout.isDefaultName === false + ? lastUserBreakout.shortName + : intl.formatMessage(intlMessages.breakoutRoom, { 0: lastUserBreakout.sequence }); + + if (rooms.indexOf(lastUserBreakoutName) !== false) { + this.changeUserRoom(u.userId, rooms.indexOf(lastUserBreakoutName) + 1); + } + } + }); + }); + } + + populateWithGroups(groups) { + const { users } = this.props; + + const changedNames = []; + groups.forEach((group, idx) => { + if (group.name.length > 0) { + changedNames[idx + 1] = group.name; + } + }); + + this.setState({ + roomNamesChanged: changedNames, + numberOfRooms: groups.length > 1 ? groups.length : 2, + roomNameDuplicatedIsValid: true, + roomNameEmptyIsValid: true, + }, () => { + groups.forEach((group, groupIdx) => { + const usersInGroup = group.usersExtId; + if (usersInGroup.length > 0) { + usersInGroup.forEach((groupUserExtId) => { + users.filter((u) => u.extId === groupUserExtId).forEach((foundUser) => { + this.changeUserRoom(foundUser.userId, groupIdx + 1); + }); + }); + } + }); + }); + } + + renderContent() { + const { intl } = this.props; + const { + leastOneUserIsValid, + allowDrop, + } = this.state; + const drop = (room) => (ev) => { + ev.preventDefault(); + const data = ev.dataTransfer.getData('text'); + this.changeUserRoom(data, room); + this.setState({ selectedId: '' }); + }; + + return ( + + + + + + + {this.renderUserItemByRoom(0)} + + + {intl.formatMessage(intlMessages.leastOneWarnBreakout)} + + + {this.renderRoomsGrid()} + + ); + } + + renderRoomsGrid() { + const { intl, isUpdate, presentations } = this.props; + const { + leastOneUserIsValid, + numberOfRooms, + roomNamesChanged, + roomPresentations, + } = this.state; + + const rooms = (numberOfRooms > MAX_BREAKOUT_ROOMS + || numberOfRooms < MIN_BREAKOUT_ROOMS) + ? 0 : numberOfRooms; + const allowDrop = (ev) => { + ev.preventDefault(); + }; + + const drop = (room) => (ev) => { + ev.preventDefault(); + const data = ev.dataTransfer.getData('text'); + this.changeUserRoom(data, room); + this.setState({ selectedId: '' }); + }; + + const changeRoomName = (position) => (ev) => { + const newRoomsNames = [...roomNamesChanged]; + newRoomsNames[position] = ev.target.value; + + this.setState({ + roomNamesChanged: newRoomsNames, + roomNameDuplicatedIsValid: true, + roomNameEmptyIsValid: true, + }); + }; + + const changeRoomPresentation = (position) => (ev) => { + const newRoomsPresentations = [...roomPresentations]; + newRoomsPresentations[position] = ev.target.value; + + this.setState({ + roomPresentations: newRoomsPresentations, + }); + }; + + const currentPresentation = presentations.find((presentation) => presentation.current); + + return ( + { this.listOfUsers = r; }} data-test="roomGrid"> + { + range(1, rooms + 1).map((value) => ( +
+ + +
+ {intl.formatMessage(intlMessages.roomNameInputDesc)} +
+
+ { presentations.length > 0 && !isUpdate ? ( + + + { currentPresentation?.id ? ( + + ) : null } + { + presentations.map((presentation) => ( + + )) + } + + + ) : null } + + {this.renderUserItemByRoom(value)} + + {this.hasNameDuplicated(value) ? ( + + {intl.formatMessage(intlMessages.roomNameDuplicatedIsValid)} + + ) : null} + {this.getRoomName(value).length === 0 ? ( + + {intl.formatMessage(intlMessages.roomNameEmptyIsValid)} + + ) : null} +
+ )) + } +
+ ); + } + + renderBreakoutForm() { + const { + intl, + isUpdate, + isBreakoutRecordable, + } = this.props; + const { + numberOfRooms, + durationTime, + numberOfRoomsIsValid, + durationIsValid, + freeJoin, + record, + captureNotes, + captureSlides, + inviteMods, + } = this.state; + if (isUpdate) return null; + + return ( + + +
+ + {intl.formatMessage(intlMessages.numberOfRooms)} + + + { + range(MIN_BREAKOUT_ROOMS, MAX_BREAKOUT_ROOMS + 1).map((item) => ()) + } + +
+ + + {intl.formatMessage(intlMessages.duration)} + + + + + + { + intl.formatMessage( + intlMessages.minimumDurationWarnBreakout, + { 0: MIN_BREAKOUT_TIME }, + ) + } + + + + + + {intl.formatMessage(intlMessages.freeJoinLabel)} + + { + isBreakoutRecordable ? ( + + + + {intl.formatMessage(intlMessages.record)} + + + ) : null + } + { + isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled() ? ( + + + + {intl.formatMessage(intlMessages.captureSlidesLabel)} + + + ) : null + } + { + isImportSharedNotesFromBreakoutRoomsEnabled() ? ( + + + + {intl.formatMessage(intlMessages.captureNotesLabel)} + + + ) : null + } + + + + {intl.formatMessage(intlMessages.sendInvitationToMods)} + + + +
+ + {intl.formatMessage(intlMessages.numberOfRoomsIsValid)} + + + {intl.formatMessage(intlMessages.randomlyAssignDesc)} + + +
+ ); + } + + renderSelectUserScreen() { + const { + users, + roomSelected, + } = this.state; + + return ( + this.setState({ formFillLevel: 2 })} + users={users} + room={roomSelected} + onCheck={this.changeUserRoom} + onUncheck={(userId) => this.changeUserRoom(userId, 0)} + /> + ); + } + + renderUnassingUsers() { + const { + intl, + isUpdate, + } = this.props; + if (isUpdate) return null; + const { + numberOfRoomsIsValid, + enableUnassingUsers, + } = this.state; + + return ( + + + {intl.formatMessage(intlMessages.manageRooms)} + + {enableUnassingUsers ? ( + + ) : ( + + )} + + ); + } + + renderUserItemByRoom(room) { + const { + leastOneUserIsValid, + selectedId, + } = this.state; + + const { intl, isMe } = this.props; + + const dragStart = (ev) => { + ev.dataTransfer.setData('text', ev.target.id); + this.setState({ selectedId: ev.target.id }); + + if (!leastOneUserIsValid) { + this.setState({ leastOneUserIsValid: true }); + } + }; + + const dragEnd = () => { + this.setState({ selectedId: '' }); + }; + + return this.getUserByRoom(room) + .map((user) => ( + + + {user.userName} + {(isMe(user.userId)) ? ` (${intl.formatMessage(intlMessages.you)})` : ''} + + {room !== user.from ? ( + this.changeUserRoom(user.userId, user.from)} + > + + + ) : null} + + )); + } + + renderRoomSortList() { + const { intl } = this.props; + const { numberOfRooms } = this.state; + const onClick = (roomNumber) => this.setState({ formFillLevel: 3, roomSelected: roomNumber }); + return ( + + + { + new Array(numberOfRooms).fill(1).map((room, idx) => ( + + + {intl.formatMessage(intlMessages.breakoutRoomLabel, { 0: idx + 1 })} + + onClick(idx + 1)} + /> + + )) + } + + {this.renderButtonSetLevel(1, intl.formatMessage(intlMessages.backLabel))} + + ); + } + + renderErrorMessages() { + const { + intl, + } = this.props; + const { + leastOneUserIsValid, + numberOfRoomsIsValid, + roomNameDuplicatedIsValid, + roomNameEmptyIsValid, + } = this.state; + return ( + <> + {!leastOneUserIsValid + && ( + + {intl.formatMessage(intlMessages.leastOneWarnBreakout)} + + )} + {!numberOfRoomsIsValid + && ( + + {intl.formatMessage(intlMessages.numberOfRoomsIsValid)} + + )} + {!roomNameDuplicatedIsValid + && ( + + {intl.formatMessage(intlMessages.roomNameDuplicatedIsValid)} + + )} + {!roomNameEmptyIsValid + && ( + + {intl.formatMessage(intlMessages.roomNameEmptyIsValid)} + + )} + + ); + } + + renderDesktop() { + return [ + this.renderBreakoutForm(), + this.renderUnassingUsers(), + this.renderContent(), + ]; + } + + renderMobile() { + const { intl } = this.props; + const { formFillLevel } = this.state; + if (formFillLevel === 2) { + return [ + this.renderErrorMessages(), + this.renderRoomSortList(), + ]; + } + + if (formFillLevel === 3) { + return [ + this.renderErrorMessages(), + this.renderSelectUserScreen(), + ]; + } + + return [ + this.renderErrorMessages(), + this.renderBreakoutForm(), + this.renderUnassingUsers(), + this.renderButtonSetLevel(2, intl.formatMessage(intlMessages.nextLabel)), + ]; + } + + renderButtonSetLevel(level, label) { + return ( +
+ + + + + {`${intl.formatMessage(intlMessages.chatLoggerLabel)}:`} + + + + + + + + + + + + + + ); + } +} + +export default injectIntl(DebugWindow); diff --git a/src/2.7.12/imports/ui/components/debug-window/styles.js b/src/2.7.12/imports/ui/components/debug-window/styles.js new file mode 100644 index 00000000..a1be2e7c --- /dev/null +++ b/src/2.7.12/imports/ui/components/debug-window/styles.js @@ -0,0 +1,132 @@ +import styled from 'styled-components'; +import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general'; +import Resizable from 're-resizable'; +import Icon from '/imports/ui/components/common/icon/component'; +import Button from '/imports/ui/components/common/button/component'; + +const ToggleLabel = styled.span` + margin-right: ${smPaddingX}; + + [dir="rtl"] & { + margin: 0 0 0 ${smPaddingX}; + } +`; + +const DebugWindowWrapper = styled(Resizable)` + position: absolute !important; + z-index: 9; +`; + +const DebugWindow = styled.div` + min-width: 20vw; + min-height: 20vh; + width: 100%; + height: 100%; + background-color: white; + border: 2px solid #06172A; + + &::after { + content: ""; + -webkit-transform: rotate(-45deg); + position: absolute; + right: 2px; + bottom: 8px; + pointer-events: none; + width: 14px; + height: 1px; + background: rgba(0,0,0,.5); + } + + &::before { + content: ""; + -webkit-transform: rotate(-45deg); + position: absolute; + right: 2px; + bottom: 5px; + pointer-events: none; + width: 8px; + height: 1px; + background: rgba(0,0,0,.5); + } +`; + +const Header = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid lightgray; + cursor: move; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +`; + +const MoveIcon = styled(Icon)` + margin: 5px; + color: rgba(0,0,0,.5); +`; + +const Title = styled.div` + font-size: 1.2rem; + font-weight: bold; +`; + +const CloseButton = styled(Button)` + & > span > i { + font-size: 115%; + } +`; + +const DebugWindowContent = styled.div` + padding: 10px; +`; + +const Table = styled.div` + display: table; + width: 100%; +`; + +const TableRow = styled.div` + display: table-row; +`; + +const TableCell = styled.div` + display: table-cell; + padding: 5px; + vertical-align: middle; +`; + +const UserAgentInput = styled.input` + margin-right: 5px; +`; + +const AutoArrangeToggle = styled.input` + margin-right: 5px; +`; + +const CellContent = styled.div` + display: flex; + align-items: center; +`; + +export default { + ToggleLabel, + DebugWindowWrapper, + DebugWindow, + Header, + MoveIcon, + Title, + CloseButton, + DebugWindowContent, + Table, + TableRow, + TableCell, + UserAgentInput, + AutoArrangeToggle, + CellContent, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/component.jsx b/src/2.7.12/imports/ui/components/dropdown/component.jsx new file mode 100644 index 00000000..3c4ffa93 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/component.jsx @@ -0,0 +1,330 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { findDOMNode } from 'react-dom'; +import TetherComponent from 'react-tether'; +import { defineMessages, injectIntl } from 'react-intl'; +import deviceInfo from '/imports/utils/deviceInfo'; +import screenreaderTrap from 'makeup-screenreader-trap'; +import { Session } from 'meteor/session'; +import Styled from './styles'; + +import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component'; +import DropdownContent from '/imports/ui/components/dropdown/content/component'; +import DropdownList from '/imports/ui/components/dropdown/list/component'; +import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component'; +import DropdownListItem from '/imports/ui/components/dropdown/list/item/component'; +import DropdownListTitle from '/imports/ui/components/dropdown/list/title/component'; + +const intlMessages = defineMessages({ + close: { + id: 'app.dropdown.close', + description: 'Close button label', + }, +}); + +const noop = () => { }; + +const propTypes = { + /** + * The dropdown needs a trigger and a content component as children + */ + children: (props, propName, componentName) => { + const children = props[propName]; + + if (!children || children.length < 2) { + return new Error(`Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Validation failed.`); + } + + const trigger = children.find((x) => x.type === DropdownTrigger); + const content = children.find((x) => x.type === DropdownContent); + + if (!trigger) { + return new Error(`Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Missing \`DropdownTrigger\`. Validation failed.`); + } + + if (!content) { + return new Error(`Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Missing \`DropdownContent\`. Validation failed.`); + } + + return null; + }, + isOpen: PropTypes.bool, + keepOpen: PropTypes.bool, + onHide: PropTypes.func, + onShow: PropTypes.func, + autoFocus: PropTypes.bool, + tethered: PropTypes.bool, + getContent: PropTypes.func, +}; + +const defaultProps = { + tethered: false, + children: null, + onShow: noop, + onHide: noop, + autoFocus: false, + isOpen: false, + keepOpen: null, + getContent: () => {}, +}; + +const attachments = { + 'right-bottom': 'bottom left', + 'right-top': 'bottom left', +}; +const targetAttachments = { + 'right-bottom': 'bottom right', + 'right-top': 'top right', +}; + +class Dropdown extends Component { + constructor(props) { + super(props); + this.state = { + isOpen: false, + isPortrait: deviceInfo.isPortrait(), + }; + this.handleShow = this.handleShow.bind(this); + this.handleHide = this.handleHide.bind(this); + this.handleToggle = this.handleToggle.bind(this); + this.handleWindowClick = this.handleWindowClick.bind(this); + this.updateOrientation = this.updateOrientation.bind(this); + } + + componentDidMount() { + window.addEventListener('resize', this.updateOrientation); + } + + componentDidUpdate(prevProps, prevState) { + const { + onShow, + onHide, + keepOpen, + tethered, + } = this.props; + + const { isOpen } = this.state; + + const enableSRTrap = isOpen && !tethered; + + if (enableSRTrap) { + screenreaderTrap.trap(this.dropdown); + } else { + screenreaderTrap.untrap(); + } + + if (isOpen && !prevState.isOpen) { onShow(); } + + if (!isOpen && prevState.isOpen) { onHide(); } + + if (prevProps.keepOpen && !keepOpen) onHide(); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.updateOrientation); + } + + handleHide() { + Session.set('dropdownOpen', false); + const { onHide } = this.props; + this.setState({ isOpen: false }, () => { + const { removeEventListener } = window; + onHide(); + removeEventListener('click', this.handleWindowClick, true); + }); + } + + handleShow() { + Session.set('dropdownOpen', true); + const { + onShow, + } = this.props; + this.setState({ isOpen: true }, () => { + const { addEventListener } = window; + onShow(); + addEventListener('click', this.handleWindowClick, true); + }); + } + + handleWindowClick(event) { + const { keepOpen, onHide } = this.props; + const { isOpen } = this.state; + const triggerElement = findDOMNode(this.trigger); + const contentElement = findDOMNode(this.content); + if (!(triggerElement && contentElement)) return; + if (triggerElement && triggerElement.contains(event.target)) { + if (keepOpen) { + onHide(); + return; + } + if (isOpen) { + this.handleHide(); + return; + } + } + + if (keepOpen && isOpen && !contentElement.contains(event.target)) { + if (triggerElement) { + const { parentElement } = triggerElement; + if (parentElement) parentElement.focus(); + } + onHide(); + this.handleHide(); + return; + } + + if (keepOpen && triggerElement) { + const { parentElement } = triggerElement; + if (parentElement) parentElement.focus(); + } + + if (keepOpen === true) return; + this.handleHide(); + } + + handleToggle() { + const { isOpen } = this.state; + return isOpen ? this.handleHide() : this.handleShow(); + } + + updateOrientation() { + this.setState({ isPortrait: deviceInfo.isPortrait() }); + } + + render() { + const { + children, + intl, + keepOpen, + tethered, + placement, + getContent, + ...otherProps + } = this.props; + const { isOpen, isPortrait } = this.state; + const { isPhone } = deviceInfo; + const placements = placement && placement.replace(' ', '-'); + const test = isPhone && isPortrait ? { + width: '100%', + height: '100%', + transform: 'translateY(0)', + } : { + width: '', + height: '', + transform: '', + }; + + let trigger = children.find((x) => x.type === DropdownTrigger); + let content = children.find((x) => x.type === DropdownContent); + + trigger = React.cloneElement(trigger, { + ref: (ref) => { this.trigger = ref; }, + dropdownIsOpen: isOpen, + dropdownToggle: this.handleToggle, + dropdownShow: this.handleShow, + dropdownHide: this.handleHide, + keepopen: `${keepOpen}`, + }); + + content = React.cloneElement(content, { + ref: (ref) => { + getContent(ref); + this.content = ref; + }, + 'aria-expanded': isOpen, + dropdownIsOpen: isOpen, + dropdownToggle: this.handleToggle, + dropdownShow: this.handleShow, + dropdownHide: this.handleHide, + keepopen: `${keepOpen}`, + }); + + const showCloseBtn = (isOpen && keepOpen) || (isOpen && keepOpen === null); + return ( + { this.dropdown = node; }} + tabIndex={-1} + > + { + tethered + ? ( + ( + + {trigger} + + )} + renderElement={(ref) => ( +
+ {content} + {showCloseBtn + ? ( + + ) : null} +
+ )} + /> + ) + : ( + // Fix eslint rule https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-fragments.md + <> + {trigger} + {content} + {showCloseBtn + ? ( + + ) : null} + + ) + } +
+ ); + } +} + +Dropdown.propTypes = propTypes; +Dropdown.defaultProps = defaultProps; + +Dropdown.DropdownTrigger = DropdownTrigger; +Dropdown.DropdownContent = DropdownContent; +Dropdown.DropdownList = DropdownList; +Dropdown.DropdownListSeparator = DropdownListSeparator; +Dropdown.DropdownListItem = DropdownListItem; +Dropdown.DropdownListTitle = DropdownListTitle; +export default injectIntl(Dropdown, { forwardRef: true }); diff --git a/src/2.7.12/imports/ui/components/dropdown/content/component.jsx b/src/2.7.12/imports/ui/components/dropdown/content/component.jsx new file mode 100644 index 00000000..32d3225b --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/content/component.jsx @@ -0,0 +1,41 @@ +import React, { Component, Children, cloneElement } from 'react'; +import Styled from './styles'; + +const defaultProps = { + 'aria-expanded': false, +}; + +export default class DropdownContent extends Component { + render() { + const { + children, + dropdownToggle, + dropdownShow, + dropdownHide, + dropdownIsOpen, + keepOpen, + ...restProps + } = this.props; + + const boundChildren = Children.map(children, child => cloneElement(child, { + dropdownIsOpen, + dropdownToggle, + dropdownShow, + dropdownHide, + keepOpen, + })); + + return ( + + + {boundChildren} + + + ); + } +} + +DropdownContent.defaultProps = defaultProps; diff --git a/src/2.7.12/imports/ui/components/dropdown/content/styles.js b/src/2.7.12/imports/ui/components/dropdown/content/styles.js new file mode 100644 index 00000000..ca962f40 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/content/styles.js @@ -0,0 +1,154 @@ +import styled from 'styled-components'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import { + borderSize, + borderRadius, + dropdownCaretHeight, + dropdownCaretWidth, +} from '/imports/ui/stylesheets/styled-components/general'; +import { colorWhite, colorGray } from '/imports/ui/stylesheets/styled-components/palette'; +import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography'; + +const Content = styled.div` + outline: transparent; + outline-width: ${borderSize}; + outline-style: solid; + z-index: 9999; + position: absolute; + background: ${colorWhite}; + border-radius: ${borderRadius}; + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + border: 0; + padding: calc(${lineHeightComputed} / 2); + + [dir="rtl"] & { + right: 10.75rem; + } + + &:after, + &:before { + content: ''; + position: absolute; + width: 0; + height: 0; + } + + &[aria-expanded="false"] { + display: none; + } + + &[aria-expanded="true"] { + display: block; + } + + @media ${smallOnly} { + z-index: 1015; + border-radius: 0; + background-color: #fff; + box-shadow: none; + position: fixed; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + border: 0 !important; + padding: 0 !important; + margin: 0 0 calc(${lineHeightComputed} * 5.25) 0 !important; + transform: translateX(0) translateY(0) !important; + + &:after, + &:before { + display: none !important; + } + } + + //top-left + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: calc(${dropdownCaretHeight} * 1.25); + + &:before, + &:after { + border-left: ${dropdownCaretWidth} solid transparent; + border-right: ${dropdownCaretWidth} solid transparent; + border-top: ${dropdownCaretHeight} solid ${colorWhite}; + bottom: 0; + margin-bottom: calc(${dropdownCaretHeight} * -1); + } + + &:before { + border-top: ${dropdownCaretHeight} solid ${colorGray}; + } + + transform: translateX(100%); + right: 100%; + left: auto; + + &:after, + &:before { + left: ${dropdownCaretWidth}; + } + + min-width: 18rem; + + @media ${smallOnly} { + width: auto; + } + + [dir="rtl"] { + transform: translateX(25%); + } +`; + +const Scrollable = styled.div` + @media ${smallOnly} { + overflow-y: auto; + background: linear-gradient(white 30%, rgba(255,255,255,0)), + linear-gradient(rgba(255,255,255,0), white 70%) 0 100%, + /* Shadows */ + radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; + + background-repeat: no-repeat; + background-color: transparent; + background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; + background-attachment: local, local, scroll, scroll; + + // Fancy scroll + &::-webkit-scrollbar { + width: 5px; + height: 5px; + } + &::-webkit-scrollbar-button { + width: 0; + height: 0; + } + &::-webkit-scrollbar-thumb { + background: rgba(0,0,0,.25); + border: none; + border-radius: 50px; + } + &::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.5); } + &::-webkit-scrollbar-thumb:active { background: rgba(0,0,0,.25); } + &::-webkit-scrollbar-track { + background: rgba(0,0,0,.25); + border: none; + border-radius: 50px; + } + &::-webkit-scrollbar-track:hover { background: rgba(0,0,0,.25); } + &::-webkit-scrollbar-track:active { background: rgba(0,0,0,.25); } + &::-webkit-scrollbar-corner { background: 0 0; } + + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +`; + +export default { + Content, + Scrollable, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/component.jsx new file mode 100644 index 00000000..1497b410 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/component.jsx @@ -0,0 +1,206 @@ +import React, { Component, Children, cloneElement } from 'react'; +import PropTypes from 'prop-types'; +import KEY_CODES from '/imports/utils/keyCodes'; +import Styled from './styles'; +import ListItem from './item/component'; +import ListSeparator from './separator/component'; +import ListTitle from './title/component'; + +const propTypes = { + /* We should recheck this proptype, sometimes we need to create an container and send to + dropdown, but with this proptype, is not possible. */ + children: PropTypes.arrayOf((propValue, key, componentName, propFullName) => { + if (propValue[key].type !== ListItem + && propValue[key].type !== ListSeparator + && propValue[key].type !== ListTitle) { + return new Error(`Invalid prop \`${propFullName}\` supplied to` + + ` \`${componentName}\`. Validation failed.`); + } + return true; + }).isRequired, + + horizontal: PropTypes.bool, +}; + +const defaultProps = { + horizontal: false, +}; + +export default class DropdownList extends Component { + constructor(props) { + super(props); + this.state = { + focusedIndex: false, + }; + + this.childrenRefs = []; + this.menuRefs = []; + this.handleItemKeyDown = this.handleItemKeyDown.bind(this); + this.handleItemClick = this.handleItemClick.bind(this); + } + + componentDidMount() { + this._menu.addEventListener('keydown', (event) => this.handleItemKeyDown(event)); + } + + componentDidUpdate() { + const { focusedIndex } = this.state; + const children = [].slice.call(this._menu.children); + this.menuRefs = children.filter((child) => child.getAttribute('role') === 'menuitem'); + + const activeRef = this.menuRefs[focusedIndex]; + + if (activeRef) { + activeRef.focus(); + } + } + + handleItemKeyDown(event, callback) { + const { + getDropdownMenuParent, + horizontal, + } = this.props; + const { focusedIndex } = this.state; + + let nextFocusedIndex = focusedIndex > 0 ? focusedIndex : 0; + + if (focusedIndex === false) { + nextFocusedIndex = this.menuRefs.indexOf(document.activeElement); + } + + const isHorizontal = horizontal; + const navigationKeys = { + previous: KEY_CODES[`ARROW_${isHorizontal ? 'LEFT' : 'UP'}`], + next: KEY_CODES[`ARROW_${isHorizontal ? 'RIGHT' : 'DOWN'}`], + click: isHorizontal ? [KEY_CODES.ENTER] : [KEY_CODES.ENTER, KEY_CODES.ARROW_RIGHT], + close: [KEY_CODES.ESCAPE, + KEY_CODES.TAB, + KEY_CODES[`ARROW_${isHorizontal ? 'DOWN' : 'LEFT'}`]], + }; + + if (navigationKeys.previous === event.which) { + event.stopPropagation(); + + nextFocusedIndex -= 1; + + if (nextFocusedIndex < 0) { + nextFocusedIndex = this.menuRefs.length - 1; + } else if (nextFocusedIndex > this.menuRefs.length - 1) { + nextFocusedIndex = 0; + } + } + + if ([navigationKeys.next].includes(event.keyCode)) { + event.stopPropagation(); + + nextFocusedIndex += 1; + + if (nextFocusedIndex > this.menuRefs.length - 1) { + nextFocusedIndex = 0; + } + } + + if (navigationKeys.click.includes(event.keyCode)) { + nextFocusedIndex = false; + event.stopPropagation(); + document.activeElement.firstChild.click(); + } + + if (navigationKeys.close.includes(event.keyCode)) { + nextFocusedIndex = false; + const { dropdownHide } = this.props; + + event.stopPropagation(); + event.preventDefault(); + + dropdownHide(); + if (getDropdownMenuParent) { + getDropdownMenuParent().focus(); + } + } + + this.setState({ focusedIndex: nextFocusedIndex }); + + if (typeof callback === 'function') { + callback(event); + } + } + + handleItemClick(event, callback) { + const { + getDropdownMenuParent, + onActionsHide, + dropdownHide, + keepOpen, + } = this.props; + + if (!keepOpen) { + if (getDropdownMenuParent) { + onActionsHide(); + } else { + this.setState({ focusedIndex: null }); + dropdownHide(); + } + } + + if (typeof callback === 'function') { + callback(event); + } + } + + render() { + const { + children, + style, + horizontal, + } = this.props; + + const boundChildren = Children.map( + children, + (item) => { + if (item.type === ListSeparator) { + return item; + } + + return cloneElement(item, { + tabIndex: 0, + injectRef: (ref) => { + if (ref && !this.childrenRefs.includes(ref)) { this.childrenRefs.push(ref); } + }, + + onClick: (event) => { + let { onClick } = item.props; + onClick = onClick ? onClick.bind(item) : null; + this.handleItemClick(event, onClick); + }, + + onKeyDown: (event) => { + let { onKeyDown } = item.props; + onKeyDown = onKeyDown ? onKeyDown.bind(item) : null; + + this.handleItemKeyDown(event, onKeyDown); + }, + }); + }, + ); + + const listDirection = horizontal ? 'horizontal' : 'vertical'; + + return ( + { + this._menu = menu; + return menu; + }} + > + {boundChildren} + + ); + } +} + +DropdownList.propTypes = propTypes; +DropdownList.defaultProps = defaultProps; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx new file mode 100644 index 00000000..92446dbf --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx @@ -0,0 +1,103 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Styled from './styles'; +import { uniqueId } from '/imports/utils/string-utils'; + +const propTypes = { + icon: PropTypes.string, + label: PropTypes.string, + description: PropTypes.string, + accessKey: PropTypes.string, + tabIndex: PropTypes.number, + disabled: PropTypes.bool, +}; + +const defaultProps = { + icon: '', + label: '', + description: '', + tabIndex: 0, + accessKey: null, + disabled: false, +}; + +const messages = defineMessages({ + activeAriaLabel: { + id: 'app.dropdown.list.item.activeLabel', + }, +}); + +class DropdownListItem extends Component { + constructor(props) { + super(props); + this.labelID = uniqueId('dropdown-item-label-'); + this.descID = uniqueId('dropdown-item-desc-'); + } + + renderDefault() { + const { + icon, label, iconRight, accessKey, + } = this.props; + + return [ + (icon ? : null), + ( + + {label} + + ), + (iconRight ? : null), + ]; + } + + render() { + const { + id, + label, + description, + children, + injectRef, + tabIndex, + onClick, + onKeyDown, + className, + style, + intl, + disabled, + 'data-test': dataTest, + } = this.props; + + const isSelected = className && className.includes('emojiSelected'); + const _label = isSelected ? `${label} (${intl.formatMessage(messages.activeAriaLabel)})` : label; + return ( + {} : onClick} + onKeyDown={disabled ? () => {} : onKeyDown} + tabIndex={tabIndex} + aria-labelledby={this.labelID} + aria-describedby={this.descID} + style={style} + role="menuitem" + data-test={dataTest} + > + { + children || this.renderDefault() + } + { + label + ? () + : null + } + + + ); + } +} + +export default injectIntl(DropdownListItem); + +DropdownListItem.propTypes = propTypes; +DropdownListItem.defaultProps = defaultProps; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/item/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/item/styles.js new file mode 100644 index 00000000..f5b483bb --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/item/styles.js @@ -0,0 +1,99 @@ +import styled from 'styled-components'; +import Icon from '/imports/ui/components/common/icon/component'; +import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography'; +import { + colorGrayDark, + colorPrimary, + colorWhite, + colorText, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { borderSize } from '/imports/ui/stylesheets/styled-components/general'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; + +const ItemIcon = styled(Icon)` + margin: 0 calc(${lineHeightComputed} / 2) 0 0; + color: ${colorText}; + flex: 0 0; + + [dir="rtl"] & { + margin: 0 0 0 calc(${lineHeightComputed} / 2); + } +`; + +const ItemLabel = styled.span` + color: ${colorGrayDark}; + font-size: 90%; + flex: 1; +`; + +const IconRight = styled(ItemIcon)` + margin-right: 0; + margin-left: 1rem; + font-size: 12px; + line-height: 16px; + + [dir="rtl"] & { + margin-left: 0; + margin-right: 1rem; + -webkit-transform: scale(-1, 1); + -moz-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + transform: scale(-1, 1); + } +`; + +const Item = styled.li` + display: flex; + flex: 1 1 100%; + align-items: center; + padding: calc(${lineHeightComputed} / 3) 0; + + &:hover, + &:focus { + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + + cursor: pointer; + background-color: ${colorPrimary}; + color: ${colorWhite}; + + & > span { + color: ${colorWhite} !important; + } + + margin-left: -.25rem; + margin-right: -.25rem; + padding-left: .25rem; + padding-right: .25rem; + + [dir="rtl"] & { + margin-right: -.25rem; + margin-left: -.25rem; + padding-right: .25rem; + padding-left: .25rem; + } + + + @media ${smallOnly} { + border-radius: 0.2rem; + } + + & i { + color: inherit; + } + } + + &:focus { + box-shadow: 0 0 0 2px ${colorWhite}, 0 0 2px 4px rgba(${colorPrimary}, .4); + outline-style: solid; + } +`; + +export default { + ItemIcon, + ItemLabel, + IconRight, + Item, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx new file mode 100644 index 00000000..31957b03 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Styled from './styles'; + +const DropdownListSeparator = ({ style }) => ( + +); + +DropdownListSeparator.propTypes = { + style: PropTypes.shape({}), +}; + +DropdownListSeparator.defaultProps = { + style: null, +}; + +export default DropdownListSeparator; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js new file mode 100644 index 00000000..26279a0d --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js @@ -0,0 +1,18 @@ +import styled from 'styled-components'; +import { colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette'; +import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography'; + +const Separator = styled.li` + display: flex; + flex: 1 1 100%; + height: 1px; + min-height: 1px; + background-color: ${colorGrayLighter}; + padding: 0; + margin-top: calc(${lineHeightComputed} * .5); + margin-bottom: calc(${lineHeightComputed} * .5); +`; + +export default { + Separator, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/styles.js new file mode 100644 index 00000000..e7a5f63f --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/styles.js @@ -0,0 +1,47 @@ +import styled from 'styled-components'; +import { fontSizeBase, fontSizeLarge, lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography'; +import { colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette'; + +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; + +const List = styled.ul` + list-style: none; + font-size: ${fontSizeBase}; + margin: 0; + padding: 0; + text-align: left; + color: ${colorGrayDark}; + display: flex; + overflow-wrap: break-word; + white-space: pre-line; + + [dir="rtl"] & { + text-align: right; + } + + @media ${smallOnly} { + font-size: calc(${fontSizeLarge} * 1.1); + padding: ${lineHeightComputed}; + } + + ${({ direction }) => direction === 'horizontal' && ` + padding: 0; + flex-direction: row; + + @media ${smallOnly} { + flex-direction: column; + padding: calc(${lineHeightComputed} / 1.5) 0; + } + + padding: 0 calc(${lineHeightComputed} / 3); + `} + + ${({ direction }) => direction === 'vertical' && ` + flex-direction: column; + width: 100%; + `} +`; + +export default { + List, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx new file mode 100644 index 00000000..7b3947a0 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx @@ -0,0 +1,22 @@ +import React, { Component } from 'react'; +import Styled from './styles'; +import { uniqueId } from '/imports/utils/string-utils'; + +export default class DropdownListTitle extends Component { + constructor(props) { + super(props); + this.labelID = uniqueId('labelContext-'); + } + + render() { + const { + children, + } = this.props; + + return ( + + {children} + + ); + } +} diff --git a/src/2.7.12/imports/ui/components/dropdown/list/title/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/title/styles.js new file mode 100644 index 00000000..a0227152 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/list/title/styles.js @@ -0,0 +1,12 @@ +import styled from 'styled-components'; +import { colorGray } from '/imports/ui/stylesheets/styled-components/palette'; + +const Title = styled.li` + color: ${colorGray}; + font-weight: 600; + width: 100%; +`; + +export default { + Title, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/styles.js b/src/2.7.12/imports/ui/components/dropdown/styles.js new file mode 100644 index 00000000..b7fcdd77 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/styles.js @@ -0,0 +1,37 @@ +import styled from 'styled-components'; +import Button from '/imports/ui/components/common/button/component'; +import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography'; +import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography'; +import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; + +const Dropdown = styled.div` + position: relative; + z-index: 3; + + &:focus { + outline: none; + } +`; + +const CloseButton = styled(Button)` + display: none; + position: fixed; + bottom: 0.8rem; + border-radius: 0; + z-index: 1011; + font-size: calc(${fontSizeLarge} * 1.1); + width: calc(100% - (${lineHeightComputed} * 2)); + left: ${lineHeightComputed}; + box-shadow: 0 0 0 2rem ${colorWhite} !important; + border: ${colorWhite} !important; + + @media ${smallOnly} { + display: block; + } +`; + +export default { + Dropdown, + CloseButton, +}; diff --git a/src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx b/src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx new file mode 100644 index 00000000..904e1437 --- /dev/null +++ b/src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx @@ -0,0 +1,101 @@ +import React, { Component } from 'react'; +import Button from '/imports/ui/components/common/button/component'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +import KEY_CODES from '/imports/utils/keyCodes'; + +const propTypes = { + children: PropTypes.element.isRequired, +}; + +export default class DropdownTrigger extends Component { + static isButtonTriggerOnEmoji(buttonComponent) { + return ( + buttonComponent + && (buttonComponent.type === Button) + && (buttonComponent.props) + && (buttonComponent.props.children) + ); + } + + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.trigger = null; + } + + handleClick(e) { + e.stopPropagation(); + const { dropdownToggle, onClick } = this.props; + if (onClick) onClick(); + return dropdownToggle(); + } + + handleKeyDown(event) { + event.stopPropagation(); + const { dropdownShow, dropdownHide } = this.props; + + if ([KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event.which)) { + event.preventDefault(); + event.stopPropagation(); + } else if ([KEY_CODES.ARROW_UP, KEY_CODES.ARROW_DOWN].includes(event.which)) { + dropdownShow(); + } else if (KEY_CODES.ESCAPE === event.which) { + dropdownHide(); + } + } + + render() { + const { dropdownIsOpen } = this.props; + const remainingProps = { ...this.props }; + delete remainingProps.dropdownToggle; + delete remainingProps.dropdownShow; + delete remainingProps.dropdownHide; + delete remainingProps.dropdownIsOpen; + + const { + children, + className, + ...restProps + } = remainingProps; + + let TriggerComponent; + let TriggerComponentBounded; + + const buttonComponentProps = { + ...restProps, + 'aria-expanded': dropdownIsOpen, + }; + + const triggerComponentProps = { + onClick: this.handleClick, + onKeyDown: this.handleKeyDown, + }; + + if (DropdownTrigger.isButtonTriggerOnEmoji(children)) { + const { children: grandChildren } = children.props; + + triggerComponentProps.className = cx(children.props.className, className); + + TriggerComponent = React.Children.only(grandChildren); + TriggerComponentBounded = React.cloneElement(TriggerComponent, + triggerComponentProps); + + const ButtonComponent = React.Children.only(children); + return React.cloneElement(ButtonComponent, + buttonComponentProps, TriggerComponentBounded); + } + + buttonComponentProps.className = cx(children.props.className, className); + TriggerComponent = React.Children.only(children); + + TriggerComponentBounded = React.cloneElement(TriggerComponent, + { ...buttonComponentProps, ...triggerComponentProps }); + + return TriggerComponentBounded; + } +} + +DropdownTrigger.propTypes = propTypes; diff --git a/src/2.7.12/imports/ui/components/emoji-picker/component.jsx b/src/2.7.12/imports/ui/components/emoji-picker/component.jsx new file mode 100644 index 00000000..0b3b015d --- /dev/null +++ b/src/2.7.12/imports/ui/components/emoji-picker/component.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import data from '@emoji-mart/data'; +import Picker from '@emoji-mart/react'; + +const DISABLE_EMOJIS = Meteor.settings.public.chat.disableEmojis; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + onEmojiSelect: PropTypes.func.isRequired, +}; + +const emojisToExclude = [ + ...DISABLE_EMOJIS, +]; + +const EmojiPicker = (props) => { + const { + intl, + onEmojiSelect, + } = props; + + const i18n = { + search: intl.formatMessage({ id: 'app.emojiPicker.search' }), + notfound: intl.formatMessage({ id: 'app.emojiPicker.notFound' }), + clear: intl.formatMessage({ id: 'app.emojiPicker.clear' }), + skintext: intl.formatMessage({ id: 'app.emojiPicker.skintext' }), + categories: { + people: intl.formatMessage({ id: 'app.emojiPicker.categories.people' }), + nature: intl.formatMessage({ id: 'app.emojiPicker.categories.nature' }), + foods: intl.formatMessage({ id: 'app.emojiPicker.categories.foods' }), + places: intl.formatMessage({ id: 'app.emojiPicker.categories.places' }), + activity: intl.formatMessage({ id: 'app.emojiPicker.categories.activity' }), + objects: intl.formatMessage({ id: 'app.emojiPicker.categories.objects' }), + symbols: intl.formatMessage({ id: 'app.emojiPicker.categories.symbols' }), + flags: intl.formatMessage({ id: 'app.emojiPicker.categories.flags' }), + recent: intl.formatMessage({ id: 'app.emojiPicker.categories.recent' }), + search: intl.formatMessage({ id: 'app.emojiPicker.categories.search' }), + }, + categorieslabel: intl.formatMessage({ id: 'app.emojiPicker.categories.label' }), + skintones: { + 1: intl.formatMessage({ id: 'app.emojiPicker.skintones.1' }), + 2: intl.formatMessage({ id: 'app.emojiPicker.skintones.2' }), + 3: intl.formatMessage({ id: 'app.emojiPicker.skintones.3' }), + 4: intl.formatMessage({ id: 'app.emojiPicker.skintones.4' }), + 5: intl.formatMessage({ id: 'app.emojiPicker.skintones.5' }), + 6: intl.formatMessage({ id: 'app.emojiPicker.skintones.6' }), + }, + }; + + return ( + onEmojiSelect(emojiObject, event)} + emojiSize={24} + i18n={i18n} + previewPosition="none" + skinTonePosition="none" + theme="light" + dynamicWidth + exceptEmojis={emojisToExclude} + /> + ); +}; + +EmojiPicker.propTypes = propTypes; + +export default injectIntl(EmojiPicker); diff --git a/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js new file mode 100644 index 00000000..fda0dd7d --- /dev/null +++ b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js @@ -0,0 +1,35 @@ +import styled, { css } from 'styled-components'; + +const EmojiWrapper = styled.span` + padding-top: 0.9em; + padding-bottom: 0.1em; + ${({ selected }) => !selected && css` + :hover { + border-radius:100%; + outline-color: transparent; + outline-style:solid; + box-shadow: 0 0 0 0.25em #eee; + background-color: #eee; + opacity: 0.75; + } + `} + ${({ selected }) => selected && css` + em-emoji { + cursor: not-allowed; + } + `} + ${({ selected, emoji }) => selected && selected !== emoji && css` + opacity: 0.75; + `} + ${({ selected, emoji }) => selected && selected === emoji && css` + border-radius:100%; + outline-color: transparent; + outline-style:solid; + box-shadow: 0 0 0 0.25em #eee; + background-color: #eee; + `} +`; + +export default { + EmojiWrapper, +}; diff --git a/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss new file mode 100644 index 00000000..4b33f6de --- /dev/null +++ b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss @@ -0,0 +1,27 @@ +.dropdownContent { + section[class^="emoji-mart-search"] { + display: none !important; + } + section[class^="emoji-mart emoji-mart-light"] { + display: unset; + border: unset; + } + div[class^="emoji-mart-bar"] { + display: none !important; + } + section[aria-label^="Frequently Used"] { + display: none !important; + } + div[class^="emoji-mart-category-label"] { + display: none !important; + } + div[class^="emoji-mart-scroll"] { + overflow-y: unset; + height: unset; + } + ul[class^="emoji-mart-category-list"] { + span { + cursor: pointer !important; + } + } +} diff --git a/src/2.7.12/imports/ui/components/emoji-rain/component.jsx b/src/2.7.12/imports/ui/components/emoji-rain/component.jsx new file mode 100644 index 00000000..fed1c286 --- /dev/null +++ b/src/2.7.12/imports/ui/components/emoji-rain/component.jsx @@ -0,0 +1,121 @@ +import React, { useRef, useState, useEffect } from 'react'; +import Settings from '/imports/ui/services/settings'; +import Service from './service'; +import logger from '/imports/startup/client/logger'; + +const EmojiRain = ({ reactions }) => { + const containerRef = useRef(null); + const [isAnimating, setIsAnimating] = useState(false); + const EMOJI_SIZE = Meteor.settings.public.app.emojiRain.emojiSize; + const NUMBER_OF_EMOJIS = Meteor.settings.public.app.emojiRain.numberOfEmojis; + const EMOJI_RAIN_ENABLED = Meteor.settings.public.app.emojiRain.enabled; + + const { animations } = Settings.application; + + function createEmojiRain(emoji) { + const coord = Service.getInteractionsButtonCoordinates(); + const flyingEmojis = []; + + if (coord == null) { + logger.warn({ + logCode: 'interactions_emoji_rain_no_coord', + }, 'No coordinates for interactions button, skipping emoji rain'); + return; + } + + for (i = 0; i < NUMBER_OF_EMOJIS; i++) { + const initialPosition = { + x: coord.x + coord.width / 8, + y: coord.y + coord.height / 5, + }; + const endPosition = { + x: Math.floor(Math.random() * 100) + coord.x - 100 / 2, + y: Math.floor(Math.random() * 300) + coord.y / 2, + }; + const scale = Math.floor(Math.random() * (8 - 4 + 1)) - 40; + const sec = Math.floor(Math.random() * 1700) + 2000; + + const shapeElement = document.createElement('svg'); + const emojiElement = document.createElement('text'); + emojiElement.setAttribute('x', '50%'); + emojiElement.setAttribute('y', '50%'); + emojiElement.innerHTML = emoji; + + shapeElement.style.position = 'absolute'; + shapeElement.style.left = `${initialPosition.x}px`; + shapeElement.style.top = `${initialPosition.y}px`; + shapeElement.style.transform = `scaleX(0.${scale}) scaleY(0.${scale})`; + shapeElement.style.transition = `${sec}ms`; + shapeElement.style.fontSize = `${EMOJI_SIZE}em`; + shapeElement.style.pointerEvents = 'none'; + + shapeElement.appendChild(emojiElement); + containerRef.current.appendChild(shapeElement); + + flyingEmojis.push({ shapeElement, endPosition }); + } + + requestAnimationFrame(() => setTimeout(() => flyingEmojis.forEach((emoji) => { + const { shapeElement, endPosition } = emoji; + shapeElement.style.left = `${endPosition.x}px`; + shapeElement.style.top = `${endPosition.y}px`; + shapeElement.style.transform = 'scaleX(0) scaleY(0)'; + }), 0)); + + setTimeout(() => { + flyingEmojis.forEach((emoji) => emoji.shapeElement.remove()); + flyingEmojis.length = 0; + }, 2000); + } + + const handleVisibilityChange = () => { + if (document.hidden) { + setIsAnimating(false); + } else if (EMOJI_RAIN_ENABLED && animations) { + setIsAnimating(true); + } + }; + + useEffect(() => { + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + useEffect(() => { + if (EMOJI_RAIN_ENABLED && animations && !isAnimating && !document.hidden) { + setIsAnimating(true); + } else if (!animations) { + setIsAnimating(false); + } + }, [EMOJI_RAIN_ENABLED, animations, isAnimating]); + + useEffect(() => { + if (isAnimating && animations !== false) { + reactions.forEach((reaction) => { + const currentTime = new Date().getTime(); + const secondsSinceCreated = (currentTime - reaction.creationDate.getTime()) / 1000; + if (secondsSinceCreated <= 1 && (reaction.reaction !== 'none')) { + createEmojiRain(reaction.reaction); + } + }); + } + }, [isAnimating, reactions]); + + const containerStyle = { + width: '100vw', + height: '100vh', + position: 'fixed', + top: 0, + left: 0, + overflow: 'hidden', + pointerEvents: 'none', + zIndex: 2, + }; + + return
; +}; + +export default EmojiRain; diff --git a/src/2.7.12/imports/ui/components/emoji-rain/container.jsx b/src/2.7.12/imports/ui/components/emoji-rain/container.jsx new file mode 100644 index 00000000..d6f24ef7 --- /dev/null +++ b/src/2.7.12/imports/ui/components/emoji-rain/container.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import EmojiRain from './component'; +import UserReaction from '/imports/api/user-reaction'; +import Auth from '/imports/ui/services/auth'; + +const EmojiRainContainer = (props) => ; + +export default withTracker(() => ({ + reactions: UserReaction.find({ meetingId: Auth.meetingID }).fetch(), +}))(EmojiRainContainer); diff --git a/src/2.7.12/imports/ui/components/emoji-rain/service.js b/src/2.7.12/imports/ui/components/emoji-rain/service.js new file mode 100644 index 00000000..b172559c --- /dev/null +++ b/src/2.7.12/imports/ui/components/emoji-rain/service.js @@ -0,0 +1,13 @@ +const getInteractionsButtonCoordinates = () => { + const el = document.getElementById('interactionsButton'); + + if (!el) return null; + + const coordinate = el.getBoundingClientRect(); + + return coordinate; +}; + +export default { + getInteractionsButtonCoordinates, +}; diff --git a/src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx b/src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx new file mode 100644 index 00000000..47c8ce5b --- /dev/null +++ b/src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx @@ -0,0 +1,80 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component'; + +const intlMessages = defineMessages({ + endMeetingTitle: { + id: 'app.endMeeting.title', + description: 'end meeting title', + }, + endMeetingDescription: { + id: 'app.endMeeting.description', + description: 'end meeting description with affected users information', + }, + endMeetingNoUserDescription: { + id: 'app.endMeeting.noUserDescription', + description: 'end meeting description', + }, + contentWarning: { + id: 'app.endMeeting.contentWarning', + description: 'end meeting content warning', + }, + confirmButtonLabel: { + id: 'app.endMeeting.yesLabel', + description: 'end meeting confirm button label', + }, +}); + +const { warnAboutUnsavedContentOnMeetingEnd } = Meteor.settings.public.app; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + endMeeting: PropTypes.func.isRequired, + meetingTitle: PropTypes.string.isRequired, + users: PropTypes.number.isRequired, +}; + +class EndMeetingComponent extends PureComponent { + render() { + const { + users, intl, endMeeting, meetingTitle, + isOpen, onRequestClose, priority, setIsOpen, + } = this.props; + + const title = intl.formatMessage(intlMessages.endMeetingTitle, { 0: meetingTitle }); + + let description = users > 0 + ? intl.formatMessage(intlMessages.endMeetingDescription, { 0: users }) + : intl.formatMessage(intlMessages.endMeetingNoUserDescription); + + if (warnAboutUnsavedContentOnMeetingEnd) { + // the double breakline it to put one empty line between the descriptions + description += `\n\n${intl.formatMessage(intlMessages.contentWarning)}`; + } + + return ( + + ); + } +} + +EndMeetingComponent.propTypes = propTypes; + +export default injectIntl(EndMeetingComponent); diff --git a/src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx b/src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx new file mode 100644 index 00000000..51e8b98f --- /dev/null +++ b/src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import { makeCall } from '/imports/ui/services/api'; +import EndMeetingComponent from './component'; +import Service from './service'; +import logger from '/imports/startup/client/logger'; + +const EndMeetingContainer = (props) => ; + +export default withTracker((props) => ({ + endMeeting: () => { + logger.warn({ + logCode: 'moderator_forcing_end_meeting', + extraInfo: { logType: 'user_action' }, + }, 'this user clicked on EndMeeting and confirmed, removing everybody from the meeting'); + makeCall('endMeeting'); + props.setIsOpen(false); + }, + meetingTitle: Service.getMeetingTitle(), + users: Service.getUsers(), +}))(EndMeetingContainer); diff --git a/src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js b/src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js new file mode 100644 index 00000000..5817cdab --- /dev/null +++ b/src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js @@ -0,0 +1,25 @@ +import Users from '/imports/api/users'; +import Auth from '/imports/ui/services/auth'; +import Meetings from '/imports/api/meetings'; + +const getMeetingTitle = () => { + const meeting = Meetings.findOne({ + meetingId: Auth.meetingID, + }, { fields: { 'meetingProp.name': 1, 'breakoutProps.sequence': 1 } }); + + return meeting.meetingProp.name; +}; + +const getUsers = () => { + const numUsers = Users.find({ + meetingId: Auth.meetingID, + userId: { $ne: Auth.userID }, + }, { fields: { _id: 1 } }).count(); + + return numUsers; +}; + +export default { + getUsers, + getMeetingTitle, +}; diff --git a/src/2.7.12/imports/ui/components/error-screen/component.jsx b/src/2.7.12/imports/ui/components/error-screen/component.jsx new file mode 100644 index 00000000..15cb0f37 --- /dev/null +++ b/src/2.7.12/imports/ui/components/error-screen/component.jsx @@ -0,0 +1,149 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { Meteor } from 'meteor/meteor'; +import { Session } from 'meteor/session'; +import AudioManager from '/imports/ui/services/audio-manager'; +import logger from '/imports/startup/client/logger'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + 503: { + id: 'app.error.503', + }, + 500: { + id: 'app.error.500', + defaultMessage: 'Oops, something went wrong', + }, + 410: { + id: 'app.error.410', + }, + 409: { + id: 'app.error.409', + }, + 408: { + id: 'app.error.408', + }, + 404: { + id: 'app.error.404', + defaultMessage: 'Not found', + }, + 403: { + id: 'app.error.403', + }, + 401: { + id: 'app.error.401', + }, + 400: { + id: 'app.error.400', + }, + meeting_ended: { + id: 'app.meeting.endedMessage', + }, + user_logged_out_reason: { + id: 'app.error.userLoggedOut', + }, + validate_token_failed_eject_reason: { + id: 'app.error.ejectedUser', + }, + banned_user_rejoining_reason: { + id: 'app.error.userBanned', + }, + joined_another_window_reason: { + id: 'app.error.joinedAnotherWindow', + }, + user_inactivity_eject_reason: { + id: 'app.meeting.logout.userInactivityEjectReason', + }, + user_requested_eject_reason: { + id: 'app.meeting.logout.ejectedFromMeeting', + }, + max_participants_reason: { + id: 'app.meeting.logout.maxParticipantsReached', + }, + guest_deny: { + id: 'app.guest.guestDeny', + }, + duplicate_user_in_meeting_eject_reason: { + id: 'app.meeting.logout.duplicateUserEjectReason', + }, + not_enough_permission_eject_reason: { + id: 'app.meeting.logout.permissionEjectReason', + }, + able_to_rejoin_user_disconnected_reason: { + id: 'app.error.disconnected.rejoin', + }, +}); + +const propTypes = { + code: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), +}; + +const defaultProps = { + code: '500', + callback: async () => {}, +}; + +class ErrorScreen extends PureComponent { + componentDidMount() { + const { code, callback } = this.props; + const log = code === '403' ? 'warn' : 'error'; + AudioManager.exitAudio(); + callback().finally(() => { + Meteor.disconnect(); + }); + logger[log]({ logCode: 'startup_client_usercouldnotlogin_error' }, `User could not log in HTML5, hit ${code}`); + } + + render() { + const { + intl, + code, + children, + } = this.props; + + let formatedMessage = intl.formatMessage(intlMessages[defaultProps.code]); + + if (code in intlMessages) { + formatedMessage = intl.formatMessage(intlMessages[code]); + } + + let errorMessageDescription = Session.get('errorMessageDescription'); + + if (errorMessageDescription in intlMessages) { + errorMessageDescription = intl.formatMessage(intlMessages[errorMessageDescription]); + } + + return ( + + + {formatedMessage} + + { + !errorMessageDescription + || formatedMessage === errorMessageDescription + || ( + + {errorMessageDescription} + + ) + } + + + {code} + +
+ {children} +
+
+ ); + } +} + +export default injectIntl(ErrorScreen); + +ErrorScreen.propTypes = propTypes; +ErrorScreen.defaultProps = defaultProps; diff --git a/src/2.7.12/imports/ui/components/error-screen/styles.js b/src/2.7.12/imports/ui/components/error-screen/styles.js new file mode 100644 index 00000000..7554699a --- /dev/null +++ b/src/2.7.12/imports/ui/components/error-screen/styles.js @@ -0,0 +1,57 @@ +import styled from 'styled-components'; +import { + colorWhite, + colorGrayDark, + colorGrayLighter, +} from '/imports/ui/stylesheets/styled-components/palette'; + +const Background = styled.div` + position: fixed; + display: flex; + flex-flow: column; + justify-content: center; + width: 100%; + height: 100%; + background-color: ${colorGrayDark}; + color: ${colorWhite}; + text-align: center; +`; + +const Message = styled.h1` + margin: 0; + color: ${colorWhite}; + font-size: 1.75rem; + font-weight: 400; + margin-bottom: 1rem; +`; + +const SessionMessage = styled.div` + margin: 0; + color: ${colorWhite}; + font-weight: 400; + margin-bottom: 1rem; + font-size: 1.25rem; +`; + +const Separator = styled.div` + height: 0; + width: 5rem; + border: 1px solid ${colorGrayLighter}; + margin: 1.5rem 0 1.5rem 0; + align-self: center; + opacity: .75; +`; + +const CodeError = styled.h1` + margin: 0; + font-size: 1.5rem; + color: ${colorWhite}; +`; + +export default { + Background, + Message, + SessionMessage, + Separator, + CodeError, +}; diff --git a/src/2.7.12/imports/ui/components/external-video-player/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/component.jsx new file mode 100644 index 00000000..d1cd24fa --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/component.jsx @@ -0,0 +1,728 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import { + sendMessage, + onMessage, + removeAllListeners, + getPlayingState, +} from './service'; +import deviceInfo from '/imports/utils/deviceInfo'; + +import logger from '/imports/startup/client/logger'; + +import Subtitles from './subtitles/component'; +import VolumeSlider from './volume-slider/component'; +import ReloadButton from '/imports/ui/components/reload-button/component'; +import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container'; + +import ArcPlayer from '/imports/ui/components/external-video-player/custom-players/arc-player'; +import PeerTubePlayer from '/imports/ui/components/external-video-player/custom-players/peertube'; +import { ACTIONS } from '/imports/ui/components/layout/enums'; +import { uniqueId } from '/imports/utils/string-utils'; +import { throttle } from 'radash'; + +import Styled from './styles'; + +const intlMessages = defineMessages({ + autoPlayWarning: { + id: 'app.externalVideo.autoPlayWarning', + description: 'Shown when user needs to interact with player to make it work', + }, + refreshLabel: { + id: 'app.externalVideo.refreshLabel', + }, + fullscreenLabel: { + id: 'app.externalVideo.fullscreenLabel', + }, + subtitlesOn: { + id: 'app.externalVideo.subtitlesOn', + }, + subtitlesOff: { + id: 'app.externalVideo.subtitlesOff', + }, +}); + +const SYNC_INTERVAL_SECONDS = 5; +const THROTTLE_INTERVAL_SECONDS = 0.5; +const AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS = 5; +const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen; +const THROTTLE_RELOAD_INTERVAL = 5000; + +Styled.VideoPlayer.addCustomPlayer(PeerTubePlayer); +Styled.VideoPlayer.addCustomPlayer(ArcPlayer); + +class VideoPlayer extends Component { + static clearVideoListeners() { + removeAllListeners('play'); + removeAllListeners('stop'); + removeAllListeners('playerUpdate'); + removeAllListeners('presenterReady'); + } + + constructor(props) { + super(props); + + const { isPresenter } = props; + + this.player = null; + this.syncInterval = null; + this.autoPlayTimeout = null; + this.hasPlayedBefore = false; + this.playerIsReady = false; + + this.lastMessage = null; + this.lastMessageTimestamp = Date.now(); + + this.throttleTimeout = null; + + this.state = { + subtitlesOn: false, + muted: false, + playing: false, + autoPlayBlocked: false, + volume: 1, + playbackRate: 1, + key: 0, + played:0, + loaded:0, + }; + + this.hideVolume = { + Vimeo: true, + Facebook: true, + ArcPlayer: true, + //YouTube: true, + }; + + this.opts = { + // default option for all players, can be overwritten + playerOptions: { + autoplay: true, + playsinline: true, + controls: isPresenter, + }, + file: { + attributes: { + controls: isPresenter ? 'controls' : '', + autoplay: 'autoplay', + playsinline: 'playsinline', + }, + }, + facebook: { + controls: isPresenter, + }, + dailymotion: { + params: { + controls: isPresenter, + }, + }, + youtube: { + playerVars: { + autoplay: 1, + modestbranding: 1, + autohide: 1, + rel: 0, + ecver: 2, + controls: isPresenter ? 1 : 0, + cc_lang_pref: document.getElementsByTagName('html')[0].lang.substring(0, 2), + }, + }, + peertube: { + isPresenter, + }, + twitch: { + options: { + controls: isPresenter, + }, + playerId: 'externalVideoPlayerTwitch', + }, + preload: true, + showHoverToolBar: false, + }; + + this.registerVideoListeners = this.registerVideoListeners.bind(this); + this.autoPlayBlockDetected = this.autoPlayBlockDetected.bind(this); + this.handleFirstPlay = this.handleFirstPlay.bind(this); + this.handleReload = throttle({ interval: THROTTLE_RELOAD_INTERVAL }, this.handleReload.bind(this)); + this.handleOnProgress = this.handleOnProgress.bind(this); + this.handleOnReady = this.handleOnReady.bind(this); + this.handleOnPlay = this.handleOnPlay.bind(this); + this.handleOnPause = this.handleOnPause.bind(this); + this.handleVolumeChanged = this.handleVolumeChanged.bind(this); + this.handleOnMuted = this.handleOnMuted.bind(this); + this.sendSyncMessage = this.sendSyncMessage.bind(this); + this.getCurrentPlaybackRate = this.getCurrentPlaybackRate.bind(this); + this.getCurrentTime = this.getCurrentTime.bind(this); + this.getCurrentVolume = this.getCurrentVolume.bind(this); + this.getMuted = this.getMuted.bind(this); + this.setPlaybackRate = this.setPlaybackRate.bind(this); + this.onBeforeUnload = this.onBeforeUnload.bind(this); + this.toggleSubtitle = this.toggleSubtitle.bind(this); + + this.mobileHoverSetTimeout = null; + } + + componentDidMount() { + const { + layoutContextDispatch, + } = this.props; + + window.addEventListener('beforeunload', this.onBeforeUnload); + + clearInterval(this.syncInterval); + clearTimeout(this.autoPlayTimeout); + + VideoPlayer.clearVideoListeners(); + this.registerVideoListeners(); + + layoutContextDispatch({ + type: ACTIONS.SET_HAS_EXTERNAL_VIDEO, + value: true, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: true, + }); + } + + shouldComponentUpdate(nextProps, nextState) { + const { isPresenter } = this.props; + const { playing } = this.state; + + // If user is presenter we don't re-render playing state changes + // Because he's in control of the play/pause status + if (nextProps.isPresenter && isPresenter && nextState.playing !== playing) { + return false; + } + + return true; + } + + componentDidUpdate(prevProp) { + // Detect presenter change and redo the sync and listeners to reassign video to the new one + const { isPresenter } = this.props; + if (isPresenter !== prevProp.isPresenter) { + VideoPlayer.clearVideoListeners(); + + clearInterval(this.syncInterval); + clearTimeout(this.autoPlayTimeout); + + this.registerVideoListeners(); + } + } + + componentWillUnmount() { + const { + layoutContextDispatch, + hidePresentationOnJoin, + } = this.props; + + window.removeEventListener('beforeunload', this.onBeforeUnload); + + VideoPlayer.clearVideoListeners(); + + clearInterval(this.syncInterval); + clearTimeout(this.autoPlayTimeout); + + this.player = null; + + layoutContextDispatch({ + type: ACTIONS.SET_HAS_EXTERNAL_VIDEO, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: Session.get('presentationLastState'), + }); + + if (hidePresentationOnJoin) { + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: false, + }); + } + } + + toggleSubtitle() { + this.setState((state) => { + return { subtitlesOn: !state.subtitlesOn }; + }, () => { + const { subtitlesOn } = this.state; + const { isPresenter } = this.props; + const internalPlayer = this?.player?.getInternalPlayer(); + if (!internalPlayer) return; + if (!isPresenter && subtitlesOn) { + if (typeof internalPlayer.setOption === 'function') { + internalPlayer.setOption('captions', 'reload', true); + } + } else if (typeof internalPlayer.unloadModule === 'function') { + internalPlayer.unloadModule('captions'); + } + }); + } + + handleOnReady() { + const { hasPlayedBefore, playerIsReady } = this; + + if (hasPlayedBefore || playerIsReady) { + return; + } + + this.playerIsReady = true; + + this.autoPlayTimeout = setTimeout( + this.autoPlayBlockDetected, + AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS * 1000, + ); + } + + handleFirstPlay() { + const { isPresenter } = this.props; + const { hasPlayedBefore } = this; + + if (!hasPlayedBefore) { + this.hasPlayedBefore = true; + this.setState({ autoPlayBlocked: false }); + + clearTimeout(this.autoPlayTimeout); + + if (isPresenter) { + this.sendSyncMessage('presenterReady'); + } + } + } + + handleOnPlay() { + const { isPresenter } = this.props; + const { playing } = this.state; + + const curTime = this.getCurrentTime(); + + if (isPresenter && !playing) { + this.sendSyncMessage('play', { time: curTime }); + } + this.setState({ playing: true }); + + this.handleFirstPlay(); + + if (!isPresenter && !playing) { + this.setState({ playing: false }); + } + } + + handleOnPause() { + const { isPresenter } = this.props; + const { playing } = this.state; + + const curTime = this.getCurrentTime(); + + if (isPresenter && playing) { + this.sendSyncMessage('stop', { time: curTime }); + } + this.setState({ playing: false }); + + this.handleFirstPlay(); + + if (!isPresenter && playing) { + this.setState({ playing: true }); + } + } + + handleOnProgress(data) { + const { mutedByEchoTest } = this.state; + + const volume = this.getCurrentVolume(); + const muted = this.getMuted(); + + const { played, loaded } = data; + + this.setState({played, loaded}); + + if (!mutedByEchoTest) { + this.setState({ volume, muted }); + } + } + + handleVolumeChanged(volume) { + this.setState({ volume }); + } + + handleOnMuted(muted) { + const { mutedByEchoTest } = this.state; + + if (!mutedByEchoTest) { + this.setState({ muted }); + } + } + + handleReload() { + const { key } = this.state; + // increment key and force a re-render of the video component + this.setState({ key: key + 1 }); + } + + onBeforeUnload() { + const { isPresenter } = this.props; + + if (isPresenter) { + this.sendSyncMessage('stop'); + } + } + + static getDerivedStateFromProps(props) { + const { inEchoTest } = props; + + return { mutedByEchoTest: inEchoTest }; + } + + getCurrentTime() { + if (this.player && this.player.getCurrentTime) { + return Math.round(this.player.getCurrentTime()); + } + return 0; + } + + getCurrentPlaybackRate() { + const intPlayer = this.player && this.player.getInternalPlayer(); + const rate = (intPlayer && intPlayer.getPlaybackRate && intPlayer.getPlaybackRate()); + + return typeof (rate) === 'number' ? rate : 1; + } + + setPlaybackRate(rate) { + const intPlayer = this.player && this.player.getInternalPlayer(); + const currentRate = this.getCurrentPlaybackRate(); + + if (currentRate === rate) { + return; + } + + this.setState({ playbackRate: rate }); + if (intPlayer && intPlayer.setPlaybackRate) { + intPlayer.setPlaybackRate(rate); + } + } + + getCurrentVolume() { + const { volume } = this.state; + const intPlayer = this.player && this.player.getInternalPlayer(); + + return (intPlayer && intPlayer.getVolume && intPlayer.getVolume() / 100.0) || volume; + } + + getMuted() { + const { isPresenter } = this.props; + const { muted } = this.state; + const intPlayer = this?.player?.getInternalPlayer?.(); + + return isPresenter ? intPlayer?.isMuted?.() : muted; + } + + autoPlayBlockDetected() { + this.setState({ autoPlayBlocked: true }); + } + + sendSyncMessage(msg, params) { + const timestamp = Date.now(); + + // If message is just a quick pause/un-pause just send nothing + const sinceLastMessage = (timestamp - this.lastMessageTimestamp) / 1000; + if (( + (msg === 'play' && this.lastMessage === 'stop') + || (msg === 'stop' && this.lastMessage === 'play')) + && sinceLastMessage < THROTTLE_INTERVAL_SECONDS) { + return clearTimeout(this.throttleTimeout); + } + + // Ignore repeat presenter ready messages + if (this.lastMessage === msg && msg === 'presenterReady') { + logger.debug('Ignoring a repeated presenterReady message'); + } else { + // Play/pause messages are sent with a delay, to permit cancelling it in case of + // quick sucessive play/pauses + const messageDelay = (msg === 'play' || msg === 'stop') ? THROTTLE_INTERVAL_SECONDS : 0; + + this.throttleTimeout = setTimeout(() => { + sendMessage(msg, { ...params }); + }, messageDelay * 1000); + + this.lastMessage = msg; + this.lastMessageTimestamp = timestamp; + } + return true; + } + + registerVideoListeners() { + const { isPresenter } = this.props; + + if (isPresenter) { + this.syncInterval = setInterval(() => { + const { playing } = this.state; + const curTime = this.getCurrentTime(); + const rate = this.getCurrentPlaybackRate(); + + // Always pause video if presenter is has not started sharing, e.g., blocked by autoplay + const playingState = this.hasPlayedBefore ? playing : false; + + this.sendSyncMessage('playerUpdate', { rate, time: curTime, state: playingState }); + }, SYNC_INTERVAL_SECONDS * 1000); + } else { + onMessage('play', ({ time }) => { + const { hasPlayedBefore, player } = this; + + if (!player || !hasPlayedBefore) { + return; + } + this.seekTo(time); + this.setState({ playing: true }); + + logger.debug({ logCode: 'external_video_client_play' }, 'Play external video'); + }); + + onMessage('stop', ({ time }) => { + const { hasPlayedBefore, player } = this; + + if (!player || !hasPlayedBefore) { + return; + } + this.seekTo(time); + this.setState({ playing: false }); + + logger.debug({ logCode: 'external_video_client_stop' }, 'Stop external video'); + }); + + onMessage('presenterReady', () => { + const { hasPlayedBefore } = this; + + logger.debug({ logCode: 'external_video_presenter_ready' }, 'Presenter is ready to sync'); + + if (!hasPlayedBefore) { + this.setState({ playing: true }); + } + }); + + onMessage('playerUpdate', (data) => { + const { hasPlayedBefore, player } = this; + const { playing } = this.state; + const { time, rate, state } = data; + + if (!player || !hasPlayedBefore) { + return; + } + + if (rate !== this.getCurrentPlaybackRate()) { + this.setPlaybackRate(rate); + logger.debug({ + logCode: 'external_video_client_update_rate', + extraInfo: { + newRate: rate, + }, + }, 'Change external video playback rate.'); + } + + this.seekTo(time); + + const playingState = getPlayingState(state); + if (playing !== playingState) { + this.setState({ playing: playingState }); + } + }); + } + } + + seekTo(time) { + const { player } = this; + + if (!player) { + return logger.error('No player on seek'); + } + + // Seek if viewer has drifted too far away from presenter + if (Math.abs(this.getCurrentTime() - time) > SYNC_INTERVAL_SECONDS * 0.75) { + player.seekTo(time, true); + logger.debug({ + logCode: 'external_video_client_update_seek', + extraInfo: { time }, + }, `Seek external video to: ${time}`); + } + return true; + } + + renderFullscreenButton() { + const { intl, fullscreenElementId, fullscreenContext } = this.props; + + if (!ALLOW_FULLSCREEN) return null; + + return ( + + ); + } + + render() { + const { + videoUrl, + isPresenter, + intl, + top, + left, + right, + height, + width, + fullscreenContext, + isResizing, + zIndex, + } = this.props; + + const { + playing, playbackRate, mutedByEchoTest, autoPlayBlocked, + volume, muted, key, showHoverToolBar, played, loaded, subtitlesOn + } = this.state; + + // This looks weird, but I need to get this nested player + const playerName = this.player && this.player.player + && this.player.player.player && this.player.player.player.constructor.name; + + let toolbarStyle = 'hoverToolbar'; + + if (deviceInfo.isMobile && !showHoverToolBar) { + toolbarStyle = 'dontShowMobileHoverToolbar'; + } + + if (deviceInfo.isMobile && showHoverToolBar) { + toolbarStyle = 'showMobileHoverToolbar'; + } + const isMinimized = width === 0 && height === 0; + + return ( + + { this.playerParent = ref; }} + > + { + autoPlayBlocked + ? ( + + {intl.formatMessage(intlMessages.autoPlayWarning)} + + ) + : '' + } + + { this.player = ref; }} + height="100%" + width="100%" + /> + { + !isPresenter + ? [ + ( + + + + + {playerName === 'YouTube' && ( + + )} + + {this.renderFullscreenButton()} + + + + + + + + ), + (deviceInfo.isMobile && playing) && ( + { this.overlay = ref; }} + onTouchStart={() => { + clearTimeout(this.mobileHoverSetTimeout); + this.setState({ showHoverToolBar: true }); + }} + onTouchEnd={() => { + this.mobileHoverSetTimeout = setTimeout( + () => this.setState({ showHoverToolBar: false }), + 5000, + ); + }} + /> + ), + ] + : null + } + + + ); + } +} + +VideoPlayer.propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + fullscreenElementId: PropTypes.string.isRequired, + fullscreenContext: PropTypes.bool.isRequired, +}; + +export default injectIntl(injectWbResizeEvent(VideoPlayer)); diff --git a/src/2.7.12/imports/ui/components/external-video-player/container.jsx b/src/2.7.12/imports/ui/components/external-video-player/container.jsx new file mode 100644 index 00000000..a97d6f0d --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/container.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import { Session } from 'meteor/session'; +import { getVideoUrl } from './service'; +import ExternalVideoComponent from './component'; +import { + layoutSelect, + layoutSelectInput, + layoutSelectOutput, + layoutDispatch, +} from '../layout/context'; + +const ExternalVideoContainer = (props) => { + const fullscreenElementId = 'ExternalVideo'; + const externalVideo = layoutSelectOutput((i) => i.externalVideo); + const cameraDock = layoutSelectInput((i) => i.cameraDock); + const { isResizing } = cameraDock; + const layoutContextDispatch = layoutDispatch(); + const fullscreen = layoutSelect((i) => i.fullscreen); + const { element } = fullscreen; + const fullscreenContext = (element === fullscreenElementId); + + return ( + + ); +}; + +const LAYOUT_CONFIG = Meteor.settings.public.layout; + +export default withTracker(({ isPresenter }) => { + const inEchoTest = Session.get('inEchoTest'); + return { + inEchoTest, + isPresenter, + videoUrl: getVideoUrl(), + }; +})(ExternalVideoContainer); diff --git a/src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx b/src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx new file mode 100644 index 00000000..e87a24da --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx @@ -0,0 +1,208 @@ +import loadScript from 'load-script'; +import React, { Component } from 'react' + +const MATCH_URL = new RegExp("https?:\/\/(.*)(instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)"); + +const SDK_URL = 'https://files.instructuremedia.com/instructure-media-script/instructure-media-1.1.0.js'; + +const EMBED_PATH = "/embed/"; + +// Util function to load an external SDK or return the SDK if it is already loaded +// From https://github.com/CookPete/react-player/blob/master/src/utils.js +const resolves = {}; +export function getSDK (url, sdkGlobal, sdkReady = null, isLoaded = () => true, fetchScript = loadScript) { + if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) { + return Promise.resolve(window[sdkGlobal]) + } + return new Promise((resolve, reject) => { + // If we are already loading the SDK, add the resolve + // function to the existing array of resolve functions + if (resolves[url]) { + resolves[url].push(resolve); + return + } + resolves[url] = [resolve]; + const onLoaded = sdk => { + // When loaded, resolve all pending promises + resolves[url].forEach(resolve => resolve(sdk)) + }; + if (sdkReady) { + const previousOnReady = window[sdkReady]; + window[sdkReady] = function () { + if (previousOnReady) previousOnReady(); + onLoaded(window[sdkGlobal]) + } + } + fetchScript(url, err => { + if (err) { + reject(err); + } + window[sdkGlobal] = url; + if (!sdkReady) { + onLoaded(window[sdkGlobal]) + } + }) + }) +} + +export class ArcPlayer extends Component { + static displayName = 'ArcPlayer'; + + static canPlay = url => { + return MATCH_URL.test(url) + }; + + constructor(props) { + super(props); + + this.currentTime = 0; + this.updateCurrentTime = this.updateCurrentTime.bind(this); + this.getCurrentTime = this.getCurrentTime.bind(this); + this.getEmbedUrl = this.getEmbedUrl.bind(this); + this.onStateChange = this.onStateChange.bind(this); + } + + componentDidMount () { + this.props.onMount && this.props.onMount(this) + } + + load() { + new Promise((resolve, reject) => { + this.render(); + resolve(); + }) + .then(() => { return getSDK(SDK_URL, 'ArcPlayer') }) + .then(() => { + this.player = new InstructureMedia.Player('arcPlayerContainer', { + height: '100%', + width: '100%', + embedUrl: this.getEmbedUrl(), + events: { + onStateChange: this.onStateChange, + } + }); + this.player.playVideo(); + }); + } + + onStateChange(event) { + if (!this.player) { + return; + } + + this.player.getCurrentTime().then((t) => { + this.updateCurrentTime(t.data); + }); + + if (event.data === "CUED") { + this.props.onReady(); + } else if (event.data === "PLAYING") { + this.props.onPlay && this.props.onPlay(); + } else if (event.data === "PAUSED") { + this.props.onPause && this.props.onPause(); + } else if (event.data === "SEEKED") { + // TODO + } else if (event.data === "SEEKING") { + // TODO + } + } + + updateCurrentTime(e) { + this.currentTime = e; + } + + getVideoId() { + const { url } = this.props; + const m = url.match(MATCH_URL); + return m && m[4]; + } + + getHostUrl() { + const { url } = this.props; + const m = url.match(MATCH_URL); + return m && 'https://' + m[1] + m[2]; + } + + getEmbedUrl() { + let url = this.getHostUrl() + EMBED_PATH + this.getVideoId(); + return url; + } + + play() { + if (this.player) { + this.player.playVideo(); + } + } + + pause() { + if (this.player) { + this.player.pauseVideo(); + } + } + + stop() { + // TODO: STOP + } + + seekTo(seconds) { + if (this.player) { + this.player.seekTo(seconds); + } + } + + setVolume(fraction) { + // console.log("SET VOLUME"); + } + + setLoop(loop) { + // console.log("SET LOOP"); + } + + mute() { + // console.log("SET MUTE"); + } + + unmute() { + // console.log("SET UNMUTE"); + } + + getDuration() { + //console.log("GET DURATION"); + } + + getCurrentTime () { + if (this.player) { + this.player.getCurrentTime().then((t) => { + this.updateCurrentTime(t.data); + }); + } + + return this.currentTime; + } + + getSecondsLoaded () { + } + + render () { + const style = { + width: '100%', + height: '100%', + overflow: 'hidden', + backgroundColor: 'black' + }; + return ( +
{ + this.container = container; + }} + > +
+ ) + } +} + +export default ArcPlayer; + diff --git a/src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx b/src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx new file mode 100644 index 00000000..d7a88663 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx @@ -0,0 +1,16 @@ +const MATCH_URL = /https?\:\/\/([^\/]+\/Panopto)(\/Pages\/Viewer\.aspx\?id=)([-a-zA-Z0-9]+)/; + +export class Panopto { + + static canPlay = url => { + return MATCH_URL.test(url) + }; + + static getSocialUrl(url) { + const m = url.match(MATCH_URL); + return 'https://' + m[1] + '/Podcast/Social/' + m[3] + '.mp4'; + } +} + +export default Panopto; + diff --git a/src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx b/src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx new file mode 100644 index 00000000..13f5cb3a --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx @@ -0,0 +1,216 @@ +import loadScript from 'load-script'; +import React, { Component } from 'react' + +//To work with PeerTube >=v3.3 URL patterns +const MATCH_URL = new RegExp("(https?)://(.*)(/videos/watch/|/w/)(.*)"); + +const SDK_URL = 'https://unpkg.com/@peertube/embed-api@0.0.4/build/player.min.js'; + +// Util function to load an external SDK or return the SDK if it is already loaded +// From https://github.com/CookPete/react-player/blob/master/src/utils.js +const resolves = {}; +export function getSDK (url, sdkGlobal, sdkReady = null, isLoaded = () => true, fetchScript = loadScript) { + if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) { + return Promise.resolve(window[sdkGlobal]) + } + return new Promise((resolve, reject) => { + // If we are already loading the SDK, add the resolve + // function to the existing array of resolve functions + if (resolves[url]) { + resolves[url].push(resolve); + return + } + resolves[url] = [resolve]; + const onLoaded = sdk => { + // When loaded, resolve all pending promises + resolves[url].forEach(resolve => resolve(sdk)) + }; + if (sdkReady) { + const previousOnReady = window[sdkReady]; + window[sdkReady] = function () { + if (previousOnReady) previousOnReady(); + onLoaded(window[sdkGlobal]) + } + } + fetchScript(url, err => { + if (err) { + reject(err); + } + window[sdkGlobal] = url; + if (!sdkReady) { + onLoaded(window[sdkGlobal]) + } + }) + }) +} + +export class PeerTubePlayer extends Component { + static displayName = 'PeerTubePlayer'; + + static canPlay = url => { + return MATCH_URL.test(url) + }; + + constructor(props) { + super(props); + + this.player = this; + this._player = null; + + this.currentTime = 0; + this.playbackRate = 1; + this.getCurrentTime = this.getCurrentTime.bind(this); + this.getEmbedUrl = this.getEmbedUrl.bind(this); + this.setupEvents = this.setupEvents.bind(this); + } + + componentDidMount () { + this.props.onMount && this.props.onMount(this) + } + + getEmbedUrl = () => { + const { url, config } = this.props; + const m = MATCH_URL.exec(url); + + const isPresenter = config && config.peertube && config.peertube.isPresenter; + + return `${m[1]}://${m[2]}/videos/embed/${m[4]}?api=1&controls=${isPresenter}`; + }; + + load() { + new Promise((resolve, reject) => { + this.render(); + resolve(); + }) + .then(() => { return getSDK(SDK_URL, 'PeerTube') }) + .then(() => { + this._player = new window['PeerTubePlayer'](this.container); + + this.setupEvents(); + + return this._player.ready.then(() => { + return this.props.onReady(); + }); + }); + } + + setupEvents(event) { + const player = this._player; + + if (!player) { + return; + } + + player.addEventListener("playbackStatusUpdate", (data) => { + this.currentTime = data.position; + }); + player.addEventListener("playbackStatusChange", (data) => { + if (data === 'playing') { + this.props.onPlay(); + } else { + this.props.onPause(); + } + }); + + } + + play() { + if (this._player) { + this._player.play(); + } + } + + pause() { + if (this._player) { + this._player.pause(); + } + } + + stop() { + } + + seekTo(seconds) { + if (this._player) { + this._player.seek(seconds); + } + } + + setVolume(fraction) { + this._player.setVolume(fraction); + } + + getVolume() { + return this._player.getVolume(); + } + + setLoop(loop) { + // console.log("SET LOOP"); + } + + mute() { + // console.log("SET MUTE"); + } + + unmute() { + // console.log("SET UNMUTE"); + } + + getDuration() { + //console.log("GET DURATION"); + } + + getCurrentTime () { + return this.currentTime; + } + + getSecondsLoaded () { + } + + getPlaybackRate () { + + if (this._player) { + this._player.getPlaybackRate().then((rate) => { + this.playbackRate = rate; + }); + } + + return this.playbackRate; + } + + setPlaybackRate (rate) { + + if (this._player) { + this._player.setPlaybackRate(rate); + } + } + + render () { + const style = { + width: '100%', + height: '100%', + margin: 0, + padding: 0, + border: 0, + overflow: 'hidden', + }; + const { url } = this.props; + + return ( + + ) + } +} + +export default PeerTubePlayer; + diff --git a/src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx new file mode 100644 index 00000000..4601fd54 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx @@ -0,0 +1,147 @@ +import React, { Component } from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import { isUrlValid } from '../service'; +import Settings from '/imports/ui/services/settings'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + start: { + id: 'app.externalVideo.start', + description: 'Share external video', + }, + urlError: { + id: 'app.externalVideo.urlError', + description: 'Not a video URL error', + }, + input: { + id: 'app.externalVideo.input', + description: 'Video URL', + }, + urlInput: { + id: 'app.externalVideo.urlInput', + description: 'URL input field placeholder', + }, + title: { + id: 'app.externalVideo.title', + description: 'Modal title', + }, + close: { + id: 'app.externalVideo.close', + description: 'Close', + }, + note: { + id: 'app.externalVideo.noteLabel', + description: 'provides hint about Shared External videos', + }, +}); + +class ExternalVideoModal extends Component { + constructor(props) { + super(props); + + const { videoUrl } = props; + + this.state = { + url: videoUrl, + sharing: videoUrl, + }; + + this.startWatchingHandler = this.startWatchingHandler.bind(this); + this.updateVideoUrlHandler = this.updateVideoUrlHandler.bind(this); + this.renderUrlError = this.renderUrlError.bind(this); + this.updateVideoUrlHandler = this.updateVideoUrlHandler.bind(this); + } + + startWatchingHandler() { + const { + startWatching, + setIsOpen, + } = this.props; + + const { url } = this.state; + + startWatching(url.trim()); + setIsOpen(false); + } + + updateVideoUrlHandler(ev) { + this.setState({ url: ev.target.value }); + } + + renderUrlError() { + const { intl } = this.props; + const { url } = this.state; + const { animations } = Settings.application; + + const valid = (!url || url.length <= 3) || isUrlValid(url); + + return ( + !valid + ? ( + + {intl.formatMessage(intlMessages.urlError)} + + ) + : null + ); + } + + render() { + const { intl, setIsOpen, isOpen, onRequestClose, priority, } = this.props; + const { url, sharing } = this.state; + const { animations } = Settings.application; + + const startDisabled = !isUrlValid(url); + + return ( + setIsOpen(false)} + contentLabel={intl.formatMessage(intlMessages.title)} + title={intl.formatMessage(intlMessages.title)} + {...{ + setIsOpen, + isOpen, + onRequestClose, + priority, + }} + > + + + + + {intl.formatMessage(intlMessages.note)} + + + +
+ {this.renderUrlError()} +
+ + +
+
+ ); + } +} + +export default injectIntl(ExternalVideoModal); diff --git a/src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx b/src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx new file mode 100644 index 00000000..9cff24d5 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import ExternalVideoModal from './component'; +import { startWatching, getVideoUrl } from '../service'; + +const ExternalVideoModalContainer = props => ; + +export default withTracker(() => ({ + startWatching, + videoUrl: getVideoUrl(), +}))(ExternalVideoModalContainer); diff --git a/src/2.7.12/imports/ui/components/external-video-player/modal/styles.js b/src/2.7.12/imports/ui/components/external-video-player/modal/styles.js new file mode 100644 index 00000000..872ecf35 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/modal/styles.js @@ -0,0 +1,110 @@ +import styled from 'styled-components'; +import { + borderSize, + borderRadius, + smPaddingY, + mdPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorText, + colorGrayLighter, + colorGray, + colorBlueLight, + colorPrimary, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography'; +import ModalSimple from '/imports/ui/components/common/modal/simple/component'; +import Button from '/imports/ui/components/common/button/component'; + +const UrlError = styled.div` + color: red; + padding: 1em 0 2.5em 0; + + ${({ animations }) => animations && ` + transition: 1s; + `} +`; + +const ExternalVideoModal = styled(ModalSimple)` + padding: 1rem; + min-height: 23rem; +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + padding: 0; + margin-right: auto; + margin-left: auto; + width: 100%; +`; + +const VideoUrl = styled.div` + margin: 0 ${borderSize} 0 ${borderSize}; + + & > label { + display: block; + } + + & > label input { + display: block; + margin: 10px 0 10px 0; + padding: 0.4em; + color: ${colorText}; + line-height: 2rem; + width: 100%; + font-family: inherit; + font-weight: inherit; + border: 1px solid ${colorGrayLighter}; + border-radius: ${borderRadius}; + + ${({ animations }) => animations && ` + transition: box-shadow .2s; + `} + + &:focus { + outline: none; + border-radius: ${borderSize}; + box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + } + } + + & > span { + font-weight: 600; + } +`; + +const ExternalVideoNote = styled.div` + color: ${colorGray}; + font-size: ${fontSizeSmall}; + font-style: italic; + padding-top: ${smPaddingY}; +`; + +const StartButton = styled(Button)` + display: flex; + align-self: center; + + &:focus { + outline: none !important; + } + + & > i { + color: #3c5764; + } + + margin: 0; + display: block; + position: absolute; + bottom: ${mdPaddingX}; +`; + +export default { + UrlError, + ExternalVideoModal, + Content, + VideoUrl, + ExternalVideoNote, + StartButton, +}; diff --git a/src/2.7.12/imports/ui/components/external-video-player/service.js b/src/2.7.12/imports/ui/components/external-video-player/service.js new file mode 100644 index 00000000..dd758449 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/service.js @@ -0,0 +1,103 @@ +import { ExternalVideoMeetings } from '/imports/api/meetings'; +import Auth from '/imports/ui/services/auth'; + +import { getStreamer } from '/imports/api/external-videos'; +import { makeCall } from '/imports/ui/services/api'; +import NotesService from '/imports/ui/components/notes/service'; + +import ReactPlayer from 'react-player'; + +import Panopto from './custom-players/panopto'; + +const YOUTUBE_SHORTS_REGEX = new RegExp(/^(?:https?:\/\/)?(?:www\.)?(youtube\.com\/shorts)\/.+$/); + +const isUrlValid = (url) => { + if (YOUTUBE_SHORTS_REGEX.test(url)) { + const shortsUrl = url.replace('shorts/', 'watch?v='); + + return /^https.*$/.test(shortsUrl) && (ReactPlayer.canPlay(shortsUrl) || Panopto.canPlay(shortsUrl)); + } + + return /^https.*$/.test(url) && (ReactPlayer.canPlay(url) || Panopto.canPlay(url)); +}; + +const startWatching = (url) => { + let externalVideoUrl = url; + + if (YOUTUBE_SHORTS_REGEX.test(url)) { + const shortsUrl = url.replace('shorts/', 'watch?v='); + externalVideoUrl = shortsUrl; + } else if (Panopto.canPlay(url)) { + externalVideoUrl = Panopto.getSocialUrl(url); + } + + // Close Shared Notes if open. + NotesService.pinSharedNotes(false); + + makeCall('startWatchingExternalVideo', externalVideoUrl); +}; + +const stopWatching = () => { + makeCall('stopWatchingExternalVideo'); +}; + +let lastMessage = null; + +const sendMessage = (event, data) => { + + // don't re-send repeated update messages + if (lastMessage && lastMessage.event === event + && event === 'playerUpdate' && lastMessage.time === data.time) { + return; + } + + // don't register to redis a viewer joined message + if (event === 'viewerJoined') { + return; + } + + lastMessage = { ...data, event }; + + // Use an integer for playing state + // 0: stopped 1: playing + // We might use more states in the future + data.state = data.state ? 1 : 0; + + makeCall('emitExternalVideoEvent', { status: event, playerStatus: data }); +}; + +const onMessage = (message, func) => { + const streamer = getStreamer(Auth.meetingID); + streamer.on(message, func); +}; + +const removeAllListeners = (eventType) => { + const streamer = getStreamer(Auth.meetingID); + streamer.removeAllListeners(eventType); +}; + +const getVideoUrl = () => { + const meetingId = Auth.meetingID; + const externalVideo = ExternalVideoMeetings + .findOne({ meetingId }, { fields: { externalVideoUrl: 1 } }); + + return externalVideo && externalVideo.externalVideoUrl; +}; + +// Convert state (Number) to playing (Boolean) +const getPlayingState = (state) => { + if (state === 1) return true; + + return false; +}; + +export { + sendMessage, + onMessage, + removeAllListeners, + getVideoUrl, + isUrlValid, + startWatching, + stopWatching, + getPlayingState, +}; diff --git a/src/2.7.12/imports/ui/components/external-video-player/styles.js b/src/2.7.12/imports/ui/components/external-video-player/styles.js new file mode 100644 index 00000000..fd056d09 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/styles.js @@ -0,0 +1,119 @@ +import styled from 'styled-components'; +import ReactPlayer from 'react-player'; + +const VideoPlayerWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + + ${({ fullscreen }) => fullscreen && ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 99; + `} +`; + +const AutoPlayWarning = styled.p` + position: absolute; + z-index: 100; + font-size: x-large; + color: white; + width: 100%; + background-color: rgba(6,23,42,0.5); + bottom: 20%; + vertical-align: middle; + text-align: center; + pointer-events: none; +`; + +const VideoPlayer = styled(ReactPlayer)` + width: 100%; + height: 100%; + & > iframe { + display: flex; + flex-flow: column; + flex-grow: 1; + flex-shrink: 1; + position: relative; + overflow-x: hidden; + overflow-y: auto; + border-style: none; + border-bottom: none; + } +`; + +const MobileControlsOverlay = styled.span` + position: absolute; + top:0; + left: 0; + width: 100%; + height: 100%; + background-color: transparent; +`; + +const HoverToolbar = styled.div` + ${({ toolbarStyle }) => toolbarStyle === 'hoverToolbar' && ` + display: none; + + :hover > & { + display: flex; + } + `} + + ${({ toolbarStyle }) => toolbarStyle === 'dontShowMobileHoverToolbar' && ` + display: none; + `} + + ${({ toolbarStyle }) => toolbarStyle === 'showMobileHoverToolbar' && ` + display: flex; + z-index: 2; + `} +`; + +const ProgressBar = styled.div` + position: absolute; + bottom: 0; + height: 5px; + width: 100%; + + background-color: transparent; +`; + +const Loaded = styled.div` + height: 100%; + background-color: gray; +`; + +const Played = styled.div` + height: 100%; + background-color: #DF2721; +`; + +const ButtonsWrapper = styled.div` + position: absolute; + right: auto; + left: 0; + bottom: 0; + top: 0; + display: flex; + + [dir="rtl"] & { + right: 0; + left : auto; + } +`; + +export default { + VideoPlayerWrapper, + AutoPlayWarning, + VideoPlayer, + MobileControlsOverlay, + HoverToolbar, + ProgressBar, + Loaded, + Played, + ButtonsWrapper, +}; diff --git a/src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx new file mode 100644 index 00000000..e61329fa --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx @@ -0,0 +1,25 @@ +import React, { Component } from 'react'; +import Styled from './styles'; + +class Subtitles extends Component { + constructor(props) { + super(props); + } + + render() { + const { toggleSubtitle, label } = this.props; + return ( + + toggleSubtitle()} + label={label} + hideLabel + /> + + ); + } +} + +export default Subtitles; diff --git a/src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js b/src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js new file mode 100644 index 00000000..e9f491f1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +import Button from '/imports/ui/components/common/button/component'; + +import { colorTransparent } from '/imports/ui/stylesheets/styled-components/palette'; + +const SubtitlesWrapper = styled.div` + background-color: ${colorTransparent}; + cursor: pointer; + border: 0; + z-index: 2; + margin: 2px; + + [dir="rtl"] & { + right: 0; + left : auto; + } +`; + +const SubtitlesButton = styled(Button)` + &, + &:active, + &:hover, + &:focus { + border: none !important; + + i { + border: none !important; + background-color: ${colorTransparent} !important; + } + } + + padding: 5px; + + &:hover { + border: 0; + } + + i { + font-size: 1rem; + } +`; + +export default { + SubtitlesWrapper, + SubtitlesButton, +}; diff --git a/src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx new file mode 100644 index 00000000..9c5e874c --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import Styled from './styles'; + +class VolumeSlider extends Component { + constructor(props) { + super(props); + + this.state = { + volume: props.volume, + muted: props.muted, + }; + + this.handleOnChange = this.handleOnChange.bind(this); + this.getVolumeIcon = this.getVolumeIcon.bind(this); + this.setMuted = this.setMuted.bind(this); + } + + componentDidUpdate(prevProp) { + const { volume, muted } = this.props; + const { volume: prevVolume, muted: prevMuted } = prevProp; + + if (prevVolume !== volume) { + this.handleOnChange(volume); + } + + if (prevMuted !== muted) { + this.setMuted(muted); + } + } + + handleOnChange(volume) { + const { onVolumeChanged } = this.props; + onVolumeChanged(volume); + + this.setState({ volume }, () => { + const { volume: stateVolume, muted } = this.state; + if (muted && stateVolume > 0) { // unmute if volume was raised during mute + this.setMuted(false); + } else if (stateVolume <= 0) { // mute if volume is turned to 0 + this.setMuted(true); + } + }); + } + + setMuted(muted) { + const { onMuted } = this.props; + this.setState({ muted }, () => onMuted(muted)); + } + + getVolumeIcon() { + const { muted, volume } = this.state; + + if (muted || volume <= 0) return 'volume_off'; + + if (volume <= 0.25) return 'volume_mute'; + + if (volume <= 0.75) return 'volume_down'; + + return 'volume_up'; + } + + render() { + const { muted, volume } = this.state; + const { hideVolume } = this.props; + + if (hideVolume) { + return null; + } + + return ( + + this.setMuted(!muted)}> + + + this.handleOnChange(e.target.valueAsNumber)} + /> + + ); + } +} + +export default VolumeSlider; diff --git a/src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js new file mode 100644 index 00000000..0a70e8fd --- /dev/null +++ b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js @@ -0,0 +1,37 @@ +import styled from 'styled-components'; + +const Slider = styled.div` + width: 0.9em; + display: flex; + position: absolute; + bottom: 2.5em; + left: 1em; + padding: 0.25rem 0.5rem; + min-width: 200px; + background-color: rgba(0,0,0,0.5); + border-radius: 32px; + + i { + color: white; + transition: 0.5s; + font-size: 200%; + cursor: pointer; + } + z-index: 2; +`; + +const Volume = styled.span` + margin-right: 0.5em; + cursor: pointer; +`; + +const VolumeSlider = styled.input` + width: 100%; + cursor: pointer; +`; + +export default { + Slider, + Volume, + VolumeSlider, +}; diff --git a/src/2.7.12/imports/ui/components/join-handler/component.jsx b/src/2.7.12/imports/ui/components/join-handler/component.jsx new file mode 100644 index 00000000..6345e6da --- /dev/null +++ b/src/2.7.12/imports/ui/components/join-handler/component.jsx @@ -0,0 +1,267 @@ +import React, { Component } from 'react'; +import { Session } from 'meteor/session'; +import PropTypes from 'prop-types'; +import SanitizeHTML from 'sanitize-html'; +import Auth from '/imports/ui/services/auth'; +import { setCustomLogoUrl, setModeratorOnlyMessage } from '/imports/ui/components/user-list/service'; +import { makeCall } from '/imports/ui/services/api'; +import logger from '/imports/startup/client/logger'; +import LoadingScreen from '/imports/ui/components/common/loading-screen/component'; +import { CurrentUser } from '/imports/api/users'; +import Settings from '/imports/ui/services/settings'; +import { updateSettings } from '/imports/ui/components/settings/service'; +import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums'; + +const propTypes = { + children: PropTypes.element.isRequired, +}; + +class JoinHandler extends Component { + static setError(codeError) { + if (codeError) Session.set('codeError', codeError); + } + + constructor(props) { + super(props); + this.fetchToken = this.fetchToken.bind(this); + + this.state = { + joined: false, + hasAlreadyJoined: false, + }; + } + + componentDidMount() { + this._isMounted = true; + + if (!this.firstJoinTime) { + this.firstJoinTime = new Date(); + } + Tracker.autorun((c) => { + const { + connected, + status, + } = Meteor.status(); + const { hasAlreadyJoined } = this.state; + if (status === 'connecting' && !hasAlreadyJoined) { + this.setState({ joined: false }); + } + + logger.debug(`Initial connection status change. status: ${status}, connected: ${connected}`); + if (connected) { + const msToConnect = (new Date() - this.firstJoinTime) / 1000; + const secondsToConnect = parseFloat(msToConnect).toFixed(2); + + logger.info({ + logCode: 'joinhandler_component_initial_connection_time', + extraInfo: { + attemptForUserInfo: Auth.fullInfo, + timeToConnect: secondsToConnect, + }, + }, `Connection to Meteor took ${secondsToConnect}s`); + + this.firstJoinTime = undefined; + this.fetchToken(); + } else if (status === 'failed') { + c.stop(); + + const msToConnect = (new Date() - this.firstJoinTime) / 1000; + const secondsToConnect = parseFloat(msToConnect).toFixed(2); + logger.info({ + logCode: 'joinhandler_component_initial_connection_failed', + extraInfo: { + attemptForUserInfo: Auth.fullInfo, + timeToConnect: secondsToConnect, + }, + }, `Connection to Meteor failed, took ${secondsToConnect}s`); + + JoinHandler.setError('400'); + Session.set('errorMessageDescription', 'Failed to connect to server'); + this.firstJoinTime = undefined; + } + }); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async fetchToken() { + const { hasAlreadyJoined } = this.state; + const APP = Meteor.settings.public.app; + if (!this._isMounted) return; + + const urlParams = new URLSearchParams(window.location.search); + const sessionToken = urlParams.get('sessionToken'); + + if (!sessionToken) { + JoinHandler.setError('400'); + Session.set('errorMessageDescription', 'Session token was not provided'); + } + + // Old credentials stored in memory were being used when joining a new meeting + if (!hasAlreadyJoined) { + Auth.clearCredentials(); + } + const logUserInfo = () => { + const userInfo = window.navigator; + + // Browser information is sent once on startup + // Sent here instead of Meteor.startup, as the + // user might not be validated by then, thus user's data + // would not be sent with this information + const clientInfo = { + language: userInfo.language, + userAgent: userInfo.userAgent, + screenSize: { width: window.screen.width, height: window.screen.height }, + windowSize: { width: window.innerWidth, height: window.innerHeight }, + bbbVersion: Meteor.settings.public.app.bbbServerVersion, + location: window.location.href, + }; + + logger.info({ + logCode: 'joinhandler_component_clientinfo', + extraInfo: { clientInfo }, + }, + 'Log information about the client'); + }; + + const setAuth = (resp) => { + const { + meetingID, internalUserID, authToken, logoutUrl, + fullname, externUserID, confname, + } = resp; + return new Promise((resolve) => { + Auth.set( + meetingID, internalUserID, authToken, logoutUrl, + sessionToken, fullname, externUserID, confname, + ); + resolve(resp); + }); + }; + + const setLogoutURL = (url) => { + Auth.logoutURL = url; + return true; + }; + + const setLogoURL = (resp) => { + setCustomLogoUrl(resp.customLogoURL); + return resp; + }; + + const setModOnlyMessage = (resp) => { + if (resp && resp.modOnlyMessage) { + const sanitizedModOnlyText = SanitizeHTML(resp.modOnlyMessage, { + allowedTags: ['a', 'b', 'br', 'i', 'img', 'li', 'small', 'span', 'strong', 'u', 'ul'], + allowedAttributes: { + a: ['href', 'name', 'target'], + img: ['src', 'width', 'height'], + }, + allowedSchemes: ['https'], + }); + setModeratorOnlyMessage(sanitizedModOnlyText); + } + return resp; + }; + + const setCustomData = (resp) => { + const { customdata } = resp; + + return new Promise((resolve) => { + if (customdata.length) { + makeCall('addUserSettings', customdata).then((r) => resolve(r)); + } + resolve(true); + }); + }; + + const setBannerProps = (resp) => { + Session.set('bannerText', resp.bannerText); + Session.set('bannerColor', resp.bannerColor); + }; + + const setUserDefaultLayout = (response) => { + const settings = { + application: { + ...Settings.application, + selectedLayout: LAYOUT_TYPE[response.defaultLayout] || 'custom', + }, + }; + updateSettings(settings); + }; + + // use enter api to get params for the client + const url = `${APP.bbbWebBase}/api/enter?sessionToken=${sessionToken}`; + const fetchContent = await fetch(url, { credentials: 'include' }); + const parseToJson = await fetchContent.json(); + const { response } = parseToJson; + + setLogoutURL(response.logoutUrl); + logUserInfo(); + + if (response.returncode !== 'FAILED') { + await setAuth(response); + + setBannerProps(response); + setLogoURL(response); + setModOnlyMessage(response); + + Tracker.autorun(async (cd) => { + const user = CurrentUser + .findOne({ userId: Auth.userID, approved: true }, { fields: { _id: 1 } }); + if (user) { + await setCustomData(response); + setUserDefaultLayout(response); + cd.stop(); + } + }); + + logger.info({ + logCode: 'joinhandler_component_joinroutehandler_success', + extraInfo: { + response, + }, + }, 'User successfully went through main.joinRouteHandler'); + } else { + + if(['missingSession','meetingForciblyEnded','notFound'].includes(response.messageKey)) { + JoinHandler.setError('410'); + Session.set('errorMessageDescription', 'meeting_ended'); + } else if(response.messageKey == "guestDeny") { + JoinHandler.setError('401'); + Session.set('errorMessageDescription', 'guest_deny'); + } else if(response.messageKey == "maxParticipantsReached") { + JoinHandler.setError('401'); + Session.set('errorMessageDescription', 'max_participants_reason'); + } else { + JoinHandler.setError('401'); + Session.set('errorMessageDescription', response.message); + } + + logger.error({ + logCode: 'joinhandler_component_joinroutehandler_error', + extraInfo: { + response, + error: new Error(response.message), + }, + }, 'User faced an error on main.joinRouteHandler.'); + } + this.setState({ + joined: true, + hasAlreadyJoined: true, + }); + } + + render() { + const { children } = this.props; + const { joined } = this.state; + return joined + ? children + : (); + } +} + +export default JoinHandler; + +JoinHandler.propTypes = propTypes; diff --git a/src/2.7.12/imports/ui/components/layout/context.jsx b/src/2.7.12/imports/ui/components/layout/context.jsx new file mode 100644 index 00000000..d02693ed --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/context.jsx @@ -0,0 +1,1243 @@ +import React, { useReducer } from 'react'; +import { createContext, useContextSelector } from 'use-context-selector'; +import PropTypes from 'prop-types'; +import { ACTIONS } from '/imports/ui/components/layout/enums'; +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; +import { INITIAL_INPUT_STATE, INITIAL_OUTPUT_STATE } from './initState'; + +// variable to debug in console log +const debug = false; + +const debugActions = (action, value) => { + const baseStyles = [ + 'color: #fff', + 'background-color: #d64541', + 'padding: 2px 4px', + 'border-radius: 2px', + ].join(';'); + return debug && console.log(`%c${action}`, baseStyles, value); +}; + +const providerPropTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, +}; + +const LayoutContextSelector = createContext(); + +const initState = { + deviceType: null, + isRTL: false, + layoutType: DEFAULT_VALUES.layoutType, + fontSize: DEFAULT_VALUES.fontSize, + idChatOpen: DEFAULT_VALUES.idChatOpen, + fullscreen: { + element: '', + group: '', + }, + input: INITIAL_INPUT_STATE, + output: INITIAL_OUTPUT_STATE, +}; + +const reducer = (state, action) => { + debugActions(action.type, action.value); + switch (action.type) { + + case ACTIONS.SET_FOCUSED_CAMERA_ID: { + const { cameraDock } = state.input; + const { focusedId } = cameraDock; + + if (focusedId === action.value) return state; + + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + focusedId: action.value, + }, + }, + }; + } + + case ACTIONS.SET_LAYOUT_INPUT: { + if (state.input === action.value) return state; + return { + ...state, + input: action.value, + }; + } + + case ACTIONS.SET_AUTO_ARRANGE_LAYOUT: { + const { autoarrAngeLayout } = state.input; + if (autoarrAngeLayout === action.value) return state; + return { + ...state, + input: { + ...state.input, + autoarrAngeLayout: action.value, + }, + }; + } + + case ACTIONS.SET_IS_RTL: { + const { isRTL } = state; + if (isRTL === action.value) return state; + return { + ...state, + isRTL: action.value, + }; + } + + // LAYOUT TYPE + // using to load a diferent layout manager + case ACTIONS.SET_LAYOUT_TYPE: { + const { layoutType } = state.input; + if (layoutType === action.value) return state; + return { + ...state, + layoutType: action.value, + }; + } + + // FONT SIZE + case ACTIONS.SET_FONT_SIZE: { + const { fontSize } = state; + if (fontSize === action.value) return state; + return { + ...state, + fontSize: action.value, + }; + } + + // ID CHAT open in sidebar content panel + case ACTIONS.SET_ID_CHAT_OPEN: { + if (state.idChatOpen === action.value) return state; + return { + ...state, + idChatOpen: action.value, + }; + } + + // DEVICE + case ACTIONS.SET_DEVICE_TYPE: { + const { deviceType } = state; + if (deviceType === action.value) return state; + return { + ...state, + deviceType: action.value, + }; + } + + // BROWSER + case ACTIONS.SET_BROWSER_SIZE: { + const { width, height } = action.value; + const { browser } = state.input; + if (browser.width === width + && browser.height === height) { + return state; + } + return { + ...state, + input: { + ...state.input, + browser: { + width, + height, + }, + }, + }; + } + + // BANNER BAR + case ACTIONS.SET_HAS_BANNER_BAR: { + const { bannerBar } = state.input; + if (bannerBar.hasBanner === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + bannerBar: { + ...bannerBar, + hasBanner: action.value, + }, + }, + }; + } + + // NOTIFICATIONS BAR + case ACTIONS.SET_HAS_NOTIFICATIONS_BAR: { + const { notificationsBar } = state.input; + if (notificationsBar.hasNotification === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + notificationsBar: { + ...notificationsBar, + hasNotification: action.value, + }, + }, + }; + } + + // NAV BAR + case ACTIONS.SET_NAVBAR_OUTPUT: { + const { + display, width, height, top, left, tabOrder, zIndex, + } = action.value; + const { navBar } = state.output; + if (navBar.display === display + && navBar.width === width + && navBar.height === height + && navBar.top === top + && navBar.left === left + && navBar.zIndex === zIndex + && navBar.tabOrder === tabOrder) { + return state; + } + return { + ...state, + output: { + ...state.output, + navBar: { + ...navBar, + display, + width, + height, + top, + left, + tabOrder, + zIndex, + }, + }, + }; + } + + // ACTION BAR + case ACTIONS.SET_ACTIONBAR_OUTPUT: { + const { + display, width, height, innerHeight, top, left, padding, tabOrder, zIndex, + } = action.value; + const { actionBar } = state.output; + if (actionBar.display === display + && actionBar.width === width + && actionBar.height === height + && actionBar.innerHeight === innerHeight + && actionBar.top === top + && actionBar.left === left + && actionBar.padding === padding + && actionBar.zIndex === zIndex + && actionBar.tabOrder === tabOrder) { + return state; + } + return { + ...state, + output: { + ...state.output, + actionBar: { + ...actionBar, + display, + width, + height, + innerHeight, + top, + left, + padding, + tabOrder, + zIndex, + }, + }, + }; + } + + // CAPTIONS + case ACTIONS.SET_CAPTIONS_OUTPUT: { + const { + left, right, maxWidth, + } = action.value; + const { captions } = state.output; + if (captions.left === left + && captions.right === right + && captions.maxWidth === maxWidth) { + return state; + } + return { + ...state, + output: { + ...state.output, + captions: { + ...captions, + left, + right, + maxWidth, + }, + }, + }; + } + + // SIDEBAR NAVIGATION + case ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN: { + const { sidebarNavigation } = state.input; + if (sidebarNavigation.isOpen === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + sidebarNavigation: { + ...sidebarNavigation, + isOpen: action.value, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_NAVIGATION_PANEL: { + const { sidebarNavigation } = state.input; + if (sidebarNavigation.sidebarNavPanel === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + sidebarNavigation: { + ...sidebarNavigation, + sidebarNavPanel: action.value, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_NAVIGATION_SIZE: { + const { width, browserWidth } = action.value; + const { sidebarNavigation } = state.input; + if (sidebarNavigation.width === width + && sidebarNavigation.browserWidth === browserWidth) { + return state; + } + return { + ...state, + input: { + ...state.input, + sidebarNavigation: { + ...sidebarNavigation, + width, + browserWidth, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT: { + const { + display, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + tabOrder, + isResizable, + zIndex, + } = action.value; + const { sidebarNavigation } = state.output; + if (sidebarNavigation.display === display + && sidebarNavigation.minWidth === minWidth + && sidebarNavigation.maxWidth === maxWidth + && sidebarNavigation.width === width + && sidebarNavigation.minHeight === minHeight + && sidebarNavigation.height === height + && sidebarNavigation.maxHeight === maxHeight + && sidebarNavigation.top === top + && sidebarNavigation.left === left + && sidebarNavigation.right === right + && sidebarNavigation.tabOrder === tabOrder + && sidebarNavigation.zIndex === zIndex + && sidebarNavigation.isResizable === isResizable) { + return state; + } + return { + ...state, + output: { + ...state.output, + sidebarNavigation: { + ...sidebarNavigation, + display, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + tabOrder, + isResizable, + zIndex, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE: { + const { + top, right, bottom, left, + } = action.value; + const { sidebarNavigation } = state.output; + if (sidebarNavigation.resizableEdge.top === top + && sidebarNavigation.resizableEdge.right === right + && sidebarNavigation.resizableEdge.bottom === bottom + && sidebarNavigation.resizableEdge.left === left) { + return state; + } + return { + ...state, + output: { + ...state.output, + sidebarNavigation: { + ...sidebarNavigation, + resizableEdge: { + top, + right, + bottom, + left, + }, + }, + }, + }; + } + + // SIDEBAR CONTENT + case ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN: { + const { sidebarContent, sidebarNavigation } = state.input; + if (sidebarContent.isOpen === action.value) { + return state; + } + // When opening content sidebar, the navigation sidebar should be opened as well + if (action.value === true) sidebarNavigation.isOpen = true; + return { + ...state, + input: { + ...state.input, + sidebarNavigation, + sidebarContent: { + ...sidebarContent, + isOpen: action.value, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_CONTENT_PANEL: { + const { sidebarContent } = state.input; + if (sidebarContent.sidebarContentPanel === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + sidebarContent: { + ...sidebarContent, + sidebarContentPanel: action.value, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_CONTENT_SIZE: { + const { + width, + browserWidth, + height, + browserHeight, + } = action.value; + const { sidebarContent } = state.input; + if (sidebarContent.width === width + && sidebarContent.browserWidth === browserWidth + && sidebarContent.height === height + && sidebarContent.browserHeight === browserHeight) { + return state; + } + return { + ...state, + input: { + ...state.input, + sidebarContent: { + ...sidebarContent, + width, + browserWidth, + height, + browserHeight, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT: { + const { + display, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + currentPanelType, + tabOrder, + isResizable, + zIndex, + } = action.value; + const { sidebarContent } = state.output; + if (sidebarContent.display === display + && sidebarContent.minWidth === minWidth + && sidebarContent.width === width + && sidebarContent.maxWidth === maxWidth + && sidebarContent.minHeight === minHeight + && sidebarContent.height === height + && sidebarContent.maxHeight === maxHeight + && sidebarContent.top === top + && sidebarContent.left === left + && sidebarContent.right === right + && sidebarContent.tabOrder === tabOrder + && sidebarContent.zIndex === zIndex + && sidebarContent.isResizable === isResizable) { + return state; + } + return { + ...state, + output: { + ...state.output, + sidebarContent: { + ...sidebarContent, + display, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + currentPanelType, + tabOrder, + isResizable, + zIndex, + }, + }, + }; + } + case ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE: { + const { + top, right, bottom, left, + } = action.value; + const { sidebarContent } = state.output; + if (sidebarContent.resizableEdge.top === top + && sidebarContent.resizableEdge.right === right + && sidebarContent.resizableEdge.bottom === bottom + && sidebarContent.resizableEdge.left === left) { + return state; + } + return { + ...state, + output: { + ...state.output, + sidebarContent: { + ...sidebarContent, + resizableEdge: { + top, + right, + bottom, + left, + }, + }, + }, + }; + } + + // MEDIA + case ACTIONS.SET_MEDIA_AREA_SIZE: { + const { width, height } = action.value; + const { mediaArea } = state.output; + if (mediaArea.width === width + && mediaArea.height === height) { + return state; + } + return { + ...state, + output: { + ...state.output, + mediaArea: { + ...mediaArea, + width, + height, + }, + }, + }; + } + + // WEBCAMS + case ACTIONS.SET_NUM_CAMERAS: { + const { cameraDock } = state.input; + if (cameraDock.numCameras === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + numCameras: action.value, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_IS_DRAGGING: { + const { cameraDock } = state.input; + if (cameraDock.isDragging === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + isDragging: action.value, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_IS_RESIZING: { + const { cameraDock } = state.input; + if (cameraDock.isResizing === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + isResizing: action.value, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_POSITION: { + const { cameraDock } = state.input; + if (cameraDock.position === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + position: action.value, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_SIZE: { + const { + width, height, browserWidth, browserHeight, + } = action.value; + const { cameraDock } = state.input; + if (cameraDock.width === width + && cameraDock.height === height + && cameraDock.browserWidth === browserWidth + && cameraDock.browserHeight === browserHeight) { + return state; + } + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + width, + height, + browserWidth, + browserHeight, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_OPTIMAL_GRID_SIZE: { + const { width, height } = action.value; + const { cameraDock } = state.input; + const { cameraOptimalGridSize } = cameraDock; + if (cameraOptimalGridSize.width === width + && cameraOptimalGridSize.height === height) { + return state; + } + return { + ...state, + input: { + ...state.input, + cameraDock: { + ...cameraDock, + cameraOptimalGridSize: { + width, + height, + }, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_OUTPUT: { + const { + display, + position, + minWidth, + width, + maxWidth, + presenterMaxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + tabOrder, + isDraggable, + resizableEdge, + zIndex, + focusedId, + } = action.value; + const { cameraDock } = state.output; + if (cameraDock.display === display + && cameraDock.position === position + && cameraDock.width === width + && cameraDock.maxWidth === maxWidth + && cameraDock.presenterMaxWidth === presenterMaxWidth + && cameraDock.height === height + && cameraDock.maxHeight === maxHeight + && cameraDock.top === top + && cameraDock.left === left + && cameraDock.right === right + && cameraDock.tabOrder === tabOrder + && cameraDock.isDraggable === isDraggable + && cameraDock.zIndex === zIndex + && cameraDock.resizableEdge === resizableEdge + && cameraDock.focusedId === focusedId) { + return state; + } + return { + ...state, + output: { + ...state.output, + cameraDock: { + ...cameraDock, + display, + position, + minWidth, + width, + maxWidth, + presenterMaxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + tabOrder, + isDraggable, + resizableEdge, + zIndex, + focusedId, + }, + }, + }; + } + case ACTIONS.SET_CAMERA_DOCK_IS_DRAGGABLE: { + const { cameraDock } = state.output; + if (cameraDock.isDraggable === action.value) { + return state; + } + return { + ...state, + output: { + ...state.output, + cameraDock: { + ...cameraDock, + isDraggable: action.value, + }, + }, + }; + } + + // WEBCAMS DROP AREAS + case ACTIONS.SET_DROP_AREAS: { + const { dropZoneAreas } = state.output; + if (dropZoneAreas === action.value) { + return state; + } + return { + ...state, + output: { + ...state.output, + dropZoneAreas: action.value, + }, + }; + } + + // PRESENTATION + case ACTIONS.SET_PRESENTATION_IS_OPEN: { + const { presentation } = state.input; + if (presentation.isOpen === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + presentation: { + ...presentation, + isOpen: action.value, + }, + }, + }; + } + case ACTIONS.SET_PRESENTATION_SLIDES_LENGTH: { + const { presentation } = state.input; + if (presentation.slidesLength === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + presentation: { + ...presentation, + slidesLength: action.value, + }, + }, + }; + } + case ACTIONS.SET_PRESENTATION_NUM_CURRENT_SLIDE: { + const { presentation } = state.input; + if (presentation.currentSlide.num === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + presentation: { + ...presentation, + currentSlide: { + ...presentation.currentSlide, + num: action.value, + }, + }, + }, + }; + } + case ACTIONS.SET_PRESENTATION_CURRENT_SLIDE_SIZE: { + const { width, height } = action.value; + const { presentation } = state.input; + const { currentSlide } = presentation; + if (currentSlide.size.width === width + && currentSlide.size.height === height) { + return state; + } + return { + ...state, + input: { + ...state.input, + presentation: { + ...presentation, + currentSlide: { + ...currentSlide, + size: { + width, + height, + }, + }, + }, + }, + }; + } + case ACTIONS.SET_PRESENTATION_RESIZABLE_EDGE: { + const { + top, right, bottom, left, + } = action.value; + const { presentation } = state.output; + const { resizableEdge } = presentation; + if (resizableEdge.top === top + && resizableEdge.right === right + && resizableEdge.bottom === bottom + && resizableEdge.left === left) { + return state; + } + return { + ...state, + output: { + ...state.output, + presentation: { + ...presentation, + resizableEdge: { + top, + right, + bottom, + left, + }, + }, + }, + }; + } + case ACTIONS.SET_PRESENTATION_SIZE: { + const { + width, height, browserWidth, browserHeight, + } = action.value; + const { presentation } = state.input; + if (presentation.width === width + && presentation.height === height + && presentation.browserWidth === browserWidth + && presentation.browserHeight === browserHeight) { + return state; + } + return { + ...state, + input: { + ...state.input, + presentation: { + ...presentation, + width, + height, + browserWidth, + browserHeight, + }, + }, + }; + } + case ACTIONS.SET_PRESENTATION_OUTPUT: { + const { + display, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + tabOrder, + isResizable, + zIndex, + } = action.value; + const { presentation } = state.output; + if (presentation.display === display + && presentation.minWidth === minWidth + && presentation.width === width + && presentation.maxWidth === maxWidth + && presentation.minHeight === minHeight + && presentation.height === height + && presentation.maxHeight === maxHeight + && presentation.top === top + && presentation.left === left + && presentation.right === right + && presentation.tabOrder === tabOrder + && presentation.zIndex === zIndex + && presentation.isResizable === isResizable) { + return state; + } + return { + ...state, + output: { + ...state.output, + presentation: { + ...presentation, + display, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + top, + left, + right, + tabOrder, + isResizable, + zIndex, + }, + }, + }; + } + + // FULLSCREEN + case ACTIONS.SET_FULLSCREEN_ELEMENT: { + const { fullscreen } = state; + if (fullscreen.element === action.value.element + && fullscreen.group === action.value.group) { + return state; + } + return { + ...state, + fullscreen: { + element: action.value.element, + group: action.value.group, + }, + }; + } + + // SCREEN SHARE + case ACTIONS.SET_HAS_SCREEN_SHARE: { + const { screenShare } = state.input; + if (screenShare.hasScreenShare === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + screenShare: { + ...screenShare, + hasScreenShare: action.value, + }, + }, + }; + } + case ACTIONS.SET_SCREEN_SHARE_SIZE: { + const { + width, height, browserWidth, browserHeight, + } = action.value; + const { screenShare } = state.input; + if (screenShare.width === width + && screenShare.height === height + && screenShare.browserWidth === browserWidth + && screenShare.browserHeight === browserHeight) { + return state; + } + return { + ...state, + input: { + ...state.input, + screenShare: { + ...screenShare, + width, + height, + browserWidth, + browserHeight, + }, + }, + }; + } + case ACTIONS.SET_SCREEN_SHARE_OUTPUT: { + const { + width, + height, + top, + left, + right, + zIndex, + } = action.value; + const { screenShare } = state.output; + if (screenShare.width === width + && screenShare.height === height + && screenShare.top === top + && screenShare.left === left + && screenShare.right === right + && screenShare.zIndex === zIndex) { + return state; + } + return { + ...state, + output: { + ...state.output, + screenShare: { + ...screenShare, + width, + height, + top, + left, + right, + zIndex, + }, + }, + }; + } + + // EXTERNAL VIDEO + case ACTIONS.SET_HAS_EXTERNAL_VIDEO: { + const { externalVideo } = state.input; + if (externalVideo.hasExternalVideo === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + externalVideo: { + ...externalVideo, + hasExternalVideo: action.value, + }, + }, + }; + } + case ACTIONS.SET_EXTERNAL_VIDEO_SIZE: { + const { + width, height, browserWidth, browserHeight, + } = action.value; + const { externalVideo } = state.input; + if (externalVideo.width === width + && externalVideo.height === height + && externalVideo.browserWidth === browserWidth + && externalVideo.browserHeight === browserHeight) { + return state; + } + return { + ...state, + input: { + ...state.input, + externalVideo: { + ...externalVideo, + width, + height, + browserWidth, + browserHeight, + }, + }, + }; + } + case ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT: { + const { + width, + height, + top, + left, + right, + } = action.value; + const { externalVideo } = state.output; + if (externalVideo.width === width + && externalVideo.height === height + && externalVideo.top === top + && externalVideo.left === left + && externalVideo.right === right) { + return state; + } + return { + ...state, + output: { + ...state.output, + externalVideo: { + ...externalVideo, + width, + height, + top, + left, + right, + }, + }, + }; + } + + // NOTES + case ACTIONS.SET_SHARED_NOTES_OUTPUT: { + const { + width, + height, + top, + left, + right, + } = action.value; + const { sharedNotes } = state.output; + if (sharedNotes.width === width + && sharedNotes.height === height + && sharedNotes.top === top + && sharedNotes.left === left + && sharedNotes.right === right) { + return state; + } + return { + ...state, + output: { + ...state.output, + sharedNotes: { + ...sharedNotes, + width, + height, + top, + left, + right, + }, + }, + }; + } + case ACTIONS.SET_NOTES_IS_PINNED: { + const { sharedNotes } = state.input; + if (sharedNotes.isPinned === action.value) { + return state; + } + return { + ...state, + input: { + ...state.input, + sharedNotes: { + ...sharedNotes, + isPinned: action.value, + }, + }, + }; + } + default: { + throw new Error('Unexpected action'); + } + } +}; + +const LayoutContextProvider = (props) => { + const [layoutContextState, layoutContextDispatch] = useReducer(reducer, initState); + const { children } = props; + return ( + + {children} + + ); +}; +LayoutContextProvider.propTypes = providerPropTypes; + +const layoutSelect = (selector) => { + return useContextSelector(LayoutContextSelector, layout => selector(layout[0])); +}; +const layoutSelectInput = (selector) => { + return useContextSelector(LayoutContextSelector, layout => selector(layout[0].input)); +}; +const layoutSelectOutput = (selector) => { + return useContextSelector(LayoutContextSelector, layout => selector(layout[0].output)); +}; +const layoutDispatch = () => { + return useContextSelector(LayoutContextSelector, layout => layout[1]); +}; + +export { + LayoutContextProvider, + layoutSelect, + layoutSelectInput, + layoutSelectOutput, + layoutDispatch, +} diff --git a/src/2.7.12/imports/ui/components/layout/defaultValues.js b/src/2.7.12/imports/ui/components/layout/defaultValues.js new file mode 100644 index 00000000..eb8743e0 --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/defaultValues.js @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; +import { LAYOUT_TYPE, CAMERADOCK_POSITION, PANELS } from './enums'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; + +const DEFAULT_VALUES = { + layoutType: LAYOUT_TYPE.CUSTOM_LAYOUT, + panelType: 'chat', + idChatOpen: PUBLIC_CHAT_ID, + fontSize: 16, + + cameraPosition: CAMERADOCK_POSITION.CONTENT_TOP, + cameraDockTabOrder: 4, + cameraDockMinHeight: 120, + cameraDockMinWidth: 120, + camerasMargin: 10, + captionsMargin: 10, + + presentationTabOrder: 5, + presentationMinHeight: 220, + presentationToolbarMinWidth: 430, + + bannerHeight: 34, + + navBarHeight: 85, + navBarTop: 0, + navBarTabOrder: 3, + + actionBarHeight: 42, + actionBarPadding: 11.2, + actionBarTabOrder: 6, + + sidebarNavMaxWidth: 240, + sidebarNavMinWidth: 70, + sidebarNavHeight: '100%', + sidebarNavTop: 0, + sidebarNavLeft: 0, + sidebarNavTabOrder: 1, + sidebarNavPanel: PANELS.USERLIST, + + sidebarContentMaxWidth: 800, + sidebarContentMinWidth: 70, + sidebarContentMinHeight: 200, + sidebarContentHeight: '100%', + sidebarContentTop: 0, + sidebarContentTabOrder: 2, + sidebarContentPanel: PANELS.NONE, +}; + +export default DEFAULT_VALUES; +export { + LAYOUT_TYPE, + CAMERADOCK_POSITION, +}; diff --git a/src/2.7.12/imports/ui/components/layout/enums.js b/src/2.7.12/imports/ui/components/layout/enums.js new file mode 100644 index 00000000..4f9f5c4a --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/enums.js @@ -0,0 +1,112 @@ +export const LAYOUT_TYPE = { + CUSTOM_LAYOUT: 'custom', + SMART_LAYOUT: 'smart', + PRESENTATION_FOCUS: 'presentationFocus', + VIDEO_FOCUS: 'videoFocus', +}; + +export const DEVICE_TYPE = { + MOBILE: 'mobile', + TABLET_PORTRAIT: 'tablet_portrait', + TABLET_LANDSCAPE: 'tablet_landscape', + TABLET: 'tablet', + DESKTOP: 'desktop', +}; + +export const SMALL_VIEWPORT_BREAKPOINT = 640; + +export const CAMERADOCK_POSITION = { + CONTENT_TOP: 'contentTop', + CONTENT_RIGHT: 'contentRight', + CONTENT_BOTTOM: 'contentBottom', + CONTENT_LEFT: 'contentLeft', + SIDEBAR_CONTENT_BOTTOM: 'sidebarContentBottom', +}; + +export const ACTIONS = { + SET_AUTO_ARRANGE_LAYOUT: 'setAutoArrangeLayout', + SET_IS_RTL: 'setIsRTL', + SET_LAYOUT_TYPE: 'setLayoutType', + SET_DEVICE_TYPE: 'setDeviceType', + SET_FONT_SIZE: 'setFontSize', + + SET_FOCUSED_CAMERA_ID: 'focusedId', + + SET_LAYOUT_INPUT: 'setLayoutInput', + + SET_SIDEBAR_NAVIGATION_PANEL: 'setSidebarNavigationPanel', + SET_SIDEBAR_CONTENT_PANEL: 'setSidebarcontentPanel', + + SET_ID_CHAT_OPEN: 'setIdChatOpen', + + SET_BROWSER_SIZE: 'setBrowserSize', + + SET_HAS_BANNER_BAR: 'setHasBannerBar', + SET_HAS_NOTIFICATIONS_BAR: 'setHasNotificationsBar', + + SET_NAVBAR_OUTPUT: 'setNavBarOutput', + + SET_ACTIONBAR_OUTPUT: 'setActionBarOutput', + + SET_SIDEBAR_NAVIGATION_IS_OPEN: 'setSidebarNavigationIsOpen', + SET_SIDEBAR_NAVIGATION_SIZE: 'setSidebarNavigationSize', + SET_SIDEBAR_NAVIGATION_OUTPUT: 'setSidebarNavigationOutput', + SET_SIDEBAR_NAVIGATION_IS_RESIZABLE: 'setSidebarNavigationIsResizable', + SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE: 'setSidebarNavigationResizableEdge', + + SET_SIDEBAR_CONTENT_IS_OPEN: 'setSidebarContentIsOpen', + SET_SIDEBAR_CONTENT_SIZE: 'setSidebarContentSize', + SET_SIDEBAR_CONTENT_PANEL_TYPE: 'setSidebarContentPanelType', + SET_SIDEBAR_CONTENT_OUTPUT: 'setSidebarContentOutput', + SET_SIDEBAR_CONTENT_IS_RESIZABLE: 'setSidebarContentIsResizable', + SET_SIDEBAR_CONTENT_RESIZABLE_EDGE: 'setSidebarContentResizableEdge', + + SET_MEDIA_AREA_SIZE: 'setMediaAreaSize', + + SET_NUM_CAMERAS: 'setNumCameras', + SET_CAMERA_DOCK_IS_DRAGGING: 'setCameraDockIsDragging', + SET_CAMERA_DOCK_IS_RESIZING: 'setCameraDockIsResizing', + SET_CAMERA_DOCK_POSITION: 'setCameraDockPosition', + SET_CAMERA_DOCK_SIZE: 'setCameraDockSize', + SET_CAMERA_DOCK_OPTIMAL_GRID_SIZE: 'setCameraDockOptimalGridSize', + SET_CAMERA_DOCK_OUTPUT: 'setCameraDockOutput', + SET_CAMERA_DOCK_IS_DRAGGABLE: 'setCameraDockIsDraggable', + SET_CAMERA_DOCK_IS_RESIZABLE: 'setCameraDockIsResizable', + SET_CAMERA_DOCK_RESIZABLE_EDGE: 'setCameraDockResizableEdge', + + SET_DROP_AREAS: 'setDropAreas', + + SET_PRESENTATION_IS_OPEN: 'setPresentationIsOpen', + SET_PRESENTATION_CURRENT_SLIDE_SIZE: 'setPresentationCurrentSlideSize', + SET_PRESENTATION_NUM_CURRENT_SLIDE: 'setPresentationNumCurrentSlide', + SET_PRESENTATION_SLIDES_LENGTH: 'setPresentationSlidesLength', + SET_PRESENTATION_SIZE: 'setPresentationSize', + SET_PRESENTATION_OUTPUT: 'setPresentationOutput', + SET_PRESENTATION_IS_RESIZABLE: 'setPresentationIsResizable', + SET_PRESENTATION_RESIZABLE_EDGE: 'setPresentationResizableEdge', + + SET_FULLSCREEN_ELEMENT: 'setFullscreenElement', + + SET_HAS_SCREEN_SHARE: 'setHasScreenShare', + SET_SCREEN_SHARE_SIZE: 'setScreenShareSize', + SET_SCREEN_SHARE_OUTPUT: 'setScreenShareOutput', + + SET_HAS_EXTERNAL_VIDEO: 'setHasExternalVideo', + SET_EXTERNAL_VIDEO_SIZE: 'setExternalVideoSize', + SET_EXTERNAL_VIDEO_OUTPUT: 'setExternalVideoOutput', + + SET_SHARED_NOTES_OUTPUT: 'setSharedNotesOutput', + SET_NOTES_IS_PINNED: 'setNotesIsPinned', +}; + +export const PANELS = { + USERLIST: 'userlist', + CHAT: 'chat', + POLL: 'poll', + CAPTIONS: 'captions', + BREAKOUT: 'breakoutroom', + SHARED_NOTES: 'shared-notes', + TIMER: 'timer', + WAITING_USERS: 'waiting-users', + NONE: 'none', +}; diff --git a/src/2.7.12/imports/ui/components/layout/initState.js b/src/2.7.12/imports/ui/components/layout/initState.js new file mode 100644 index 00000000..14a199c8 --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/initState.js @@ -0,0 +1,246 @@ +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; + +export const INITIAL_INPUT_STATE = { + autoarrAngeLayout: true, + customParameters: { + + }, + browser: { + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight, + }, + bannerBar: { + hasBanner: false, + }, + notificationsBar: { + hasNotification: false, + }, + navBar: { + hasNavBar: true, + height: DEFAULT_VALUES.navBarHeight, + }, + actionBar: { + hasActionBar: true, + height: DEFAULT_VALUES.actionBarHeight, + }, + sidebarNavigation: { + isOpen: true, + width: 0, + height: 0, + browserWidth: 0, + sidebarNavPanel: DEFAULT_VALUES.sidebarNavPanel, + }, + sidebarContent: { + isOpen: true, + currentPanelType: DEFAULT_VALUES.panelType, + width: 0, + height: 0, + browserWidth: 0, + sidebarContentPanel: DEFAULT_VALUES.sidebarContentPanel, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + }, + sidebarContentHorizontalResizer: { + isOpen: true, + currentPanelType: DEFAULT_VALUES.panelType, + width: 0, + height: 0, + browserWidth: 0, + }, + cameraDock: { + numCameras: 0, + position: DEFAULT_VALUES.cameraPosition, + width: 0, + height: 0, + browserWidth: 0, + browserHeight: 0, + isDragging: false, + isResizing: false, + cameraOptimalGridSize: { + width: 0, + height: 0, + }, + focusedId: 'none', + }, + presentation: { + isOpen: true, + slidesLength: 0, + currentSlide: { + num: 0, + size: { + width: 0, + height: 0, + }, + }, + width: 0, + height: 0, + browserWidth: 0, + browserHeight: 0, + }, + screenShare: { + hasScreenShare: false, + width: 0, + height: 0, + browserWidth: 0, + browserHeight: 0, + }, + externalVideo: { + hasExternalVideo: false, + width: 0, + height: 0, + browserWidth: 0, + browserHeight: 0, + }, + sharedNotes: { + isPinned: false, + width: 0, + height: 0, + browserWidth: 0, + browserHeight: 0, + }, +}; + +export const INITIAL_OUTPUT_STATE = { + navBar: { + display: false, + width: 0, + height: 0, + top: 0, + left: 0, + tabOrder: 0, + zIndex: 1, + }, + actionBar: { + display: false, + width: 0, + height: 0, + top: 0, + left: 0, + tabOrder: 0, + zIndex: 1, + }, + captions: { + left: 0, + right: 0, + }, + sidebarNavigation: { + display: true, + minWidth: 0, + width: 0, + maxWidth: 0, + minHeight: 0, + height: 0, + maxHeight: 0, + top: 0, + left: 0, + tabOrder: 0, + isResizable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + zIndex: 1, + }, + sidebarContent: { + display: true, + minWidth: 0, + width: 0, + maxWidth: 0, + minHeight: 0, + height: 0, + maxHeight: 0, + top: 0, + left: 0, + currentPanelType: '', + tabOrder: 0, + isResizable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + zIndex: 1, + }, + mediaArea: { + width: 0, + height: 0, + }, + cameraDock: { + display: false, + position: null, + minWidth: 0, + width: 0, + maxWidth: 0, + minHeight: 0, + height: 0, + maxHeight: 0, + top: 0, + left: 0, + tabOrder: 0, + isDraggable: false, + isResizable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + zIndex: 1, + focusedId: 'none', + }, + dropZoneAreas: {}, + presentation: { + display: true, + minWidth: 0, + width: 0, + maxWidth: 0, + minHeight: 0, + height: 0, + maxHeight: 0, + top: 0, + left: 0, + tabOrder: 0, + isResizable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + presentationOrientation: 'horizontal', + zIndex: 1, + }, + screenShare: { + display: false, + width: 0, + height: 0, + top: 0, + left: 0, + zIndex: 1, + }, + externalVideo: { + display: false, + width: 0, + height: 0, + top: 0, + left: 0, + tabOrder: 0, + zIndex: 1, + }, + sharedNotes: { + display: false, + width: 0, + height: 0, + top: 0, + left: 0, + tabOrder: 0, + zIndex: 1, + }, +}; diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx new file mode 100644 index 00000000..1e98edd4 --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx @@ -0,0 +1,754 @@ +import { useEffect, useRef } from 'react'; +import { throttle } from '/imports/utils/throttle'; +import { layoutSelect, layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context'; +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; +import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState'; +import { ACTIONS, CAMERADOCK_POSITION, PANELS } from '../enums'; +import Storage from '/imports/ui/services/storage/session'; +import { defaultsDeep } from '/imports/utils/array-utils'; +import { isPresentationEnabled } from '/imports/ui/services/features'; + +const windowWidth = () => window.document.documentElement.clientWidth; +const windowHeight = () => window.document.documentElement.clientHeight; +const min = (value1, value2) => (value1 <= value2 ? value1 : value2); +const max = (value1, value2) => (value1 >= value2 ? value1 : value2); + +const CustomLayout = (props) => { + const { bannerAreaHeight, calculatesActionbarHeight, isMobile } = props; + + function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + } + + const input = layoutSelect((i) => i.input); + const deviceType = layoutSelect((i) => i.deviceType); + const isRTL = layoutSelect((i) => i.isRTL); + const fullscreen = layoutSelect((i) => i.fullscreen); + const fontSize = layoutSelect((i) => i.fontSize); + const currentPanelType = layoutSelect((i) => i.currentPanelType); + + const presentationInput = layoutSelectInput((i) => i.presentation); + const externalVideoInput = layoutSelectInput((i) => i.externalVideo); + const screenShareInput = layoutSelectInput((i) => i.screenShare); + const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes); + + const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation); + const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent); + const cameraDockInput = layoutSelectInput((i) => i.cameraDock); + const actionbarInput = layoutSelectInput((i) => i.actionBar); + const navbarInput = layoutSelectInput((i) => i.navBar); + const layoutContextDispatch = layoutDispatch(); + + const { isResizing } = cameraDockInput; + + const prevDeviceType = usePrevious(deviceType); + const prevIsResizing = usePrevious(isResizing); + + const throttledCalculatesLayout = throttle(() => calculatesLayout(), + 50, { trailing: true, leading: true }); + + useEffect(() => { + window.addEventListener('resize', () => { + layoutContextDispatch({ + type: ACTIONS.SET_BROWSER_SIZE, + value: { + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight, + }, + }); + }); + }, []); + + useEffect(() => { + if (deviceType === null) return () => null; + + if (deviceType !== prevDeviceType) { + // reset layout if deviceType changed + // not all options is supported in all devices + init(); + } else { + throttledCalculatesLayout(); + } + }, [input, deviceType, isRTL, fontSize, fullscreen]); + + const calculatesDropAreas = (sidebarNavWidth, sidebarContentWidth, cameraDockBounds) => { + const { height: actionBarHeight } = calculatesActionbarHeight(); + const mediaAreaHeight = windowHeight() - (DEFAULT_VALUES.navBarHeight + actionBarHeight); + const mediaAreaWidth = windowWidth() - (sidebarNavWidth + sidebarContentWidth); + const DROP_ZONE_DEFAUL_SIZE = 100; + const dropZones = {}; + const sidebarSize = sidebarNavWidth + sidebarContentWidth; + + dropZones[CAMERADOCK_POSITION.CONTENT_TOP] = { + top: DEFAULT_VALUES.navBarHeight, + left: !isRTL ? sidebarSize : null, + right: isRTL ? sidebarSize : null, + width: mediaAreaWidth, + height: DROP_ZONE_DEFAUL_SIZE, + zIndex: cameraDockBounds.zIndex, + }; + + dropZones[CAMERADOCK_POSITION.CONTENT_RIGHT] = { + top: DEFAULT_VALUES.navBarHeight + DROP_ZONE_DEFAUL_SIZE, + left: !isRTL ? windowWidth() - DROP_ZONE_DEFAUL_SIZE : 0, + height: mediaAreaHeight - 2 * DROP_ZONE_DEFAUL_SIZE, + width: DROP_ZONE_DEFAUL_SIZE, + zIndex: cameraDockBounds.zIndex, + }; + + dropZones[CAMERADOCK_POSITION.CONTENT_BOTTOM] = { + top: DEFAULT_VALUES.navBarHeight + mediaAreaHeight - DROP_ZONE_DEFAUL_SIZE, + left: !isRTL ? sidebarSize : null, + right: isRTL ? sidebarSize : null, + width: mediaAreaWidth, + height: DROP_ZONE_DEFAUL_SIZE, + zIndex: cameraDockBounds.zIndex, + }; + + dropZones[CAMERADOCK_POSITION.CONTENT_LEFT] = { + top: DEFAULT_VALUES.navBarHeight + DROP_ZONE_DEFAUL_SIZE, + left: !isRTL ? sidebarSize : null, + right: isRTL ? sidebarSize : null, + height: mediaAreaHeight - 2 * DROP_ZONE_DEFAUL_SIZE, + width: DROP_ZONE_DEFAUL_SIZE, + zIndex: cameraDockBounds.zIndex, + }; + + dropZones[CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM] = { + top: windowHeight() - DROP_ZONE_DEFAUL_SIZE, + left: !isRTL ? sidebarNavWidth : null, + right: isRTL ? sidebarNavWidth : null, + width: sidebarContentWidth, + height: DROP_ZONE_DEFAUL_SIZE, + zIndex: cameraDockBounds.zIndex, + }; + + return dropZones; + }; + + const init = () => { + const { sidebarContentPanel } = sidebarContentInput; + + if (isMobile) { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + sidebarNavPanel: sidebarNavigationInput.sidebarNavPanel, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel: sidebarContentInput.sidebarContentPanel, + }, + sidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: input.externalVideo.hasExternalVideo, + }, + screenShare: { + hasScreenShare: input.screenShare.hasScreenShare, + width: input.screenShare.width, + height: input.screenShare.height, + }, + sharedNotes: { + isPinned: sharedNotesInput.isPinned, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + sidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: input.externalVideo.hasExternalVideo, + }, + screenShare: { + hasScreenShare: input.screenShare.hasScreenShare, + width: input.screenShare.width, + height: input.screenShare.height, + }, + sharedNotes: { + isPinned: sharedNotesInput.isPinned, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } + Session.set('layoutReady', true); + throttledCalculatesLayout(); + }; + + const calculatesSidebarContentHeight = (cameraDockHeight) => { + const { isOpen, slidesLength } = presentationInput; + const { hasExternalVideo } = externalVideoInput; + const { hasScreenShare } = screenShareInput; + const { isPinned: isSharedNotesPinned } = sharedNotesInput; + + const hasPresentation = isPresentationEnabled() && slidesLength !== 0; + const isGeneralMediaOff = + !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned; + + let sidebarContentHeight = 0; + if (sidebarContentInput.isOpen) { + if (isMobile) { + sidebarContentHeight = windowHeight() - DEFAULT_VALUES.navBarHeight; + } else if ( + cameraDockInput.numCameras > 0 && + cameraDockInput.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM && + isOpen && + !isGeneralMediaOff + ) { + sidebarContentHeight = windowHeight() - cameraDockHeight; + } else { + sidebarContentHeight = windowHeight(); + } + sidebarContentHeight -= bannerAreaHeight(); + } + return sidebarContentHeight; + }; + + const calculatesCameraDockBounds = (sidebarNavWidth, sidebarContentWidth, mediaAreaBounds) => { + const { baseCameraDockBounds } = props; + const sidebarSize = sidebarNavWidth + sidebarContentWidth; + + const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize); + + // do not proceed if using values from LayoutEngine + if (Object.keys(baseBounds).length > 0) { + return baseBounds; + } + + const { + camerasMargin, + cameraDockMinHeight, + cameraDockMinWidth, + navBarHeight, + presentationToolbarMinWidth, + } = DEFAULT_VALUES; + + const cameraDockBounds = {}; + + let cameraDockHeight = 0; + let cameraDockWidth = 0; + + const lastSize = Storage.getItem('webcamSize') || { width: 0, height: 0 }; + let { width: lastWidth, height: lastHeight } = lastSize; + + if (cameraDockInput.isDragging) cameraDockBounds.zIndex = 99; + else cameraDockBounds.zIndex = 1; + + const isCameraTop = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_TOP; + const isCameraBottom = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_BOTTOM; + const isCameraLeft = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_LEFT; + const isCameraRight = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_RIGHT; + const isCameraSidebar = cameraDockInput.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM; + + const stoppedResizing = prevIsResizing && !isResizing; + if (stoppedResizing) { + const isCameraTopOrBottom = + cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_TOP || + cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_BOTTOM; + + Storage.setItem('webcamSize', { + width: isCameraTopOrBottom || isCameraSidebar ? lastWidth : cameraDockInput.width, + height: isCameraTopOrBottom || isCameraSidebar ? cameraDockInput.height : lastHeight, + }); + + const updatedLastSize = Storage.getItem('webcamSize'); + lastWidth = updatedLastSize.width; + lastHeight = updatedLastSize.height; + } + + if (isCameraTop || isCameraBottom) { + if ((lastHeight === 0 && !isResizing) || (isCameraTop && isMobile)) { + cameraDockHeight = min( + max(mediaAreaBounds.height * 0.2, cameraDockMinHeight), + mediaAreaBounds.height - cameraDockMinHeight + ); + } else { + const height = isResizing ? cameraDockInput.height : lastHeight; + cameraDockHeight = min( + max(height, cameraDockMinHeight), + mediaAreaBounds.height - cameraDockMinHeight + ); + } + + cameraDockBounds.top = navBarHeight; + cameraDockBounds.left = mediaAreaBounds.left; + cameraDockBounds.right = isRTL ? sidebarSize : null; + cameraDockBounds.minWidth = mediaAreaBounds.width; + cameraDockBounds.width = mediaAreaBounds.width; + cameraDockBounds.maxWidth = mediaAreaBounds.width; + cameraDockBounds.minHeight = cameraDockMinHeight; + cameraDockBounds.height = cameraDockHeight; + cameraDockBounds.maxHeight = mediaAreaBounds.height * 0.8; + + if (isCameraBottom) { + cameraDockBounds.top += mediaAreaBounds.height - cameraDockHeight; + } + + return cameraDockBounds; + } + + if (isCameraLeft || isCameraRight) { + if (lastWidth === 0 && !isResizing) { + cameraDockWidth = min( + max(mediaAreaBounds.width * 0.2, cameraDockMinWidth), + mediaAreaBounds.width - cameraDockMinWidth + ); + } else { + const width = isResizing ? cameraDockInput.width : lastWidth; + cameraDockWidth = min( + max(width, cameraDockMinWidth), + mediaAreaBounds.width - cameraDockMinWidth + ); + } + + cameraDockBounds.top = navBarHeight + bannerAreaHeight(); + cameraDockBounds.minWidth = cameraDockMinWidth; + cameraDockBounds.width = cameraDockWidth; + cameraDockBounds.maxWidth = mediaAreaBounds.width * 0.8; + cameraDockBounds.presenterMaxWidth = + mediaAreaBounds.width - presentationToolbarMinWidth - camerasMargin; + cameraDockBounds.minHeight = cameraDockMinHeight; + cameraDockBounds.height = mediaAreaBounds.height; + cameraDockBounds.maxHeight = mediaAreaBounds.height; + // button size in vertical position + cameraDockBounds.height -= 20; + + if (isCameraRight) { + const sizeValue = mediaAreaBounds.left + mediaAreaBounds.width - cameraDockWidth; + cameraDockBounds.left = !isRTL ? sizeValue - camerasMargin : 0; + cameraDockBounds.right = isRTL ? sizeValue + sidebarSize - camerasMargin : null; + } else if (isCameraLeft) { + cameraDockBounds.left = mediaAreaBounds.left + camerasMargin; + cameraDockBounds.right = isRTL ? sidebarSize + camerasMargin * 2 : null; + } + + return cameraDockBounds; + } + + if (isCameraSidebar) { + if (lastHeight === 0 && !isResizing) { + cameraDockHeight = min( + max(windowHeight() * 0.2, cameraDockMinHeight), + windowHeight() - cameraDockMinHeight + ); + } else { + const height = isResizing ? cameraDockInput.height : lastHeight; + cameraDockHeight = min( + max(height, cameraDockMinHeight), + windowHeight() - cameraDockMinHeight + ); + } + + cameraDockBounds.top = windowHeight() - cameraDockHeight - bannerAreaHeight(); + cameraDockBounds.left = !isRTL ? sidebarNavWidth : 0; + cameraDockBounds.right = isRTL ? sidebarNavWidth : 0; + cameraDockBounds.minWidth = sidebarContentWidth; + cameraDockBounds.width = sidebarContentWidth; + cameraDockBounds.maxWidth = sidebarContentWidth; + cameraDockBounds.minHeight = cameraDockMinHeight; + cameraDockBounds.height = cameraDockHeight; + cameraDockBounds.maxHeight = windowHeight() * 0.8; + } + return cameraDockBounds; + }; + + const calculatesMediaBounds = (sidebarNavWidth, sidebarContentWidth, cameraDockBounds) => { + const { isOpen, slidesLength } = presentationInput; + const { hasExternalVideo } = externalVideoInput; + const { hasScreenShare } = screenShareInput; + const { isPinned: isSharedNotesPinned } = sharedNotesInput; + + const { height: actionBarHeight } = calculatesActionbarHeight(); + const mediaAreaHeight = + windowHeight() - (DEFAULT_VALUES.navBarHeight + actionBarHeight + bannerAreaHeight()); + const mediaAreaWidth = windowWidth() - (sidebarNavWidth + sidebarContentWidth); + const mediaBounds = {}; + const { element: fullscreenElement } = fullscreen; + const { navBarHeight, camerasMargin } = DEFAULT_VALUES; + + const hasPresentation = isPresentationEnabled() && slidesLength !== 0; + const isGeneralMediaOff = + !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned; + + if (!isOpen || isGeneralMediaOff) { + mediaBounds.width = 0; + mediaBounds.height = 0; + mediaBounds.top = 0; + mediaBounds.left = !isRTL ? 0 : null; + mediaBounds.right = isRTL ? 0 : null; + mediaBounds.zIndex = 0; + return mediaBounds; + } + + if ( + fullscreenElement === 'Presentation' || + fullscreenElement === 'Screenshare' || + fullscreenElement === 'ExternalVideo' + ) { + mediaBounds.width = windowWidth(); + mediaBounds.height = windowHeight(); + mediaBounds.top = 0; + mediaBounds.left = !isRTL ? 0 : null; + mediaBounds.right = isRTL ? 0 : null; + mediaBounds.zIndex = 99; + return mediaBounds; + } + + const sidebarSize = sidebarNavWidth + sidebarContentWidth; + + if (cameraDockInput.numCameras > 0 && !cameraDockInput.isDragging) { + switch (cameraDockInput.position) { + case CAMERADOCK_POSITION.CONTENT_TOP: { + mediaBounds.width = mediaAreaWidth; + mediaBounds.height = mediaAreaHeight - cameraDockBounds.height - camerasMargin; + mediaBounds.top = + navBarHeight + cameraDockBounds.height + camerasMargin + bannerAreaHeight(); + mediaBounds.left = !isRTL ? sidebarSize : null; + mediaBounds.right = isRTL ? sidebarSize : null; + break; + } + case CAMERADOCK_POSITION.CONTENT_RIGHT: { + mediaBounds.width = mediaAreaWidth - cameraDockBounds.width - camerasMargin * 2; + mediaBounds.height = mediaAreaHeight; + mediaBounds.top = navBarHeight + bannerAreaHeight(); + mediaBounds.left = !isRTL ? sidebarSize : null; + mediaBounds.right = isRTL ? sidebarSize - camerasMargin * 2 : null; + break; + } + case CAMERADOCK_POSITION.CONTENT_BOTTOM: { + mediaBounds.width = mediaAreaWidth; + mediaBounds.height = mediaAreaHeight - cameraDockBounds.height - camerasMargin; + mediaBounds.top = navBarHeight - camerasMargin + bannerAreaHeight(); + mediaBounds.left = !isRTL ? sidebarSize : null; + mediaBounds.right = isRTL ? sidebarSize : null; + break; + } + case CAMERADOCK_POSITION.CONTENT_LEFT: { + mediaBounds.width = mediaAreaWidth - cameraDockBounds.width - camerasMargin * 2; + mediaBounds.height = mediaAreaHeight; + mediaBounds.top = navBarHeight + bannerAreaHeight(); + const sizeValue = + sidebarNavWidth + sidebarContentWidth + mediaAreaWidth - mediaBounds.width; + mediaBounds.left = !isRTL ? sizeValue : null; + mediaBounds.right = isRTL ? sidebarSize : null; + break; + } + case CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM: { + mediaBounds.width = mediaAreaWidth; + mediaBounds.height = mediaAreaHeight; + mediaBounds.top = navBarHeight + bannerAreaHeight(); + mediaBounds.left = !isRTL ? sidebarSize : null; + mediaBounds.right = isRTL ? sidebarSize : null; + break; + } + default: { + console.log('presentation - camera default'); + } + } + mediaBounds.zIndex = 1; + } else { + mediaBounds.width = mediaAreaWidth; + mediaBounds.height = mediaAreaHeight; + mediaBounds.top = DEFAULT_VALUES.navBarHeight + bannerAreaHeight(); + mediaBounds.left = !isRTL ? sidebarSize : null; + mediaBounds.right = isRTL ? sidebarSize : null; + } + + return mediaBounds; + }; + + const calculatesLayout = () => { + const { + calculatesNavbarBounds, + calculatesActionbarBounds, + calculatesSidebarNavWidth, + calculatesSidebarNavHeight, + calculatesSidebarNavBounds, + calculatesSidebarContentWidth, + calculatesSidebarContentBounds, + calculatesMediaAreaBounds, + isTablet, + } = props; + const { position: cameraPosition } = cameraDockInput; + const { camerasMargin, captionsMargin } = DEFAULT_VALUES; + + const sidebarNavWidth = calculatesSidebarNavWidth(); + const sidebarNavHeight = calculatesSidebarNavHeight(); + const sidebarContentWidth = calculatesSidebarContentWidth(); + const sidebarNavBounds = calculatesSidebarNavBounds(); + const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width); + const mediaAreaBounds = calculatesMediaAreaBounds( + sidebarNavWidth.width, + sidebarContentWidth.width + ); + const navbarBounds = calculatesNavbarBounds(mediaAreaBounds); + const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds); + const cameraDockBounds = calculatesCameraDockBounds( + sidebarNavWidth.width, + sidebarContentWidth.width, + mediaAreaBounds + ); + const dropZoneAreas = calculatesDropAreas( + sidebarNavWidth.width, + sidebarContentWidth.width, + cameraDockBounds + ); + const sidebarContentHeight = calculatesSidebarContentHeight(cameraDockBounds.height); + const mediaBounds = calculatesMediaBounds( + sidebarNavWidth.width, + sidebarContentWidth.width, + cameraDockBounds + ); + const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width; + const { height: actionBarHeight } = calculatesActionbarHeight(); + + let horizontalCameraDiff = 0; + + if (cameraPosition === CAMERADOCK_POSITION.CONTENT_LEFT) { + horizontalCameraDiff = cameraDockBounds.width + camerasMargin * 2; + } + + if (cameraPosition === CAMERADOCK_POSITION.CONTENT_RIGHT) { + horizontalCameraDiff = camerasMargin * 2; + } + + layoutContextDispatch({ + type: ACTIONS.SET_NAVBAR_OUTPUT, + value: { + display: navbarInput.hasNavBar, + width: navbarBounds.width, + height: navbarBounds.height, + top: navbarBounds.top, + left: navbarBounds.left, + tabOrder: DEFAULT_VALUES.navBarTabOrder, + zIndex: navbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_ACTIONBAR_OUTPUT, + value: { + display: actionbarInput.hasActionBar, + width: actionbarBounds.width, + height: actionbarBounds.height, + innerHeight: actionbarBounds.innerHeight, + top: actionbarBounds.top, + left: actionbarBounds.left, + padding: actionbarBounds.padding, + tabOrder: DEFAULT_VALUES.actionBarTabOrder, + zIndex: actionbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAPTIONS_OUTPUT, + value: { + left: !isRTL ? sidebarSize + captionsMargin : null, + right: isRTL ? sidebarSize + captionsMargin : null, + maxWidth: mediaAreaBounds.width - captionsMargin * 2, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT, + value: { + display: sidebarNavigationInput.isOpen, + minWidth: sidebarNavWidth.minWidth, + width: sidebarNavWidth.width, + maxWidth: sidebarNavWidth.maxWidth, + height: sidebarNavHeight, + top: sidebarNavBounds.top, + left: sidebarNavBounds.left, + right: sidebarNavBounds.right, + tabOrder: DEFAULT_VALUES.sidebarNavTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarNavBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: false, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT, + value: { + display: sidebarContentInput.isOpen, + minWidth: sidebarContentWidth.minWidth, + width: sidebarContentWidth.width, + maxWidth: sidebarContentWidth.maxWidth, + height: sidebarContentHeight, + top: sidebarContentBounds.top, + left: sidebarContentBounds.left, + right: sidebarContentBounds.right, + currentPanelType, + tabOrder: DEFAULT_VALUES.sidebarContentTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarContentBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: false, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_MEDIA_AREA_SIZE, + value: { + width: windowWidth() - sidebarNavWidth.width - sidebarContentWidth.width, + height: windowHeight() - DEFAULT_VALUES.navBarHeight - actionBarHeight, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAMERA_DOCK_OUTPUT, + value: { + display: cameraDockInput.numCameras > 0, + position: cameraDockInput.position, + minWidth: cameraDockBounds.minWidth, + width: cameraDockBounds.width, + maxWidth: cameraDockBounds.maxWidth, + presenterMaxWidth: cameraDockBounds.presenterMaxWidth, + minHeight: cameraDockBounds.minHeight, + height: cameraDockBounds.height, + maxHeight: cameraDockBounds.maxHeight, + top: cameraDockBounds.top, + left: cameraDockBounds.left, + right: cameraDockBounds.right, + tabOrder: 4, + isDraggable: !isMobile && !isTablet && presentationInput.isOpen, + resizableEdge: { + top: + input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_BOTTOM || + (input.cameraDock.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM && + input.sidebarContent.isOpen), + right: + (!isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT) || + (isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT), + bottom: input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_TOP, + left: + (!isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT) || + (isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT), + }, + zIndex: cameraDockBounds.zIndex, + focusedId: input.cameraDock.focusedId, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_DROP_AREAS, + value: dropZoneAreas, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_OUTPUT, + value: { + display: presentationInput.isOpen, + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + tabOrder: DEFAULT_VALUES.presentationTabOrder, + isResizable: false, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SCREEN_SHARE_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SHARED_NOTES_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + }, + }); + }; + + return null; +}; + +export default CustomLayout; \ No newline at end of file diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx new file mode 100644 index 00000000..a111f242 --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx @@ -0,0 +1,319 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context'; +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; +import { LAYOUT_TYPE, DEVICE_TYPE } from '/imports/ui/components/layout/enums'; + +import CustomLayout from '/imports/ui/components/layout/layout-manager/customLayout'; +import SmartLayout from '/imports/ui/components/layout/layout-manager/smartLayout'; +import PresentationFocusLayout from '/imports/ui/components/layout/layout-manager/presentationFocusLayout'; +import VideoFocusLayout from '/imports/ui/components/layout/layout-manager/videoFocusLayout'; +import { isPresentationEnabled } from '/imports/ui/services/features'; + +const propTypes = { + layoutType: PropTypes.string.isRequired, +}; + +const LayoutEngine = ({ layoutType }) => { + const bannerBarInput = layoutSelectInput((i) => i.bannerBar); + const notificationsBarInput = layoutSelectInput((i) => i.notificationsBar); + const cameraDockInput = layoutSelectInput((i) => i.cameraDock); + const presentationInput = layoutSelectInput((i) => i.presentation); + const actionbarInput = layoutSelectInput((i) => i.actionBar); + const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation); + const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent); + const externalVideoInput = layoutSelectInput((i) => i.externalVideo); + const screenShareInput = layoutSelectInput((i) => i.screenShare); + const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes); + + const fullscreen = layoutSelect((i) => i.fullscreen); + const isRTL = layoutSelect((i) => i.isRTL); + const fontSize = layoutSelect((i) => i.fontSize); + const deviceType = layoutSelect((i) => i.deviceType); + + const isMobile = deviceType === DEVICE_TYPE.MOBILE; + const isTablet = deviceType === DEVICE_TYPE.TABLET; + const windowWidth = () => window.document.documentElement.clientWidth; + const windowHeight = () => window.document.documentElement.clientHeight; + const min = (value1, value2) => (value1 <= value2 ? value1 : value2); + const max = (value1, value2) => (value1 >= value2 ? value1 : value2); + + const bannerAreaHeight = () => { + const { hasNotification } = notificationsBarInput; + const { hasBanner } = bannerBarInput; + const bannerHeight = hasBanner ? DEFAULT_VALUES.bannerHeight : 0; + const notificationHeight = hasNotification ? DEFAULT_VALUES.bannerHeight : 0; + + return bannerHeight + notificationHeight; + }; + + const baseCameraDockBounds = (mediaAreaBounds, sidebarSize) => { + const { isOpen, slidesLength } = presentationInput; + const { hasExternalVideo } = externalVideoInput; + const { hasScreenShare } = screenShareInput; + const { isPinned: isSharedNotesPinned } = sharedNotesInput; + + const cameraDockBounds = {}; + + if (cameraDockInput.numCameras === 0 && layoutType !== LAYOUT_TYPE.VIDEO_FOCUS) { + cameraDockBounds.width = 0; + cameraDockBounds.height = 0; + + return cameraDockBounds; + } + + const hasPresentation = isPresentationEnabled() && slidesLength !== 0 + const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned; + + if (!isOpen || isGeneralMediaOff) { + cameraDockBounds.width = mediaAreaBounds.width; + cameraDockBounds.maxWidth = mediaAreaBounds.width; + cameraDockBounds.height = mediaAreaBounds.height - bannerAreaHeight(); + cameraDockBounds.maxHeight = mediaAreaBounds.height; + cameraDockBounds.top = DEFAULT_VALUES.navBarHeight + bannerAreaHeight(); + cameraDockBounds.left = !isRTL ? mediaAreaBounds.left : 0; + cameraDockBounds.right = isRTL ? sidebarSize : null; + } + + if (fullscreen.group === 'webcams') { + cameraDockBounds.width = windowWidth(); + cameraDockBounds.minWidth = windowWidth(); + cameraDockBounds.maxWidth = windowWidth(); + cameraDockBounds.height = windowHeight(); + cameraDockBounds.minHeight = windowHeight(); + cameraDockBounds.maxHeight = windowHeight(); + cameraDockBounds.top = 0; + cameraDockBounds.left = 0; + cameraDockBounds.right = 0; + cameraDockBounds.zIndex = 99; + + return cameraDockBounds; + } + + return cameraDockBounds; + }; + + const calculatesNavbarBounds = (mediaAreaBounds) => { + const { navBarHeight, navBarTop } = DEFAULT_VALUES; + + return { + width: mediaAreaBounds.width, + height: navBarHeight, + top: navBarTop + bannerAreaHeight(), + left: !isRTL ? mediaAreaBounds.left : 0, + zIndex: 1, + }; + }; + + const calculatesActionbarHeight = () => { + const { actionBarHeight, actionBarPadding } = DEFAULT_VALUES; + + const BASE_FONT_SIZE = 14; // 90% font size + const height = ((actionBarHeight / BASE_FONT_SIZE) * fontSize); + + return { + height: height + (actionBarPadding * 2), + innerHeight: height, + padding: actionBarPadding, + }; + }; + + const calculatesActionbarBounds = (mediaAreaBounds) => { + const actionBarHeight = calculatesActionbarHeight(); + + return { + display: actionbarInput.hasActionBar, + width: mediaAreaBounds.width, + height: actionBarHeight.height, + innerHeight: actionBarHeight.innerHeight, + padding: actionBarHeight.padding, + top: windowHeight() - actionBarHeight.height, + left: !isRTL ? mediaAreaBounds.left : 0, + zIndex: 1, + }; + }; + + const calculatesSidebarNavWidth = () => { + const { + sidebarNavMinWidth, + sidebarNavMaxWidth, + } = DEFAULT_VALUES; + + const { isOpen, width: sidebarNavWidth } = sidebarNavigationInput; + + let minWidth = 0; + let width = 0; + let maxWidth = 0; + if (isOpen) { + if (isMobile) { + minWidth = windowWidth(); + width = windowWidth(); + maxWidth = windowWidth(); + } else { + if (sidebarNavWidth === 0) { + width = min(max((windowWidth() * 0.2), sidebarNavMinWidth), sidebarNavMaxWidth); + } else { + width = min(max(sidebarNavWidth, sidebarNavMinWidth), sidebarNavMaxWidth); + } + minWidth = sidebarNavMinWidth; + maxWidth = sidebarNavMaxWidth; + } + } + return { + minWidth, + width, + maxWidth, + }; + }; + + const calculatesSidebarNavHeight = () => { + const { navBarHeight } = DEFAULT_VALUES; + const { isOpen } = sidebarNavigationInput; + + let sidebarNavHeight = 0; + if (isOpen) { + if (isMobile) { + sidebarNavHeight = windowHeight() - navBarHeight - bannerAreaHeight(); + } else { + sidebarNavHeight = windowHeight() - bannerAreaHeight(); + } + } + return sidebarNavHeight; + }; + + const calculatesSidebarNavBounds = () => { + const { sidebarNavTop, navBarHeight, sidebarNavLeft } = DEFAULT_VALUES; + + let top = sidebarNavTop + bannerAreaHeight(); + + if (isMobile) { + top = navBarHeight + bannerAreaHeight(); + } + + return { + top, + left: !isRTL ? sidebarNavLeft : null, + right: isRTL ? sidebarNavLeft : null, + zIndex: isMobile ? 11 : 2, + }; + }; + + const calculatesSidebarContentWidth = () => { + const { + sidebarContentMinWidth, + sidebarContentMaxWidth, + } = DEFAULT_VALUES; + + const { isOpen, width: sidebarContentWidth } = sidebarContentInput; + + let minWidth = 0; + let width = 0; + let maxWidth = 0; + + if (isOpen) { + if (isMobile) { + minWidth = windowWidth(); + width = windowWidth(); + maxWidth = windowWidth(); + } else { + if (sidebarContentWidth === 0) { + width = min( + max((windowWidth() * 0.2), sidebarContentMinWidth), sidebarContentMaxWidth, + ); + } else { + width = min(max(sidebarContentWidth, sidebarContentMinWidth), + sidebarContentMaxWidth); + } + minWidth = sidebarContentMinWidth; + maxWidth = sidebarContentMaxWidth; + } + } + return { + minWidth, + width, + maxWidth, + }; + }; + + const calculatesSidebarContentBounds = (sidebarNavWidth) => { + const { navBarHeight, sidebarNavTop } = DEFAULT_VALUES; + + let top = sidebarNavTop + bannerAreaHeight(); + + if (isMobile) top = navBarHeight + bannerAreaHeight(); + + let left = isMobile ? 0 : sidebarNavWidth; + let right = isMobile ? 0 : sidebarNavWidth; + left = !isRTL ? left : null; + right = isRTL ? right : null; + + const zIndex = isMobile ? 11 : 1; + + return { + top, + left, + right, + zIndex, + }; + }; + + const calculatesMediaAreaBounds = (sidebarNavWidth, sidebarContentWidth) => { + const { navBarHeight } = DEFAULT_VALUES; + const { height: actionBarHeight } = calculatesActionbarHeight(); + let left = 0; + let width = 0; + if (isMobile) { + width = windowWidth(); + } else { + left = !isRTL ? sidebarNavWidth + sidebarContentWidth : 0; + width = windowWidth() - sidebarNavWidth - sidebarContentWidth; + } + + return { + width, + height: windowHeight() - (navBarHeight + actionBarHeight + bannerAreaHeight()), + top: navBarHeight + bannerAreaHeight(), + left, + }; + }; + + const common = { + bannerAreaHeight, + baseCameraDockBounds, + calculatesNavbarBounds, + calculatesActionbarHeight, + calculatesActionbarBounds, + calculatesSidebarNavWidth, + calculatesSidebarNavHeight, + calculatesSidebarNavBounds, + calculatesSidebarContentWidth, + calculatesSidebarContentBounds, + calculatesMediaAreaBounds, + isMobile, + isTablet, + }; + + const layout = document.getElementById('layout'); + + switch (layoutType) { + case LAYOUT_TYPE.CUSTOM_LAYOUT: + layout?.setAttribute("data-layout", LAYOUT_TYPE.CUSTOM_LAYOUT); + return ; + case LAYOUT_TYPE.SMART_LAYOUT: + layout?.setAttribute("data-layout", LAYOUT_TYPE.SMART_LAYOUT); + return ; + case LAYOUT_TYPE.PRESENTATION_FOCUS: + layout?.setAttribute("data-layout", LAYOUT_TYPE.PRESENTATION_FOCUS); + return ; + case LAYOUT_TYPE.VIDEO_FOCUS: + layout?.setAttribute("data-layout",LAYOUT_TYPE.VIDEO_FOCUS); + return ; + default: + layout?.setAttribute("data-layout", LAYOUT_TYPE.CUSTOM_LAYOUT); + return ; + } +}; + +LayoutEngine.propTypes = propTypes; + +export default LayoutEngine; diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx new file mode 100644 index 00000000..b37d0761 --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx @@ -0,0 +1,514 @@ +import { useEffect, useRef } from 'react'; +import { throttle } from '/imports/utils/throttle'; +import { layoutDispatch, layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context'; +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; +import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState'; +import { + ACTIONS, + PANELS, + CAMERADOCK_POSITION, +} from '/imports/ui/components/layout/enums'; +import { defaultsDeep } from '/imports/utils/array-utils'; +import { isPresentationEnabled } from '/imports/ui/services/features'; + +const windowWidth = () => window.document.documentElement.clientWidth; +const windowHeight = () => window.document.documentElement.clientHeight; +const min = (value1, value2) => (value1 <= value2 ? value1 : value2); +const max = (value1, value2) => (value1 >= value2 ? value1 : value2); + +const PresentationFocusLayout = (props) => { + const { bannerAreaHeight, isMobile } = props; + + function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + } + + const input = layoutSelect((i) => i.input); + const deviceType = layoutSelect((i) => i.deviceType); + const isRTL = layoutSelect((i) => i.isRTL); + const fullscreen = layoutSelect((i) => i.fullscreen); + const fontSize = layoutSelect((i) => i.fontSize); + const currentPanelType = layoutSelect((i) => i.currentPanelType); + + const presentationInput = layoutSelectInput((i) => i.presentation); + const externalVideoInput = layoutSelectInput((i) => i.externalVideo); + const screenShareInput = layoutSelectInput((i) => i.screenShare); + const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes); + + const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation); + const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent); + const cameraDockInput = layoutSelectInput((i) => i.cameraDock); + const actionbarInput = layoutSelectInput((i) => i.actionBar); + const navbarInput = layoutSelectInput((i) => i.navBar); + const layoutContextDispatch = layoutDispatch(); + + const prevDeviceType = usePrevious(deviceType); + + const throttledCalculatesLayout = throttle(() => calculatesLayout(), + 50, { trailing: true, leading: true }); + + useEffect(() => { + window.addEventListener('resize', () => { + layoutContextDispatch({ + type: ACTIONS.SET_BROWSER_SIZE, + value: { + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight, + }, + }); + }); + }, []); + + useEffect(() => { + if (deviceType === null) return () => null; + + if (deviceType !== prevDeviceType) { + // reset layout if deviceType changed + // not all options is supported in all devices + init(); + } else { + throttledCalculatesLayout(); + } + }, [input, deviceType, isRTL, fontSize, fullscreen]); + + const init = () => { + const { sidebarContentPanel } = sidebarContentInput; + if (isMobile) { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + SidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: input.externalVideo.hasExternalVideo, + }, + screenShare: { + hasScreenShare: input.screenShare.hasScreenShare, + width: input.screenShare.width, + height: input.screenShare.height, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + SidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: input.externalVideo.hasExternalVideo, + }, + screenShare: { + hasScreenShare: input.screenShare.hasScreenShare, + width: input.screenShare.width, + height: input.screenShare.height, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } + Session.set('layoutReady', true); + throttledCalculatesLayout(); + }; + + const calculatesSidebarContentHeight = () => { + const { isOpen, slidesLength } = presentationInput; + const { hasExternalVideo } = externalVideoInput; + const { hasScreenShare } = screenShareInput; + const { isPinned: isSharedNotesPinned } = sharedNotesInput; + + const hasPresentation = isPresentationEnabled() && slidesLength !== 0; + const isGeneralMediaOff = + !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned; + + const { navBarHeight, sidebarContentMinHeight } = DEFAULT_VALUES; + let height = 0; + let minHeight = 0; + let maxHeight = 0; + if (sidebarContentInput.isOpen) { + if (isMobile) { + height = windowHeight() - navBarHeight - bannerAreaHeight(); + minHeight = height; + maxHeight = height; + } else if (cameraDockInput.numCameras > 0 && isOpen && !isGeneralMediaOff) { + if (sidebarContentInput.height === 0) { + height = windowHeight() * 0.75 - bannerAreaHeight(); + } else { + height = min(max(sidebarContentInput.height, sidebarContentMinHeight), windowHeight()); + } + minHeight = windowHeight() * 0.25 - bannerAreaHeight(); + maxHeight = windowHeight() * 0.75 - bannerAreaHeight(); + } else { + height = windowHeight() - bannerAreaHeight(); + minHeight = height; + maxHeight = height; + } + } + return { + height, + minHeight, + maxHeight, + }; + }; + + const calculatesCameraDockBounds = ( + mediaBounds, + mediaAreaBounds, + sidebarNavWidth, + sidebarContentWidth, + sidebarContentHeight + ) => { + const { baseCameraDockBounds } = props; + const sidebarSize = sidebarNavWidth + sidebarContentWidth; + + const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize); + + // do not proceed if using values from LayoutEngine + if (Object.keys(baseBounds).length > 0) { + return baseBounds; + } + + const { cameraDockMinHeight } = DEFAULT_VALUES; + + const cameraDockBounds = {}; + + let cameraDockHeight = 0; + + if (isMobile) { + cameraDockBounds.top = mediaAreaBounds.top + mediaBounds.height; + cameraDockBounds.left = 0; + cameraDockBounds.right = 0; + cameraDockBounds.minWidth = mediaAreaBounds.width; + cameraDockBounds.width = mediaAreaBounds.width; + cameraDockBounds.maxWidth = mediaAreaBounds.width; + cameraDockBounds.minHeight = cameraDockMinHeight; + cameraDockBounds.height = mediaAreaBounds.height - mediaBounds.height; + cameraDockBounds.maxHeight = mediaAreaBounds.height - mediaBounds.height; + } else { + if (cameraDockInput.height === 0) { + cameraDockHeight = min( + max(windowHeight() - sidebarContentHeight, cameraDockMinHeight), + windowHeight() - cameraDockMinHeight + ); + const bannerAreaDiff = + windowHeight() - sidebarContentHeight - cameraDockHeight - bannerAreaHeight(); + cameraDockHeight += bannerAreaDiff; + } else { + cameraDockHeight = min( + max(cameraDockInput.height, cameraDockMinHeight), + windowHeight() - cameraDockMinHeight + ); + } + cameraDockBounds.top = windowHeight() - cameraDockHeight - bannerAreaHeight(); + cameraDockBounds.left = !isRTL ? sidebarNavWidth : 0; + cameraDockBounds.right = isRTL ? sidebarNavWidth : 0; + cameraDockBounds.minWidth = sidebarContentWidth; + cameraDockBounds.width = sidebarContentWidth; + cameraDockBounds.maxWidth = sidebarContentWidth; + cameraDockBounds.minHeight = cameraDockMinHeight; + cameraDockBounds.height = cameraDockHeight; + cameraDockBounds.maxHeight = windowHeight() - sidebarContentHeight; + cameraDockBounds.zIndex = 1; + } + return cameraDockBounds; + }; + + const calculatesMediaBounds = (mediaAreaBounds, sidebarSize) => { + const mediaBounds = {}; + const { element: fullscreenElement } = fullscreen; + + if ( + fullscreenElement === 'Presentation' || + fullscreenElement === 'Screenshare' || + fullscreenElement === 'ExternalVideo' + ) { + mediaBounds.width = windowWidth(); + mediaBounds.height = windowHeight(); + mediaBounds.top = 0; + mediaBounds.left = !isRTL ? 0 : null; + mediaBounds.right = isRTL ? 0 : null; + mediaBounds.zIndex = 99; + return mediaBounds; + } + + if (isMobile && cameraDockInput.numCameras > 0) { + mediaBounds.height = mediaAreaBounds.height * 0.7; + } else { + mediaBounds.height = mediaAreaBounds.height; + } + mediaBounds.width = mediaAreaBounds.width; + mediaBounds.top = DEFAULT_VALUES.navBarHeight + bannerAreaHeight(); + mediaBounds.left = !isRTL ? mediaAreaBounds.left : null; + mediaBounds.right = isRTL ? sidebarSize : null; + mediaBounds.zIndex = 1; + + return mediaBounds; + }; + + const calculatesLayout = () => { + const { + calculatesNavbarBounds, + calculatesActionbarBounds, + calculatesSidebarNavWidth, + calculatesSidebarNavHeight, + calculatesSidebarNavBounds, + calculatesSidebarContentWidth, + calculatesSidebarContentBounds, + calculatesMediaAreaBounds, + isTablet, + } = props; + const { captionsMargin } = DEFAULT_VALUES; + + const sidebarNavWidth = calculatesSidebarNavWidth(); + const sidebarNavHeight = calculatesSidebarNavHeight(); + const sidebarContentWidth = calculatesSidebarContentWidth(); + const sidebarNavBounds = calculatesSidebarNavBounds(); + const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width); + const mediaAreaBounds = calculatesMediaAreaBounds( + sidebarNavWidth.width, + sidebarContentWidth.width + ); + const navbarBounds = calculatesNavbarBounds(mediaAreaBounds); + const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds); + const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width; + const mediaBounds = calculatesMediaBounds(mediaAreaBounds, sidebarSize); + const sidebarContentHeight = calculatesSidebarContentHeight(); + const cameraDockBounds = calculatesCameraDockBounds( + mediaBounds, + mediaAreaBounds, + sidebarNavWidth.width, + sidebarContentWidth.width, + sidebarContentHeight.height + ); + const { isOpen } = presentationInput; + + layoutContextDispatch({ + type: ACTIONS.SET_NAVBAR_OUTPUT, + value: { + display: navbarInput.hasNavBar, + width: navbarBounds.width, + height: navbarBounds.height, + top: navbarBounds.top, + left: navbarBounds.left, + tabOrder: DEFAULT_VALUES.navBarTabOrder, + zIndex: navbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_ACTIONBAR_OUTPUT, + value: { + display: actionbarInput.hasActionBar, + width: actionbarBounds.width, + height: actionbarBounds.height, + innerHeight: actionbarBounds.innerHeight, + top: actionbarBounds.top, + left: actionbarBounds.left, + padding: actionbarBounds.padding, + tabOrder: DEFAULT_VALUES.actionBarTabOrder, + zIndex: actionbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAPTIONS_OUTPUT, + value: { + left: !isRTL ? sidebarSize + captionsMargin : null, + right: isRTL ? sidebarSize + captionsMargin : null, + maxWidth: mediaAreaBounds.width - captionsMargin * 2, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT, + value: { + display: sidebarNavigationInput.isOpen, + minWidth: sidebarNavWidth.minWidth, + width: sidebarNavWidth.width, + maxWidth: sidebarNavWidth.maxWidth, + height: sidebarNavHeight, + top: sidebarNavBounds.top, + left: sidebarNavBounds.left, + right: sidebarNavBounds.right, + tabOrder: DEFAULT_VALUES.sidebarNavTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarNavBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: false, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT, + value: { + display: sidebarContentInput.isOpen, + minWidth: sidebarContentWidth.minWidth, + width: sidebarContentWidth.width, + maxWidth: sidebarContentWidth.maxWidth, + minHeight: sidebarContentHeight.minHeight, + height: sidebarContentHeight.height, + maxHeight: sidebarContentHeight.maxHeight, + top: sidebarContentBounds.top, + left: sidebarContentBounds.left, + right: sidebarContentBounds.right, + currentPanelType, + tabOrder: DEFAULT_VALUES.sidebarContentTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarContentBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: cameraDockInput.numCameras > 0, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_MEDIA_AREA_SIZE, + value: { + width: mediaAreaBounds.width, + height: mediaAreaBounds.height, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAMERA_DOCK_OUTPUT, + value: { + display: cameraDockInput.numCameras > 0, + position: CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM, + minWidth: cameraDockBounds.minWidth, + width: cameraDockBounds.width, + maxWidth: cameraDockBounds.maxWidth, + minHeight: cameraDockBounds.minHeight, + height: cameraDockBounds.height, + maxHeight: cameraDockBounds.maxHeight, + top: cameraDockBounds.top, + left: cameraDockBounds.left, + right: cameraDockBounds.right, + tabOrder: 4, + isDraggable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + focusedId: input.cameraDock.focusedId, + zIndex: cameraDockBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_OUTPUT, + value: { + display: presentationInput.isOpen, + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right : null, + tabOrder: DEFAULT_VALUES.presentationTabOrder, + isResizable: false, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SCREEN_SHARE_OUTPUT, + value: { + width: mediaBounds.width, + height: isOpen ? mediaBounds.height : 0, + top: mediaBounds.top, + left: mediaBounds.left, + right: mediaBounds.right, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT, + value: { + width: isOpen ? mediaBounds.width : 0, + height: isOpen ? mediaBounds.height : 0, + top: mediaBounds.top, + left: mediaBounds.left, + right: mediaBounds.right, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SHARED_NOTES_OUTPUT, + value: { + width: isOpen ? mediaBounds.width : 0, + height: isOpen ? mediaBounds.height : 0, + top: mediaBounds.top, + left: mediaBounds.left, + right: mediaBounds.right, + }, + }); + }; + + return null; +}; + +export default PresentationFocusLayout; \ No newline at end of file diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx new file mode 100644 index 00000000..a10756e2 --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx @@ -0,0 +1,585 @@ +import { useEffect, useRef } from 'react'; +import { throttle } from '/imports/utils/throttle'; +import { layoutDispatch, layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context'; +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; +import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState'; +import { ACTIONS, PANELS, CAMERADOCK_POSITION } from '/imports/ui/components/layout/enums'; +import { defaultsDeep } from '/imports/utils/array-utils'; +import { isPresentationEnabled } from '/imports/ui/services/features'; + +const windowWidth = () => window.document.documentElement.clientWidth; +const windowHeight = () => window.document.documentElement.clientHeight; + +const SmartLayout = (props) => { + const { bannerAreaHeight, isMobile } = props; + + function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + } + + const input = layoutSelect((i) => i.input); + const deviceType = layoutSelect((i) => i.deviceType); + const isRTL = layoutSelect((i) => i.isRTL); + const fullscreen = layoutSelect((i) => i.fullscreen); + const fontSize = layoutSelect((i) => i.fontSize); + const currentPanelType = layoutSelect((i) => i.currentPanelType); + + const presentationInput = layoutSelectInput((i) => i.presentation); + const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation); + const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent); + const cameraDockInput = layoutSelectInput((i) => i.cameraDock); + const actionbarInput = layoutSelectInput((i) => i.actionBar); + const navbarInput = layoutSelectInput((i) => i.navBar); + const externalVideoInput = layoutSelectInput((i) => i.externalVideo); + const screenShareInput = layoutSelectInput((i) => i.screenShare); + const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes); + const layoutContextDispatch = layoutDispatch(); + + const prevDeviceType = usePrevious(deviceType); + + const throttledCalculatesLayout = throttle(() => calculatesLayout(), 50, { + trailing: true, + leading: true, + }); + + useEffect(() => { + window.addEventListener('resize', () => { + layoutContextDispatch({ + type: ACTIONS.SET_BROWSER_SIZE, + value: { + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight, + }, + }); + }); + }, []); + + useEffect(() => { + if (deviceType === null) return () => null; + + if (deviceType !== prevDeviceType) { + // reset layout if deviceType changed + // not all options is supported in all devices + init(); + } else { + throttledCalculatesLayout(); + } + }, [input, deviceType, isRTL, fontSize, fullscreen]); + + const init = () => { + const { sidebarContentPanel } = sidebarContentInput; + + if (isMobile) { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + SidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: externalVideoInput.hasExternalVideo, + }, + screenShare: { + hasScreenShare: screenShareInput.hasScreenShare, + width: screenShareInput.width, + height: screenShareInput.height, + }, + sharedNotes: { + isPinned: sharedNotesInput.isPinned, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + SidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: externalVideoInput.hasExternalVideo, + }, + screenShare: { + hasScreenShare: screenShareInput.hasScreenShare, + width: screenShareInput.width, + height: screenShareInput.height, + }, + sharedNotes: { + isPinned: sharedNotesInput.isPinned, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } + Session.set('layoutReady', true); + throttledCalculatesLayout(); + }; + + const calculatesSidebarContentHeight = () => { + let sidebarContentHeight = 0; + if (sidebarContentInput.isOpen) { + if (isMobile) { + sidebarContentHeight = windowHeight() - DEFAULT_VALUES.navBarHeight; + } else { + sidebarContentHeight = windowHeight(); + } + sidebarContentHeight -= bannerAreaHeight(); + } + return sidebarContentHeight; + }; + + const calculatesCameraDockBounds = (mediaAreaBounds, mediaBounds, sidebarSize) => { + const { baseCameraDockBounds } = props; + + const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize); + + // do not proceed if using values from LayoutEngine + if (Object.keys(baseBounds).length > 0) { + baseBounds.isCameraHorizontal = false; + return baseBounds; + } + + const { camerasMargin, presentationToolbarMinWidth, navBarHeight } = DEFAULT_VALUES; + + const cameraDockBounds = {}; + + cameraDockBounds.isCameraHorizontal = false; + + const mediaBoundsWidth = + mediaBounds.width > presentationToolbarMinWidth && !isMobile + ? mediaBounds.width + : presentationToolbarMinWidth; + + cameraDockBounds.top = navBarHeight; + cameraDockBounds.left = mediaAreaBounds.left; + cameraDockBounds.right = isRTL ? sidebarSize : null; + cameraDockBounds.zIndex = 1; + + if (mediaBounds.width < mediaAreaBounds.width) { + cameraDockBounds.top = navBarHeight + bannerAreaHeight(); + cameraDockBounds.width = mediaAreaBounds.width - mediaBoundsWidth; + cameraDockBounds.maxWidth = mediaAreaBounds.width * 0.8; + cameraDockBounds.height = mediaAreaBounds.height; + cameraDockBounds.maxHeight = mediaAreaBounds.height; + cameraDockBounds.left += camerasMargin; + cameraDockBounds.width -= camerasMargin * 2; + cameraDockBounds.isCameraHorizontal = true; + cameraDockBounds.position = CAMERADOCK_POSITION.CONTENT_LEFT; + // button size in vertical position + cameraDockBounds.height -= 20; + } else { + cameraDockBounds.width = mediaAreaBounds.width; + cameraDockBounds.maxWidth = mediaAreaBounds.width; + cameraDockBounds.height = mediaAreaBounds.height - mediaBounds.height; + cameraDockBounds.maxHeight = mediaAreaBounds.height * 0.8; + cameraDockBounds.top += camerasMargin; + cameraDockBounds.height -= camerasMargin * 2; + cameraDockBounds.position = CAMERADOCK_POSITION.CONTENT_TOP; + } + + cameraDockBounds.minWidth = cameraDockBounds.width; + cameraDockBounds.minHeight = cameraDockBounds.height; + + return cameraDockBounds; + }; + + const calculatesSlideSize = (mediaAreaBounds) => { + const { currentSlide } = presentationInput; + + if (currentSlide.size.width === 0 && currentSlide.size.height === 0) { + return { + width: 0, + height: 0, + }; + } + + let slideWidth; + let slideHeight; + + slideWidth = (currentSlide.size.width * mediaAreaBounds.height) / currentSlide.size.height; + slideHeight = mediaAreaBounds.height; + + if (slideWidth > mediaAreaBounds.width) { + slideWidth = mediaAreaBounds.width; + slideHeight = (currentSlide.size.height * mediaAreaBounds.width) / currentSlide.size.width; + } + + return { + width: slideWidth, + height: slideHeight, + }; + }; + + const calculatesScreenShareSize = (mediaAreaBounds) => { + const { width = 0, height = 0 } = screenShareInput; + + if (width === 0 && height === 0) return { width, height }; + + let screeShareWidth; + let screeShareHeight; + + screeShareWidth = (width * mediaAreaBounds.height) / height; + screeShareHeight = mediaAreaBounds.height; + + if (screeShareWidth > mediaAreaBounds.width) { + screeShareWidth = mediaAreaBounds.width; + screeShareHeight = (height * mediaAreaBounds.width) / width; + } + + return { + width: screeShareWidth, + height: screeShareHeight, + }; + }; + + const calculatesMediaBounds = (mediaAreaBounds, slideSize, sidebarSize, screenShareSize) => { + const { isOpen, slidesLength } = presentationInput; + const { hasExternalVideo } = externalVideoInput; + const { hasScreenShare } = screenShareInput; + const { isPinned: isSharedNotesPinned } = sharedNotesInput; + + const hasPresentation = isPresentationEnabled() && slidesLength !== 0; + const isGeneralMediaOff = + !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned; + + const mediaBounds = {}; + const { element: fullscreenElement } = fullscreen; + + if (!isOpen || isGeneralMediaOff) { + mediaBounds.width = 0; + mediaBounds.height = 0; + mediaBounds.top = 0; + mediaBounds.left = !isRTL ? 0 : null; + mediaBounds.right = isRTL ? 0 : null; + mediaBounds.zIndex = 0; + return mediaBounds; + } + + if ( + fullscreenElement === 'Presentation' || + fullscreenElement === 'Screenshare' || + fullscreenElement === 'ExternalVideo' + ) { + mediaBounds.width = windowWidth(); + mediaBounds.height = windowHeight(); + mediaBounds.top = 0; + mediaBounds.left = !isRTL ? 0 : null; + mediaBounds.right = isRTL ? 0 : null; + mediaBounds.zIndex = 99; + return mediaBounds; + } + + const mediaContentSize = hasScreenShare ? screenShareSize : slideSize; + + if (cameraDockInput.numCameras > 0 && !cameraDockInput.isDragging) { + if (mediaContentSize.width !== 0 && mediaContentSize.height !== 0 && !hasExternalVideo) { + if (mediaContentSize.width < mediaAreaBounds.width && !isMobile) { + if (mediaContentSize.width < mediaAreaBounds.width * 0.8) { + mediaBounds.width = mediaContentSize.width; + } else { + mediaBounds.width = mediaAreaBounds.width * 0.8; + } + mediaBounds.height = mediaAreaBounds.height; + mediaBounds.top = mediaAreaBounds.top; + const sizeValue = mediaAreaBounds.left + (mediaAreaBounds.width - mediaBounds.width); + mediaBounds.left = !isRTL ? sizeValue : null; + mediaBounds.right = isRTL ? sidebarSize : null; + } else { + if (mediaContentSize.height < mediaAreaBounds.height * 0.8) { + mediaBounds.height = mediaContentSize.height; + } else { + mediaBounds.height = mediaAreaBounds.height * 0.8; + } + mediaBounds.width = mediaAreaBounds.width; + mediaBounds.top = mediaAreaBounds.top + (mediaAreaBounds.height - mediaBounds.height); + const sizeValue = mediaAreaBounds.left; + mediaBounds.left = !isRTL ? sizeValue : null; + mediaBounds.right = isRTL ? sidebarSize : null; + } + } else { + mediaBounds.width = mediaAreaBounds.width; + mediaBounds.height = mediaAreaBounds.height * 0.8; + mediaBounds.top = mediaAreaBounds.top + (mediaAreaBounds.height - mediaBounds.height); + const sizeValue = mediaAreaBounds.left; + mediaBounds.left = !isRTL ? sizeValue : null; + mediaBounds.right = isRTL ? sidebarSize : null; + } + } else { + mediaBounds.width = mediaAreaBounds.width; + mediaBounds.height = mediaAreaBounds.height; + mediaBounds.top = mediaAreaBounds.top; + const sizeValue = mediaAreaBounds.left; + mediaBounds.left = !isRTL ? sizeValue : null; + mediaBounds.right = isRTL ? sidebarSize : null; + } + mediaBounds.zIndex = 1; + + return mediaBounds; + }; + + const calculatesLayout = () => { + const { + calculatesNavbarBounds, + calculatesActionbarBounds, + calculatesSidebarNavWidth, + calculatesSidebarNavHeight, + calculatesSidebarNavBounds, + calculatesSidebarContentWidth, + calculatesSidebarContentBounds, + calculatesMediaAreaBounds, + isTablet, + } = props; + const { camerasMargin, captionsMargin } = DEFAULT_VALUES; + + const sidebarNavWidth = calculatesSidebarNavWidth(); + const sidebarNavHeight = calculatesSidebarNavHeight(); + const sidebarContentWidth = calculatesSidebarContentWidth(); + const sidebarContentHeight = calculatesSidebarContentHeight(); + const sidebarNavBounds = calculatesSidebarNavBounds(); + const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width); + const mediaAreaBounds = calculatesMediaAreaBounds( + sidebarNavWidth.width, + sidebarContentWidth.width + ); + const navbarBounds = calculatesNavbarBounds(mediaAreaBounds); + const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds); + const slideSize = calculatesSlideSize(mediaAreaBounds); + const screenShareSize = calculatesScreenShareSize(mediaAreaBounds); + const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width; + const mediaBounds = calculatesMediaBounds( + mediaAreaBounds, + slideSize, + sidebarSize, + screenShareSize + ); + const cameraDockBounds = calculatesCameraDockBounds(mediaAreaBounds, mediaBounds, sidebarSize); + const horizontalCameraDiff = cameraDockBounds.isCameraHorizontal + ? cameraDockBounds.width + camerasMargin * 2 + : 0; + + layoutContextDispatch({ + type: ACTIONS.SET_NAVBAR_OUTPUT, + value: { + display: navbarInput.hasNavBar, + width: navbarBounds.width, + height: navbarBounds.height, + top: navbarBounds.top, + left: navbarBounds.left, + tabOrder: DEFAULT_VALUES.navBarTabOrder, + zIndex: navbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_ACTIONBAR_OUTPUT, + value: { + display: actionbarInput.hasActionBar, + width: actionbarBounds.width, + height: actionbarBounds.height, + innerHeight: actionbarBounds.innerHeight, + top: actionbarBounds.top, + left: actionbarBounds.left, + padding: actionbarBounds.padding, + tabOrder: DEFAULT_VALUES.actionBarTabOrder, + zIndex: actionbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAPTIONS_OUTPUT, + value: { + left: !isRTL ? sidebarSize + captionsMargin : null, + right: isRTL ? sidebarSize + captionsMargin : null, + maxWidth: mediaAreaBounds.width - captionsMargin * 2, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT, + value: { + display: sidebarNavigationInput.isOpen, + minWidth: sidebarNavWidth.minWidth, + width: sidebarNavWidth.width, + maxWidth: sidebarNavWidth.maxWidth, + height: sidebarNavHeight, + top: sidebarNavBounds.top, + left: sidebarNavBounds.left, + right: sidebarNavBounds.right, + tabOrder: DEFAULT_VALUES.sidebarNavTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarNavBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: false, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT, + value: { + display: sidebarContentInput.isOpen, + minWidth: sidebarContentWidth.minWidth, + width: sidebarContentWidth.width, + maxWidth: sidebarContentWidth.maxWidth, + height: sidebarContentHeight, + top: sidebarContentBounds.top, + left: sidebarContentBounds.left, + right: sidebarContentBounds.right, + currentPanelType, + tabOrder: DEFAULT_VALUES.sidebarContentTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarContentBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: false, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_MEDIA_AREA_SIZE, + value: { + width: mediaAreaBounds.width, + height: mediaAreaBounds.height, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAMERA_DOCK_OUTPUT, + value: { + display: cameraDockInput.numCameras > 0, + position: cameraDockBounds.position, + minWidth: cameraDockBounds.minWidth, + width: cameraDockBounds.width, + maxWidth: cameraDockBounds.maxWidth, + minHeight: cameraDockBounds.minHeight, + height: cameraDockBounds.height, + maxHeight: cameraDockBounds.maxHeight, + top: cameraDockBounds.top, + left: cameraDockBounds.left, + right: cameraDockBounds.right, + tabOrder: 4, + isDraggable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + zIndex: cameraDockBounds.zIndex, + focusedId: input.cameraDock.focusedId, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_OUTPUT, + value: { + display: presentationInput.isOpen, + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + tabOrder: DEFAULT_VALUES.presentationTabOrder, + isResizable: false, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SCREEN_SHARE_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SHARED_NOTES_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right + horizontalCameraDiff : null, + }, + }); + }; + + return null; +}; + +export default SmartLayout; \ No newline at end of file diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx new file mode 100644 index 00000000..16bc993e --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx @@ -0,0 +1,524 @@ +import { useEffect, useRef } from 'react'; +import { throttle } from '/imports/utils/throttle'; +import { + layoutDispatch, + layoutSelect, + layoutSelectInput, + layoutSelectOutput, +} from '/imports/ui/components/layout/context'; +import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues'; +import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState'; +import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums'; +import { defaultsDeep } from '/imports/utils/array-utils'; +import { isPresentationEnabled } from '/imports/ui/services/features'; + +const windowWidth = () => window.document.documentElement.clientWidth; +const windowHeight = () => window.document.documentElement.clientHeight; + +const VideoFocusLayout = (props) => { + const { bannerAreaHeight, isMobile } = props; + + function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + } + + const input = layoutSelect((i) => i.input); + const deviceType = layoutSelect((i) => i.deviceType); + const isRTL = layoutSelect((i) => i.isRTL); + const fullscreen = layoutSelect((i) => i.fullscreen); + const fontSize = layoutSelect((i) => i.fontSize); + const currentPanelType = layoutSelect((i) => i.currentPanelType); + + const presentationInput = layoutSelectInput((i) => i.presentation); + const externalVideoInput = layoutSelectInput((i) => i.externalVideo); + const screenShareInput = layoutSelectInput((i) => i.screenShare); + const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes); + + const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation); + const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent); + const cameraDockInput = layoutSelectInput((i) => i.cameraDock); + const actionbarInput = layoutSelectInput((i) => i.actionBar); + const navbarInput = layoutSelectInput((i) => i.navBar); + const layoutContextDispatch = layoutDispatch(); + + const sidebarContentOutput = layoutSelectOutput((i) => i.sidebarContent); + + const prevDeviceType = usePrevious(deviceType); + + const throttledCalculatesLayout = throttle(() => calculatesLayout(), 50, { + trailing: true, + leading: true, + }); + + useEffect(() => { + window.addEventListener('resize', () => { + layoutContextDispatch({ + type: ACTIONS.SET_BROWSER_SIZE, + value: { + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight, + }, + }); + }); + }, []); + + useEffect(() => { + if (deviceType === null) return () => null; + + if (deviceType !== prevDeviceType) { + // reset layout if deviceType changed + // not all options is supported in all devices + init(); + } else { + throttledCalculatesLayout(); + } + }, [input, deviceType, isRTL, fontSize, fullscreen]); + + const init = () => { + const { sidebarContentPanel } = sidebarContentInput; + if (isMobile) { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + SidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: input.externalVideo.hasExternalVideo, + }, + screenShare: { + hasScreenShare: input.screenShare.hasScreenShare, + width: input.screenShare.width, + height: input.screenShare.height, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_LAYOUT_INPUT, + value: defaultsDeep( + { + sidebarNavigation: { + isOpen: + input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false, + }, + sidebarContent: { + isOpen: sidebarContentPanel !== PANELS.NONE, + sidebarContentPanel, + }, + SidebarContentHorizontalResizer: { + isOpen: false, + }, + presentation: { + isOpen: presentationInput.isOpen, + slidesLength: presentationInput.slidesLength, + currentSlide: { + ...presentationInput.currentSlide, + }, + }, + cameraDock: { + numCameras: cameraDockInput.numCameras, + }, + externalVideo: { + hasExternalVideo: input.externalVideo.hasExternalVideo, + }, + screenShare: { + hasScreenShare: input.screenShare.hasScreenShare, + width: input.screenShare.width, + height: input.screenShare.height, + }, + }, + INITIAL_INPUT_STATE + ), + }); + } + Session.set('layoutReady', true); + throttledCalculatesLayout(); + }; + + const calculatesSidebarContentHeight = () => { + const { isOpen, slidesLength } = presentationInput; + const { hasExternalVideo } = externalVideoInput; + const { hasScreenShare } = screenShareInput; + const { isPinned: isSharedNotesPinned } = sharedNotesInput; + + const hasPresentation = isPresentationEnabled() && slidesLength !== 0; + const isGeneralMediaOff = + !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned; + + let minHeight = 0; + let height = 0; + let maxHeight = 0; + if (sidebarContentInput.isOpen) { + if (isMobile) { + height = windowHeight() - DEFAULT_VALUES.navBarHeight - bannerAreaHeight(); + minHeight = height; + maxHeight = height; + } else if (isOpen && !isGeneralMediaOff) { + if (sidebarContentInput.height > 0 && sidebarContentInput.height < windowHeight()) { + height = sidebarContentInput.height - bannerAreaHeight(); + } else { + const { size: slideSize } = presentationInput.currentSlide; + let calculatedHeight = (windowHeight() - bannerAreaHeight()) * 0.3; + + if (slideSize.height > 0 && slideSize.width > 0) { + calculatedHeight = (slideSize.height * sidebarContentOutput.width) / slideSize.width; + } + height = windowHeight() - calculatedHeight - bannerAreaHeight(); + } + maxHeight = windowHeight() * 0.75 - bannerAreaHeight(); + minHeight = windowHeight() * 0.25 - bannerAreaHeight(); + + if (height > maxHeight) { + height = maxHeight; + } + } else { + height = windowHeight() - bannerAreaHeight(); + maxHeight = height; + minHeight = height; + } + } + return { + minHeight, + height, + maxHeight, + }; + }; + + const calculatesCameraDockBounds = (mediaAreaBounds, sidebarSize) => { + const { baseCameraDockBounds } = props; + + const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize); + + // do not proceed if using values from LayoutEngine + if (Object.keys(baseBounds).length > 0) { + return baseBounds; + } + + const { navBarHeight } = DEFAULT_VALUES; + + const cameraDockBounds = {}; + + const mobileCameraHeight = mediaAreaBounds.height * 0.7 - bannerAreaHeight(); + const cameraHeight = mediaAreaBounds.height - bannerAreaHeight(); + + if (isMobile) { + cameraDockBounds.minHeight = mobileCameraHeight; + cameraDockBounds.height = mobileCameraHeight; + cameraDockBounds.maxHeight = mobileCameraHeight; + } else { + cameraDockBounds.minHeight = cameraHeight; + cameraDockBounds.height = cameraHeight; + cameraDockBounds.maxHeight = cameraHeight; + } + + cameraDockBounds.top = navBarHeight + bannerAreaHeight(); + cameraDockBounds.left = !isRTL ? mediaAreaBounds.left : null; + cameraDockBounds.right = isRTL ? sidebarSize : null; + cameraDockBounds.minWidth = mediaAreaBounds.width; + cameraDockBounds.width = mediaAreaBounds.width; + cameraDockBounds.maxWidth = mediaAreaBounds.width; + cameraDockBounds.zIndex = 1; + + return cameraDockBounds; + }; + + const calculatesMediaBounds = ( + mediaAreaBounds, + cameraDockBounds, + sidebarNavWidth, + sidebarContentWidth, + sidebarContentHeight + ) => { + const mediaBounds = {}; + const { element: fullscreenElement } = fullscreen; + + if ( + fullscreenElement === 'Presentation' || + fullscreenElement === 'Screenshare' || + fullscreenElement === 'ExternalVideo' + ) { + mediaBounds.width = windowWidth(); + mediaBounds.height = windowHeight(); + mediaBounds.top = 0; + mediaBounds.left = 0; + mediaBounds.right = 0; + mediaBounds.zIndex = 99; + return mediaBounds; + } + + if (isMobile) { + mediaBounds.height = mediaAreaBounds.height - cameraDockBounds.height; + mediaBounds.left = mediaAreaBounds.left; + mediaBounds.top = mediaAreaBounds.top + cameraDockBounds.height; + mediaBounds.width = mediaAreaBounds.width; + } else if (presentationInput.isOpen) { + mediaBounds.height = windowHeight() - sidebarContentHeight - bannerAreaHeight(); + mediaBounds.left = !isRTL ? sidebarNavWidth : 0; + mediaBounds.right = isRTL ? sidebarNavWidth : 0; + mediaBounds.top = sidebarContentHeight + bannerAreaHeight(); + mediaBounds.width = sidebarContentWidth; + mediaBounds.zIndex = 1; + } else if (!presentationInput.isOpen) { + mediaBounds.width = 0; + mediaBounds.height = 0; + mediaBounds.top = 0; + mediaBounds.left = 0; + } + + return mediaBounds; + }; + + const calculatesLayout = () => { + const { + calculatesNavbarBounds, + calculatesActionbarBounds, + calculatesSidebarNavWidth, + calculatesSidebarNavHeight, + calculatesSidebarNavBounds, + calculatesSidebarContentWidth, + calculatesSidebarContentBounds, + calculatesMediaAreaBounds, + isTablet, + } = props; + const { captionsMargin } = DEFAULT_VALUES; + + const sidebarNavWidth = calculatesSidebarNavWidth(); + const sidebarNavHeight = calculatesSidebarNavHeight(); + const sidebarContentWidth = calculatesSidebarContentWidth(); + const sidebarNavBounds = calculatesSidebarNavBounds(); + const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width); + const mediaAreaBounds = calculatesMediaAreaBounds( + sidebarNavWidth.width, + sidebarContentWidth.width + ); + const navbarBounds = calculatesNavbarBounds(mediaAreaBounds); + const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds); + const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width; + const cameraDockBounds = calculatesCameraDockBounds(mediaAreaBounds, sidebarSize); + const sidebarContentHeight = calculatesSidebarContentHeight(); + const mediaBounds = calculatesMediaBounds( + mediaAreaBounds, + cameraDockBounds, + sidebarNavWidth.width, + sidebarContentWidth.width, + sidebarContentHeight.height + ); + + layoutContextDispatch({ + type: ACTIONS.SET_NAVBAR_OUTPUT, + value: { + display: navbarInput.hasNavBar, + width: navbarBounds.width, + height: navbarBounds.height, + top: navbarBounds.top, + left: navbarBounds.left, + tabOrder: DEFAULT_VALUES.navBarTabOrder, + zIndex: navbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_ACTIONBAR_OUTPUT, + value: { + display: actionbarInput.hasActionBar, + width: actionbarBounds.width, + height: actionbarBounds.height, + innerHeight: actionbarBounds.innerHeight, + top: actionbarBounds.top, + left: actionbarBounds.left, + padding: actionbarBounds.padding, + tabOrder: DEFAULT_VALUES.actionBarTabOrder, + zIndex: actionbarBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAPTIONS_OUTPUT, + value: { + left: !isRTL ? sidebarSize + captionsMargin : null, + right: isRTL ? sidebarSize + captionsMargin : null, + maxWidth: mediaAreaBounds.width - captionsMargin * 2, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT, + value: { + display: sidebarNavigationInput.isOpen, + minWidth: sidebarNavWidth.minWidth, + width: sidebarNavWidth.width, + maxWidth: sidebarNavWidth.maxWidth, + height: sidebarNavHeight, + top: sidebarNavBounds.top, + left: sidebarNavBounds.left, + right: sidebarNavBounds.right, + tabOrder: DEFAULT_VALUES.sidebarNavTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarNavBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: false, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT, + value: { + display: sidebarContentInput.isOpen, + minWidth: sidebarContentWidth.minWidth, + width: sidebarContentWidth.width, + maxWidth: sidebarContentWidth.maxWidth, + minHeight: sidebarContentHeight.minHeight, + height: sidebarContentHeight.height, + maxHeight: sidebarContentHeight.maxHeight, + top: sidebarContentBounds.top, + left: sidebarContentBounds.left, + right: sidebarContentBounds.right, + currentPanelType, + tabOrder: DEFAULT_VALUES.sidebarContentTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: sidebarContentBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE, + value: { + top: false, + right: !isRTL, + bottom: true, + left: isRTL, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_MEDIA_AREA_SIZE, + value: { + width: mediaAreaBounds.width, + height: mediaAreaBounds.height, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_CAMERA_DOCK_OUTPUT, + value: { + display: true, + minWidth: cameraDockBounds.minWidth, + width: cameraDockBounds.width, + maxWidth: cameraDockBounds.maxWidth, + minHeight: cameraDockBounds.minHeight, + height: cameraDockBounds.height, + maxHeight: cameraDockBounds.maxHeight, + top: cameraDockBounds.top, + left: cameraDockBounds.left, + right: cameraDockBounds.right, + tabOrder: 4, + isDraggable: false, + resizableEdge: { + top: false, + right: false, + bottom: false, + left: false, + }, + zIndex: cameraDockBounds.zIndex, + focusedId: input.cameraDock.focusedId, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_OUTPUT, + value: { + display: presentationInput.isOpen, + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right : null, + tabOrder: DEFAULT_VALUES.presentationTabOrder, + isResizable: !isMobile && !isTablet, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_RESIZABLE_EDGE, + value: { + top: true, + right: false, + bottom: false, + left: false, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SCREEN_SHARE_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right : null, + zIndex: mediaBounds.zIndex, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right : null, + }, + }); + + layoutContextDispatch({ + type: ACTIONS.SET_SHARED_NOTES_OUTPUT, + value: { + width: mediaBounds.width, + height: mediaBounds.height, + top: mediaBounds.top, + left: mediaBounds.left, + right: isRTL ? mediaBounds.right : null, + }, + }); + }; + + return null; +}; + +export default VideoFocusLayout; \ No newline at end of file diff --git a/src/2.7.12/imports/ui/components/layout/modal/component.jsx b/src/2.7.12/imports/ui/components/layout/modal/component.jsx new file mode 100644 index 00000000..7494f57d --- /dev/null +++ b/src/2.7.12/imports/ui/components/layout/modal/component.jsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { LAYOUT_TYPE, CAMERADOCK_POSITION } from '/imports/ui/components/layout/enums'; +import SettingsService from '/imports/ui/components/settings/service'; +import deviceInfo from '/imports/utils/deviceInfo'; +import Button from '/imports/ui/components/common/button/component'; +import Styled from './styles'; + +const LayoutModalComponent = (props) => { + const { + intl, + setIsOpen, + isModerator, + isPresenter, + application, + updateSettings, + onRequestClose, + isOpen, + } = props; + + const [selectedLayout, setSelectedLayout] = useState(application.selectedLayout); + const [updateAllUsed, setUpdateAllUsed] = useState(false); + + const BASE_NAME = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename; + + const LAYOUTS_PATH = `${BASE_NAME}/resources/images/layouts/`; + const isKeepPushingLayoutEnabled = SettingsService.isKeepPushingLayoutEnabled(); + + const intlMessages = defineMessages({ + title: { + id: 'app.layout.modal.title', + description: 'Modal title', + }, + update: { + id: 'app.layout.modal.update', + description: 'Modal confirm button', + }, + updateAll: { + id: 'app.layout.modal.updateAll', + description: 'Modal updateAll button', + }, + layoutLabel: { + id: 'app.layout.modal.layoutLabel', + description: 'Layout label', + }, + layoutToastLabelAuto: { + id: 'app.layout.modal.layoutToastLabelAuto', + description: 'Layout toast label', + }, + layoutToastLabel: { + id: 'app.layout.modal.layoutToastLabel', + description: 'Layout toast label', + }, + layoutToastLabelAutoOff: { + id: 'app.layout.modal.layoutToastLabelAutoOff', + description: 'Layout toast label', + }, + customLayout: { + id: 'app.layout.style.custom', + description: 'label for custom layout style', + }, + smartLayout: { + id: 'app.layout.style.smart', + description: 'label for smart layout style', + }, + presentationFocusLayout: { + id: 'app.layout.style.presentationFocus', + description: 'label for presentationFocus layout style', + }, + videoFocusLayout: { + id: 'app.layout.style.videoFocus', + description: 'label for videoFocus layout style', + }, + layoutSingular: { + id: 'app.layout.modal.layoutSingular', + description: 'label for singular layout', + }, + layoutBtnDesc: { + id: 'app.layout.modal.layoutBtnDesc', + description: 'label for singular layout', + }, + }); + + const handleSwitchLayout = (e) => { + setSelectedLayout(e); + }; + + const handleUpdateLayout = (updateAll) => { + const obj = { + application: + { ...application, selectedLayout, pushLayout: updateAll }, + }; + if ((isModerator || isPresenter) && updateAll) { + updateSettings(obj, intlMessages.layoutToastLabelAuto); + setUpdateAllUsed(true); + } else if ((isModerator || isPresenter) && !updateAll && !updateAllUsed) { + updateSettings(obj, intlMessages.layoutToastLabelAutoOff); + setUpdateAllUsed(false); + } else { + updateSettings(obj, intlMessages.layoutToastLabel); + } + setIsOpen(false); + }; + + const renderPushLayoutsOptions = () => { + if (!isModerator && !isPresenter) { + return null; + } + + if (isKeepPushingLayoutEnabled) { + return ( + handleUpdateLayout(true)} + color="secondary" + data-test="updateEveryoneLayoutBtn" + /> + ); + } + return null; + }; + + const renderLayoutButtons = () => ( + + {Object.values(LAYOUT_TYPE) + .map((layout) => ( + + + )} + onClick={() => { + handleSwitchLayout(layout); + if (layout === LAYOUT_TYPE.CUSTOM_LAYOUT && application.selectedLayout !== layout) { + document.getElementById('layout')?.setAttribute('data-cam-position', CAMERADOCK_POSITION.CONTENT_TOP); + } + }} + active={(layout === selectedLayout).toString()} + aria-describedby="layout-btn-desc" + data-test={`${layout}Layout`} + /> + {intl.formatMessage(intlMessages[`${layout}Layout`])} + + ))} + + ); + + return ( + setIsOpen(false)} + title={intl.formatMessage(intlMessages.title)} + {...{ + isOpen, + onRequestClose, + }} + > + + + {renderLayoutButtons()} + + + + {renderPushLayoutsOptions()} +
+ ) : ( + // Until the Dropzone package has fixed the mime type hover validation, the rejectClassName + // prop is being remove to prevent the error styles from being applied to valid file types. + // Error handling is being done in the onDrop prop. + fileValid.extension)} + disablepreview="true" + onDrop={(files, files2) => this.handleFiledrop(files, files2, this, intl, intlMessages)} + > + + + {intl.formatMessage(intlMessages.dropzoneLabel)} +   + + {intl.formatMessage(intlMessages.browseFilesLabel)} + + + + ); + } + + renderExternalUpload() { + const { externalUploadData, intl } = this.props; + + const { + presentationUploadExternalDescription, presentationUploadExternalUrl, + } = externalUploadData; + + if (!presentationUploadExternalDescription || !presentationUploadExternalUrl) return null; + + return ( + +
+ + {intl.formatMessage(intlMessages.externalUploadTitle)} + + +

{presentationUploadExternalDescription}

+
+ window.open(`${presentationUploadExternalUrl}`)} + label={intl.formatMessage(intlMessages.externalUploadLabel)} + aria-describedby={intl.formatMessage(intlMessages.externalUploadLabel)} + /> +
+ ); + } + + renderPicDropzone() { + const { + intl, + } = this.props; + + const { disableActions } = this.state; + + if (disableActions && !this.hasError) return null; + + return this.hasError ? ( +
+
+ ) : ( + this.handleFiledrop(files, files2, this, intl, intlMessages)} + > + + + {intl.formatMessage(intlMessages.dropzoneImagesLabel)} +   + + {intl.formatMessage(intlMessages.browseImagesLabel)} + + + + ); + } + + render() { + const { + isOpen, + isPresenter, + intl, + fileUploadConstraintsHint, + } = this.props; + if (!isPresenter) return null; + const { presentations, disableActions } = this.state; + + let hasNewUpload = false; + + presentations.forEach((item) => { + if (item.id.indexOf(item.filename) !== -1 && item.upload.progress === 0) hasNewUpload = true; + }); + + return ( + <> + + {isOpen + ? ( + + + + {intl.formatMessage(intlMessages.title)} + + + this.handleConfirm()} + disabled={disableActions} + label={hasNewUpload + ? intl.formatMessage(intlMessages.uploadLabel) + : intl.formatMessage(intlMessages.confirmLabel)} + /> + + + + + {`${intl.formatMessage(intlMessages.message)}`} + {fileUploadConstraintsHint ? this.renderExtraHint() : null} + + {this.renderPresentationList()} + {this.renderDownloadableWithAnnotationsHint()} + {isMobile ? this.renderPicDropzone() : null} + {this.renderDropzone()} + {this.renderExternalUpload()} + + + ) + : null} + + ); + } +} + +PresentationUploader.propTypes = propTypes; +PresentationUploader.defaultProps = defaultProps; + +export default injectIntl(PresentationUploader); diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx new file mode 100644 index 00000000..1c322f1b --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx @@ -0,0 +1,69 @@ +import React, { useContext } from 'react'; +import { Meteor } from 'meteor/meteor'; +import { withTracker } from 'meteor/react-meteor-data'; +import ErrorBoundary from '/imports/ui/components/common/error-boundary/component'; +import FallbackModal from '/imports/ui/components/common/fallback-errors/fallback-modal/component'; +import Service from './service'; +import PresUploaderToast from '/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component'; +import PresentationUploader from './component'; +import { UsersContext } from '/imports/ui/components/components-data/users-context/context'; +import Auth from '/imports/ui/services/auth'; +import { + isDownloadPresentationWithAnnotationsEnabled, + isDownloadPresentationOriginalFileEnabled, + isDownloadPresentationConvertedToPdfEnabled, + isPresentationEnabled, +} from '/imports/ui/services/features'; +import { hasAnnotations } from '/imports/ui/components/whiteboard/service'; + +const PRESENTATION_CONFIG = Meteor.settings.public.presentation; + +const PresentationUploaderContainer = (props) => { + const usingUsersContext = useContext(UsersContext); + const { users } = usingUsersContext; + const currentUser = users[Auth.meetingID][Auth.userID]; + const userIsPresenter = currentUser.presenter; + + return userIsPresenter && ( + + + + ); +}; + +export default withTracker(() => { + const presentations = Service.getPresentations(); + const currentPresentation = presentations.find((p) => p.isCurrent)?.id || ''; + const { + dispatchDisableDownloadable, + dispatchEnableDownloadable, + dispatchChangePresentationDownloadable, + exportPresentation, + } = Service; + const isOpen = isPresentationEnabled() && (Session.get('showUploadPresentationView') || false); + + return { + presentations, + currentPresentation, + fileUploadConstraintsHint: PRESENTATION_CONFIG.fileUploadConstraintsHint, + fileSizeMax: PRESENTATION_CONFIG.mirroredFromBBBCore.uploadSizeMax, + filePagesMax: PRESENTATION_CONFIG.mirroredFromBBBCore.uploadPagesMax, + fileValidMimeTypes: PRESENTATION_CONFIG.uploadValidMimeTypes, + allowDownloadOriginal: isDownloadPresentationOriginalFileEnabled(), + allowDownloadConverted: isDownloadPresentationConvertedToPdfEnabled(), + allowDownloadWithAnnotations: isDownloadPresentationWithAnnotationsEnabled(), + handleSave: Service.handleSavePresentation, + handleDismissToast: PresUploaderToast.handleDismissToast, + renderToastList: Service.renderToastList, + renderPresentationItemStatus: PresUploaderToast.renderPresentationItemStatus, + dispatchDisableDownloadable, + dispatchEnableDownloadable, + dispatchChangePresentationDownloadable, + exportPresentation, + isOpen, + selectedToBeNextCurrent: Session.get('selectedToBeNextCurrent') || null, + externalUploadData: Service.getExternalUploadData(), + handleFiledrop: Service.handleFiledrop, + hasAnnotations, + }; +})(PresentationUploaderContainer); diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx new file mode 100644 index 00000000..7e2e9de9 --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx @@ -0,0 +1,207 @@ +import React, { PureComponent } from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import { uniqueId } from '/imports/utils/string-utils'; +import Trigger from '/imports/ui/components/common/control-header/right/component'; +import PresentationDownloadDropdownWrapper from './presentation-download-dropdown-wrapper/component'; + +const intlMessages = defineMessages({ + enableOriginalPresentationDownload: { + id: 'app.presentationUploader.enableOriginalPresentationDownload', + description: 'Send original presentation to chat', + }, + disableOriginalPresentationDownload: { + id: 'app.presentationUploader.disableOriginalPresentationDownload', + description: 'Send original presentation to chat', + }, + sendCurrentStateDocument: { + id: 'app.presentationUploader.exportCurrentStatePresentation', + description: 'Send presentation to chat in the current state label', + }, + copySuccess: { + id: 'app.chat.copySuccess', + description: 'aria success alert', + }, + copyErr: { + id: 'app.chat.copyErr', + description: 'aria error alert', + }, + options: { + id: 'app.presentationUploader.dropdownExportOptions', + description: 'Chat Options', + }, +}); + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + handleDownloadingOfPresentation: PropTypes.func.isRequired, + handleDownloadableChange: PropTypes.func.isRequired, + isDownloadable: PropTypes.bool.isRequired, + allowDownloadOriginal: PropTypes.bool.isRequired, + allowDownloadConverted: PropTypes.bool.isRequired, + allowDownloadWithAnnotations: PropTypes.bool.isRequired, + item: PropTypes.shape({ + id: PropTypes.string.isRequired, + filename: PropTypes.string.isRequired, + filenameConverted: PropTypes.string, + isCurrent: PropTypes.bool.isRequired, + temporaryPresentationId: PropTypes.string, + isDownloadable: PropTypes.bool.isRequired, + isRemovable: PropTypes.bool.isRequired, + conversion: PropTypes.shape({ + done: PropTypes.bool, + error: PropTypes.bool, + status: PropTypes.string, + numPages: PropTypes.number, + pagesCompleted: PropTypes.number, + }), + upload: PropTypes.shape({ + done: PropTypes.bool, + error: PropTypes.bool, + }).isRequired, + exportation: PropTypes.shape({ + status: PropTypes.string, + }), + uploadTimestamp: PropTypes.string, + downloadableExtension: PropTypes.string, + }).isRequired, + closeModal: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, +}; + +class PresentationDownloadDropdown extends PureComponent { + constructor(props) { + super(props); + + this.actionsKey = [ + uniqueId('action-item-'), + uniqueId('action-item-'), + uniqueId('action-item-'), + ]; + } + + getAvailableActions() { + const { + intl, + handleDownloadingOfPresentation, + handleDownloadableChange, + isDownloadable, + allowDownloadOriginal, + allowDownloadConverted, + allowDownloadWithAnnotations, + item, + closeModal, + } = this.props; + + this.menuItems = []; + + const { filenameConverted, filename, downloadableExtension } = item; + const convertedFileExtension = filenameConverted?.split('.').slice(-1)[0]; + const originalFileExtension = filename?.split('.').slice(-1)[0]; + const changeDownloadOriginalOrConvertedPresentation = (enableDownload, fileStateType) => { + handleDownloadableChange(item, fileStateType, enableDownload); + if (enableDownload) { + handleDownloadingOfPresentation(fileStateType); + } + closeModal(); + }; + + if (allowDownloadOriginal) { + if (isDownloadable && !!downloadableExtension + && downloadableExtension === originalFileExtension) { + this.menuItems.push({ + key: this.actionsKey[0], + dataTest: 'disableOriginalPresentationDownload', + label: intl.formatMessage(intlMessages.disableOriginalPresentationDownload, + { 0: originalFileExtension }), + onClick: () => changeDownloadOriginalOrConvertedPresentation(false, 'Original'), + }); + } else { + this.menuItems.push({ + key: this.actionsKey[0], + dataTest: 'enableOriginalPresentationDownload', + label: intl.formatMessage(intlMessages.enableOriginalPresentationDownload, + { 0: originalFileExtension }), + onClick: () => changeDownloadOriginalOrConvertedPresentation(true, 'Original'), + }); + } + } + if (allowDownloadConverted) { + if ((!!filenameConverted && filenameConverted !== '') + && convertedFileExtension !== originalFileExtension) { + if (isDownloadable && !!downloadableExtension + && downloadableExtension === convertedFileExtension) { + this.menuItems.push({ + key: this.actionsKey[0], + dataTest: 'disableOriginalPresentationDownload', + label: intl.formatMessage(intlMessages.disableOriginalPresentationDownload, + { 0: convertedFileExtension }), + onClick: () => changeDownloadOriginalOrConvertedPresentation(false, 'Converted'), + }); + } else { + this.menuItems.push({ + key: this.actionsKey[0], + dataTest: 'enableOriginalPresentationDownload', + label: intl.formatMessage(intlMessages.enableOriginalPresentationDownload, + { 0: convertedFileExtension }), + onClick: () => changeDownloadOriginalOrConvertedPresentation(true, 'Converted'), + }); + } + } + } + if (allowDownloadWithAnnotations) { + this.menuItems.push({ + key: this.actionsKey[1], + id: 'sendCurrentStateDocument', + dataTest: 'sendCurrentStateDocument', + label: intl.formatMessage(intlMessages.sendCurrentStateDocument), + onClick: () => { + closeModal(); + handleDownloadingOfPresentation('Annotated'); + }, + }); + } + return this.menuItems; + } + + render() { + const { intl, disabled } = this.props; + + const customStyles = { zIndex: 9999 }; + + return ( + + null} + /> + )} + opts={{ + id: 'presentation-download-dropdown', + keepMounted: true, + transitionDuration: 0, + elevation: 2, + getcontentanchorel: null, + fullwidth: 'true', + anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, + transformOrigin: { vertical: 'top', horizontal: 'left' }, + }} + actions={this.getAvailableActions()} + /> + + ); + } +} + +PresentationDownloadDropdown.propTypes = propTypes; + +export default injectIntl(PresentationDownloadDropdown); diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx new file mode 100644 index 00000000..d496ece8 --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx @@ -0,0 +1,39 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import Styled from './styles'; + +const propTypes = { + children: PropTypes.shape({}).isRequired, + disabled: PropTypes.bool.isRequired, +}; + +class PresentationDownloadDropdownWrapper extends PureComponent { + validateDisabled(eventHandler, ...args) { + const { disabled } = this.props; + if (!disabled && typeof eventHandler === 'function') { + return eventHandler(...args); + } + + return null; + } + + render() { + const { + disabled, + children, + } = this.props; + + return ( + + {children} + + ); + } +} + +PresentationDownloadDropdownWrapper.propTypes = propTypes; + +export default PresentationDownloadDropdownWrapper; diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js new file mode 100644 index 00000000..728309dd --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +const DropdownMenuWrapper = styled.div` + display: inline-block; + + &[aria-disabled="true"] { + cursor: not-allowed; + opacity: .5; + box-shadow: none; + pointer-events: none; + } +`; + +export default { + DropdownMenuWrapper, +}; diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js new file mode 100644 index 00000000..644e2618 --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js @@ -0,0 +1,497 @@ +import Presentations, { UploadingPresentations } from '/imports/api/presentations'; +import PresentationUploadToken from '/imports/api/presentation-upload-token'; +import Auth from '/imports/ui/services/auth'; +import Poll from '/imports/api/polls/'; +import { Meteor } from 'meteor/meteor'; +import { makeCall } from '/imports/ui/services/api'; +import logger from '/imports/startup/client/logger'; +import { partition } from '/imports/utils/array-utils'; +import update from 'immutability-helper'; +import { Random } from 'meteor/random'; +import Meetings from '/imports/api/meetings'; +import { uniqueId } from '/imports/utils/string-utils'; +import { isPresentationEnabled } from '/imports/ui/services/features'; +import { notify } from '/imports/ui/services/notification'; + +const CONVERSION_TIMEOUT = 300000; +const TOKEN_TIMEOUT = 5000; +const PRESENTATION_CONFIG = Meteor.settings.public.presentation; + +// fetch doesn't support progress. So we use xhr which support progress. +const futch = (url, opts = {}, onProgress) => new Promise((res, rej) => { + const xhr = new XMLHttpRequest(); + + xhr.open(opts.method || 'get', url); + + Object.keys(opts.headers || {}) + .forEach((k) => xhr.setRequestHeader(k, opts.headers[k])); + + xhr.onload = (e) => { + if (e.target.status !== 200) { + return rej(new Error({ code: e.target.status, message: e.target.statusText })); + } + + return res(e.target.responseText); + }; + xhr.onerror = rej; + if (xhr.upload && onProgress) { + xhr.upload.addEventListener('progress', onProgress, false); + } + xhr.send(opts.body); +}); + +const getPresentations = () => Presentations + .find({ + 'conversion.error': false, + }) + .fetch() + .map((presentation) => { + const { + conversion, + current, + downloadable, + removable, + renderedInToast, + temporaryPresentationId, + id, + name, + exportation, + filenameConverted, + downloadableExtension, + } = presentation; + + const uploadTimestamp = id.split('-').pop(); + + return { + id, + filename: name, + renderedInToast, + temporaryPresentationId, + isCurrent: current || false, + upload: { done: true, error: false }, + isDownloadable: downloadable, + isRemovable: removable, + conversion: conversion || { done: true, error: false }, + uploadTimestamp, + exportation: exportation || { error: false }, + filenameConverted, + downloadableExtension, + }; + }); + +const dispatchChangePresentationDownloadable = (presentation, newState, fileStateType) => { + makeCall('setPresentationDownloadable', presentation.id, newState, fileStateType); +}; + +const observePresentationConversion = ( + meetingId, + temporaryPresentationId, + onConversion, +) => new Promise((resolve) => { + // The token is placed as an id before the original one is generated + // in the back-end; + const tokenId = PresentationUploadToken.findOne({ temporaryPresentationId })?.authzToken; + + const conversionTimeout = setTimeout(() => { + onConversion({ + done: true, + error: true, + status: 'TIMEOUT', + }); + }, CONVERSION_TIMEOUT); + + const didValidate = (doc) => { + clearTimeout(conversionTimeout); + resolve(doc); + }; + + Tracker.autorun((c) => { + const query = Presentations.find({ meetingId }); + + query.observe({ + added: (doc) => { + if (doc.temporaryPresentationId !== temporaryPresentationId && doc.id !== tokenId) return; + + if (doc.conversion.status === 'FILE_TOO_LARGE' || doc.conversion.status === 'UNSUPPORTED_DOCUMENT' + || doc.conversion.status === 'CONVERSION_TIMEOUT' || doc.conversion.status === 'INVALID_MIME_TYPE') { + Presentations.update( + { id: tokenId }, { $set: { temporaryPresentationId, renderedInToast: false } }, + ); + onConversion(doc.conversion); + c.stop(); + clearTimeout(conversionTimeout); + } + }, + changed: (newDoc) => { + if (newDoc.temporaryPresentationId !== temporaryPresentationId) return; + + onConversion(newDoc.conversion); + + if (newDoc.conversion.error) { + c.stop(); + clearTimeout(conversionTimeout); + } + + if (newDoc.conversion.done) { + c.stop(); + didValidate(newDoc); + } + }, + }); + }); +}); + +const requestPresentationUploadToken = ( + temporaryPresentationId, + podId, + meetingId, + filename, +) => new Promise((resolve, reject) => { + makeCall('requestPresentationUploadToken', podId, filename, temporaryPresentationId); + + let computation = null; + const timeout = setTimeout(() => { + computation.stop(); + reject(new Error({ code: 408, message: 'requestPresentationUploadToken timeout' })); + }, TOKEN_TIMEOUT); + + Tracker.autorun((c) => { + computation = c; + const sub = Meteor.subscribe('presentation-upload-token', podId, filename, temporaryPresentationId); + if (!sub.ready()) return; + + const PresentationToken = PresentationUploadToken.findOne({ + podId, + meetingId, + temporaryPresentationId, + used: false, + }); + + if (!PresentationToken || !('failed' in PresentationToken)) return; + + if (!PresentationToken.failed) { + clearTimeout(timeout); + resolve(PresentationToken.authzToken); + } + + if (PresentationToken.failed) { + reject(new Error({ code: 401, message: `requestPresentationUploadToken token ${PresentationToken.authzToken} failed` })); + } + }); +}); + +const uploadAndConvertPresentation = ( + file, + downloadable, + podId, + meetingId, + endpoint, + onUpload, + onProgress, + onConversion, +) => { + const temporaryPresentationId = uniqueId(Random.id(20)); + + const data = new FormData(); + data.append('fileUpload', file); + data.append('conference', meetingId); + data.append('room', meetingId); + data.append('temporaryPresentationId', temporaryPresentationId); + + // TODO: Currently the uploader is not related to a POD so the id is fixed to the default + data.append('pod_id', podId); + + data.append('is_downloadable', downloadable); + + const opts = { + method: 'POST', + body: data, + }; + + // If the presentation is from sharedNotes I don't want to + // insert another one, I just need to update it. + UploadingPresentations.upsert({ + filename: file.name, + lastModifiedUploader: false, + }, { + $set: { + temporaryPresentationId, + progress: 0, + filename: file.name, + lastModifiedUploader: true, + upload: { + done: false, + error: false, + }, + uploadTimestamp: new Date(), + }, + }); + + return requestPresentationUploadToken(temporaryPresentationId, podId, meetingId, file.name) + .then((token) => { + makeCall('setUsedToken', token); + UploadingPresentations.upsert({ + temporaryPresentationId, + }, { + $set: { + id: token, + }, + }); + return futch(endpoint.replace('upload', `${token}/upload`), opts, (e) => { + onProgress(e); + const pr = (e.loaded / e.total) * 100; + if (pr !== 100) { + UploadingPresentations.upsert({ temporaryPresentationId }, { $set: { progress: pr } }); + } else { + UploadingPresentations.upsert({ temporaryPresentationId }, { + $set: { + progress: pr, + upload: { + done: true, + error: false, + }, + }, + }); + } + }); + }) + .then(() => observePresentationConversion(meetingId, temporaryPresentationId, onConversion)) + // Trap the error so we can have parallel upload + .catch((error) => { + logger.debug({ + logCode: 'presentation_uploader_service', + extraInfo: { + error, + }, + }, 'Generic presentation upload exception catcher'); + observePresentationConversion(meetingId, temporaryPresentationId, onConversion); + onUpload({ error: true, done: true, status: error.code }); + return Promise.resolve(); + }); +}; + +const uploadAndConvertPresentations = ( + presentationsToUpload, + meetingId, + podId, + uploadEndpoint, +) => Promise.all(presentationsToUpload.map((p) => uploadAndConvertPresentation( + p.file, p.isDownloadable, podId, meetingId, uploadEndpoint, + p.onUpload, p.onProgress, p.onConversion, +))); + +const setPresentation = (presentationId, podId) => { + makeCall('setPresentation', presentationId, podId); +}; + +const removePresentation = (presentationId, podId) => { + const hasPoll = Poll.find({}, { fields: {} }).count(); + if (hasPoll) makeCall('stopPoll'); + makeCall('removePresentation', presentationId, podId); +}; + +const removePresentations = ( + presentationsToRemove, + podId, +) => Promise.all(presentationsToRemove.map((p) => removePresentation(p.id, podId))); + +const persistPresentationChanges = (oldState, newState, uploadEndpoint, podId) => { + const presentationsToUpload = newState.filter((p) => !p.upload.done); + const presentationsToRemove = oldState.filter((p) => !newState.find((u) => { return u.id === p.id })); + + let currentPresentation = newState.find((p) => p.isCurrent); + + return uploadAndConvertPresentations(presentationsToUpload, Auth.meetingID, podId, uploadEndpoint) + .then((presentations) => { + if (!presentations.length && !currentPresentation) return Promise.resolve(); + + // Update the presentation with their new ids + presentations.forEach((p, i) => { + if (p === undefined) return; + presentationsToUpload[i].onDone(p.id); + }); + + return Promise.resolve(presentations); + }) + .then((presentations) => { + if (currentPresentation === undefined) { + setPresentation('', podId); + return Promise.resolve(); + } + + // If its a newly uploaded presentation we need to get it from promise result + if (!currentPresentation.conversion.done) { + const currentIndex = presentationsToUpload.findIndex((p) => p === currentPresentation); + currentPresentation = presentations[currentIndex]; + } + + // skip setting as current if error happened + if (currentPresentation.conversion.error) { + return Promise.resolve(); + } + + return setPresentation(currentPresentation.id, podId); + }) + .then(removePresentations.bind(null, presentationsToRemove, podId)); +}; + +const handleSavePresentation = ( + presentations = [], isFromPresentationUploaderInterface = true, newPres = {}, +) => { + if (!isPresentationEnabled()) { + return null; + } + + const currentPresentations = getPresentations(); + if (!isFromPresentationUploaderInterface) { + if (presentations.length === 0) { + presentations = [...currentPresentations]; + } + presentations = presentations.map((p) => update(p, { + isCurrent: { + $set: false, + }, + })); + newPres.isCurrent = true; + presentations.push(newPres); + } + return persistPresentationChanges( + currentPresentations, + presentations, + PRESENTATION_CONFIG.uploadEndpoint, + 'DEFAULT_PRESENTATION_POD', + ); +}; + +const getExternalUploadData = () => { + const { meetingProp } = Meetings.findOne( + { meetingId: Auth.meetingID }, + { + fields: { + 'meetingProp.presentationUploadExternalDescription': 1, + 'meetingProp.presentationUploadExternalUrl': 1, + }, + }, + ); + + const { presentationUploadExternalDescription, presentationUploadExternalUrl } = meetingProp; + + return { + presentationUploadExternalDescription, + presentationUploadExternalUrl, + }; +}; + +const exportPresentation = (presentationId, observer, fileStateType) => { + let lastStatus = {}; + + Tracker.autorun((c) => { + const cursor = Presentations.find({ id: presentationId }); + + const checkStatus = (exportation) => { + const shouldStop = ['RUNNING', 'PROCESSING'].includes(lastStatus.status) && exportation.status === 'EXPORTED'; + + if (shouldStop) { + observer(exportation, true); + c.stop(); + return; + } + + observer(exportation, false); + lastStatus = exportation; + }; + + cursor.observe({ + added: (doc) => { + checkStatus(doc.exportation); + }, + changed: (doc) => { + checkStatus(doc.exportation); + }, + }); + }); + + makeCall('exportPresentation', presentationId, fileStateType); +}; + +function handleFiledrop(files, files2, that, intl, intlMessages) { + if (that) { + const { fileValidMimeTypes } = that.props; + const { toUploadCount } = that.state; + const validMimes = fileValidMimeTypes.map((fileValid) => fileValid.mime); + const validExtentions = fileValidMimeTypes.map((fileValid) => fileValid.extension); + const [accepted, rejected] = partition( + files.concat(files2), (f) => ( + validMimes.includes(f.type) || validExtentions.includes(`.${f.name.split('.').pop()}`) + ), + ); + + const presentationsToUpload = accepted.map((file) => { + const id = uniqueId(file.name); + + return { + file, + isDownloadable: false, // by default new presentations are set not to be downloadable + isRemovable: true, + id, + filename: file.name, + isCurrent: false, + conversion: { done: false, error: false }, + upload: { done: false, error: false, progress: 0 }, + exportation: { error: false }, + onProgress: (event) => { + if (!event.lengthComputable) { + that.deepMergeUpdateFileKey(id, 'upload', { + progress: 100, + done: true, + }); + + return; + } + + that.deepMergeUpdateFileKey(id, 'upload', { + progress: (event.loaded / event.total) * 100, + done: event.loaded === event.total, + }); + }, + onConversion: (conversion) => { + that.deepMergeUpdateFileKey(id, 'conversion', conversion); + }, + onUpload: (upload) => { + that.deepMergeUpdateFileKey(id, 'upload', upload); + }, + onDone: (newId) => { + that.updateFileKey(id, 'id', newId); + }, + }; + }); + + that.setState(({ presentations }) => ({ + presentations: presentations.concat(presentationsToUpload), + toUploadCount: (toUploadCount + presentationsToUpload.length), + }), () => { + // after the state is set (files have been dropped), + // make the first of the new presentations current + if (presentationsToUpload && presentationsToUpload.length) { + that.handleCurrentChange(presentationsToUpload[0].id); + } + }); + + if (rejected.length > 0) { + notify(intl.formatMessage(intlMessages.rejectedError), 'error'); + } + } +} + +export default { + handleSavePresentation, + getPresentations, + persistPresentationChanges, + dispatchChangePresentationDownloadable, + setPresentation, + requestPresentationUploadToken, + getExternalUploadData, + exportPresentation, + uploadAndConvertPresentation, + handleFiledrop, +}; diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js new file mode 100644 index 00000000..a5de15a5 --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js @@ -0,0 +1,669 @@ +import styled, { css, keyframes } from 'styled-components'; +import Icon from '/imports/ui/components/common/icon/component'; +import Dropzone from 'react-dropzone'; +import Button from '/imports/ui/components/common/button/component'; +import { + fileLineWidth, + iconPaddingMd, + borderSizeLarge, + lgPaddingX, + statusIconSize, + toastMdMargin, + uploadListHeight, + smPaddingX, + smPaddingY, + borderSize, + borderRadius, + lgPaddingY, + mdPaddingY, + modalInnerWidth, + statusInfoHeight, + itemActionsWidth, + uploadIconSize, + iconLineHeight, + mdPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + headingsFontWeight, + fontSizeLarge, + fontSizeLarger, +} from '/imports/ui/stylesheets/styled-components/typography'; +import { + colorGrayLight, + colorGrayDark, + colorPrimary, + colorWhite, + colorDanger, + colorGray, + colorGrayLighter, + colorLink, + colorSuccess, + colorGrayLightest, + colorText, + colorBlueLight, + colorOffWhite, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable'; + +const barStripes = keyframes` + from { background-position: 1rem 0; } + to { background-position: 0 0; } +`; +const rotate = keyframes` + 0% { transform: rotate(0); } + 100% { transform: rotate(360deg); } +`; + +const UploadRow = styled.div` + display: flex; + flex-direction: column; +`; + +const FileLine = styled.div` + display: flex; + flex-direction: row; + align-items: center; + padding-bottom: ${iconPaddingMd}; + width: ${fileLineWidth}; +`; + +const ToastFileName = styled.span` + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + height: 1.25rem !important; + margin-left: ${mdPaddingY}; + height: 1rem; + width: auto; + text-align: left; + font-weight: ${headingsFontWeight}; + + [dir="rtl"] & { + margin-right: ${mdPaddingY}; + margin-left: 0; + text-align: right; + } +`; + +const StatusIcon = styled.span` + & > i { + height: ${statusIconSize}; + width: ${statusIconSize}; + } +`; + +const StatusInfo = styled.div` + padding: 0; + bottom: ${toastMdMargin}; + position: relative; + left: ${borderSizeLarge}; + + [dir="rtl"] & { + right: ${borderSizeLarge}; + left: 0; + } +`; + +const FileList = styled(ScrollboxVertical)` + height: 100%; + max-height: ${uploadListHeight}; + padding: 1px; + margin-bottom: 2rem; + overflow-x: hidden; +`; + +const Table = styled.table` + position: relative; + width: 100%; + border-spacing: 0; + border-collapse: collapse; + + & > tbody { + text-align: left; + + [dir="rtl"] & { + text-align: right; + } + + > tr { + border-bottom: 1px solid ${colorGrayLight}; + + &:last-child { + border-bottom: 0; + } + + &:hover, + &:focus { + background-color: transparentize(#8B9AA8, .85); + } + + th, + td { + padding: calc(${smPaddingY} * 2) calc(${smPaddingX} / 2); + white-space: nowrap; + } + + th { + font-weight: bold; + color: ${colorGrayDark}; + } + } + } +`; + +const VisuallyHidden = styled.th` + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; width: 1px; + margin: -1px; padding: 0; border: 0; +`; + +const ToastWrapper = styled.div` + max-height: 50%; + width: ${fileLineWidth}; +`; + +const UploadToastHeader = styled.div` + position: relative; + margin-bottom: ${toastMdMargin}; + padding-bottom: ${smPaddingX}; +`; + +const UploadIcon = styled(Icon)` + background-color: ${colorPrimary}; + color: ${colorWhite}; + height: ${uploadIconSize}; + width: ${uploadIconSize}; + border-radius: 50%; + font-size: 135%; + line-height: ${iconLineHeight}; + margin-right: ${smPaddingX}; + + [dir="rtl"] & { + margin-left: ${smPaddingX}; + margin-right: 0; + } +`; + +const UploadToastTitle = styled.span` + position: fixed; + font-weight: 600; + margin-top: ${toastMdMargin}; +`; + +const InnerToast = styled(ScrollboxVertical)` + position: relative; + width: 100%; + height: 100%; + max-height: ${uploadListHeight}; + overflow-y: auto; + padding-right: 1.5rem; + box-sizing: content-box; + background: none; + + [dir="rtl"] & { + padding-right: 0; + padding-left: 1.5rem; + } +`; + +const TableItemIcon = styled.td` + width: 1%; + + & > i { + font-size: 1.35rem; + } +`; + +const TableItemCurrent = styled.th` + width: 1%; + + padding-left: 0; + padding-right: inherit; + + [dir="rtl"] & { + padding-left: inherit; + padding-right: 0; + } +`; + +const CurrentLabel = styled.span` + display: inline; + padding: .25em .5em; + font-size: 75%; + font-weight: 700; + line-height: 1; + color: ${colorWhite}; + background: ${colorPrimary}; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; + text-transform: uppercase; +`; + +const TableItemName = styled.th` + height: 1rem; + width: auto; + position: relative; + + &:before { + content: "\\00a0"; + visibility: hidden; + } + + & > span { + min-width: 0; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + position: absolute; + left: 0; + right: 0; + + [dir="rtl"] & { + right: 1rem; + } + } +`; + +const TableItemStatus = styled.td` + width: 1%; + + text-align: right; + + [dir="rtl"] & { + text-align: left; + } +`; + +const ItemAction = styled.div` + margin-left: ${smPaddingX}; + &, & i { + margin-top: .25rem; + display: inline-block; + border: 0; + background: transparent; + cursor: pointer; + font-size: 1.35rem; + color: ${colorGrayLight}; + padding: 0; + ${({ animations }) => animations && ` + transition: all .25s; + `} + :hover, :focus { + padding: unset !important; + } + } +`; + +const RemoveButton = styled(Button)` + [dir="ltr"] & { + margin-left: ${smPaddingX}; + } + + [dir="rtl"] & { + margin-right: ${smPaddingX}; + } + + div > i { + margin-top: .25rem; + } + + &, + & > i { + display: inline-block; + border: 0; + background: transparent; + cursor: pointer; + font-size: 1.35rem; + color: ${colorGrayLight}; + padding: 0; + + ${({ animations }) => animations && ` + transition: all .25s; + `} + + :hover, :focus { + padding: unset !important; + } + } + + background-color: transparent; + border: 0 !important; + + & > i:focus, + & > i:hover { + color: ${colorDanger} !important; + } + + &[aria-disabled="true"] { + cursor: not-allowed; + opacity: .5; + box-shadow: none; + pointer-events: none; + } +`; + +const UploaderDropzone = styled(Dropzone)` + flex: auto; + border: ${borderSize} dashed ${colorGray}; + color: ${colorGray}; + border-radius: ${borderRadius}; + padding: calc(${lgPaddingY} * 2.5) ${lgPaddingX}; + text-align: center; + font-size: ${fontSizeLarge}; + cursor: pointer; + + & .dropzoneActive { + background-color: ${colorGrayLighter}; + } +`; + +const DropzoneIcon = styled(Icon)` + font-size: calc(${fontSizeLarge} * 3); +`; + +const DropzoneMessage = styled.p` + margin: ${mdPaddingY} 0; +`; + +const DropzoneLink = styled.span` + color: ${colorLink}; + text-decoration: underline; + font-size: 80%; + display: block; +`; + +const UploaderModal = styled.div` + background-color: white; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1300; +`; + +const ModalInner = styled.div` + margin-left: auto; + margin-right: auto; + width: ${modalInnerWidth}; + max-height: 100%; + max-width: 100%; + padding-bottom: .75rem; + overflow-y: auto; + + @media ${smallOnly} { + padding-left: ${statusInfoHeight}; + padding-right: ${statusInfoHeight}; + } +`; + +const ModalHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + border-bottom:${borderSize} solid ${colorGrayLighter}; + margin-bottom: 2rem; + padding: ${mdPaddingX} 0; + + div { + display: flex; + flex-direction: row; + justify-content: space-between; + } +`; + +const ActionWrapper = styled.div` + display: flex; + align-items: center; + margin: 0 0.25rem; +`; + +const DismissButton = styled(Button)` + min-width: 6rem; + height: 1.875rem; + margin-right: ${toastMdMargin}; +`; + +const ConfirmButton = styled(Button)` + min-width: 6rem; + height: 1.875rem; +`; + +const ModalHint = styled.div` + margin-bottom: 1rem; + color: ${colorText}; + font-weight: normal; +`; + +const ToastItemIcon = styled(Icon)` + position: relative; + width: ${statusIconSize}; + height: ${statusIconSize}; + font-size: 117%; + left: ${statusInfoHeight}; + + [dir="rtl"] & { + left: unset; + right: ${statusInfoHeight}; + } + + ${({ done }) => done && ` + color: ${colorSuccess}; + `} + + ${({ error }) => error && ` + color: ${colorDanger}; + `} + + ${({ loading }) => loading && css` + color: ${colorGrayLightest}; + border: 1px solid; + border-radius: 50%; + border-right-color: ${({ color }) => color || colorGray}; + animation: ${rotate} 1s linear infinite; + `} +`; + +const StatusInfoSpan = styled.span` + font-size: 70%; + + ${({ styles }) => styles === 'error' && ` + display: inline-block; + color: ${colorDanger}; + `} +`; + +const PresentationItem = styled.tr` + ${({ isNew }) => isNew && ` + background-color: rgba(0, 128, 129, 0.05); + `} + + ${({ uploading }) => uploading && ` + background-color: rgba(0, 128, 129, 0.25); + `} + + ${({ converting }) => converting && ` + background-color: rgba(0, 128, 129, 0.25); + `} + + ${({ error }) => error && ` + background-color: rgba(223, 39, 33, 0.25); + `} + + ${({ animated }) => animated && ` + background-image: linear-gradient(45deg, + rgba(255, 255, 255, .15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, .15) 50%, + rgba(255, 255, 255, .15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; + + ${({ animations }) => animations && css` + animation: ${barStripes} 1s linear infinite; + `} + } + `} +`; + +const TableItemActions = styled.td` + width: 1%; + min-width: ${itemActionsWidth}; + text-align: left; + + [dir="rtl"] & { + text-align: right; + } + + ${({ notDownloadable }) => notDownloadable && ` + min-width: 48px; + `} +`; + +const ExtraHint = styled.div` + margin-top: 1rem; + font-weight: bold; +`; + +const ExternalUpload = styled.div` + background-color: ${colorOffWhite}; + border-radius: ${borderRadius}; + margin-top: 2rem; + padding: ${lgPaddingX}; + color: ${colorText}; + font-weight: normal; + display: flex; + justify-content: space-between; + flex-direction: row; + + & p { + margin: 0; + } +`; + +const ExternalUploadTitle = styled.h4` + font-size: 0.9rem; + margin: 0; +`; + +const ExternalUploadButton = styled(Button)` + height: 2rem; + align-self: center; + margin-left: 2rem; +`; + +const ExportHint = styled(ModalHint)` + margin: 2rem 0; +`; + +const SetCurrentAction = styled.td` + width: 0; + + &, & i { + border: 0; + background: transparent; + cursor: pointer; + font-size: 1.35rem; + + [dir="ltr"] & { + padding-left: 0 !important; + } + + [dir="rtl"] & { + padding-right: 0 !important; + } + + ${({ animations }) => animations && ` + transition: all .25s; + `} + } +`; + +const Head = styled.tr` + color: ${colorText}; + + th { + padding: calc(${smPaddingY} * 2) calc(${smPaddingX} / 2); + white-space: nowrap; + text-align: left; + + [dir="rtl"] & { + text-align: right; + } + + &:first-child { + [dir="ltr"] & { + padding-left: 0; + } + + [dir="rtl"] & { + padding-right: 0; + } + } + } +`; + +const Title = styled.h1` + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: ${fontSizeLarger}; + font-weight: ${headingsFontWeight}; +`; + +export default { + UploadRow, + FileLine, + ToastFileName, + StatusIcon, + StatusInfo, + FileList, + Table, + VisuallyHidden, + ToastWrapper, + UploadToastHeader, + UploadIcon, + UploadToastTitle, + InnerToast, + TableItemIcon, + TableItemCurrent, + CurrentLabel, + TableItemName, + TableItemStatus, + ItemAction, + RemoveButton, + UploaderDropzone, + DropzoneIcon, + DropzoneMessage, + DropzoneLink, + UploaderModal, + ModalInner, + ModalHeader, + ActionWrapper, + DismissButton, + ConfirmButton, + ModalHint, + ToastItemIcon, + StatusInfoSpan, + PresentationItem, + TableItemActions, + ExtraHint, + ExternalUpload, + ExternalUploadTitle, + ExternalUploadButton, + ExportHint, + SetCurrentAction, + Head, + Title, +}; diff --git a/src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx b/src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx new file mode 100644 index 00000000..f2b47fee --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx @@ -0,0 +1,19 @@ +import React, { Component } from 'react'; + +const injectWbResizeEvent = (WrappedComponent) => class Resize extends Component { + componentDidMount() { + window.dispatchEvent(new Event('resize')); + } + + componentWillUnmount() { + window.dispatchEvent(new Event('resize')); + } + + render() { + return ( + + ); + } +}; + +export default injectWbResizeEvent; diff --git a/src/2.7.12/imports/ui/components/presentation/service.js b/src/2.7.12/imports/ui/components/presentation/service.js new file mode 100644 index 00000000..7c858472 --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/service.js @@ -0,0 +1,244 @@ +import Presentations from '/imports/api/presentations'; +import { Slides, SlidePositions } from '/imports/api/slides'; +import PollService from '/imports/ui/components/poll/service'; +import { safeMatch } from '/imports/utils/string-utils'; + +const POLL_SETTINGS = Meteor.settings.public.poll; +const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom; +const MAX_CHAR_LIMIT = POLL_SETTINGS.maxTypedAnswerLength; + +const getCurrentPresentation = (podId) => Presentations.findOne({ + podId, + current: true, +}); + +const isPresentationDownloadable = (podId) => { + const currentPresentation = getCurrentPresentation(podId); + if (!currentPresentation) { + return null; + } + + return currentPresentation.downloadable; +}; + +const getCurrentSlide = (podId) => { + const currentPresentation = getCurrentPresentation(podId); + + if (!currentPresentation) { + return null; + } + + return Slides.findOne({ + podId, + presentationId: currentPresentation.id, + current: true, + }, { + fields: { + meetingId: 0, + thumbUri: 0, + txtUri: 0, + }, + }); +}; + +const getSlidesLength = (podId) => getCurrentPresentation(podId)?.pages?.length || 0; + +const getSlidePosition = (podId, presentationId, slideId) => SlidePositions.findOne({ + podId, + presentationId, + id: slideId, +}); + +const currentSlidHasContent = () => { + const currentSlide = getCurrentSlide('DEFAULT_PRESENTATION_POD'); + if (!currentSlide) return false; + + const { + content, + } = currentSlide; + + return !!content.length; +}; + +// Utility function to escape special characters for regex +const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Function to create a regex pattern +const createPattern = (values) => new RegExp(`.*(${escapeRegExp(values[0])}\\/${escapeRegExp(values[1])}|${escapeRegExp(values[1])}\\/${escapeRegExp(values[0])}).*`, 'gmi'); + +const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue, falseValue) => { + const { pollTypes } = PollService; + const currentSlide = getCurrentSlide('DEFAULT_PRESENTATION_POD'); + const quickPollOptions = []; + if (!currentSlide) return quickPollOptions; + + let { + content, + } = currentSlide; + + let lines = content.split('\n'); + let questions = []; + let questionLines = []; + + for (let line of lines) { + let startsWithCapital = /^[A-Z]/.test(line); + let isEndOfQuestion = /\?$/.test(line); + + if (startsWithCapital) { + if (questionLines.length > 0) { + questions.push(questionLines.join(' ')); + } + questionLines = []; + } + + questionLines.push(line.trim()); + + if (isEndOfQuestion) { + questions.push(questionLines.join(' ')); + questionLines = []; + } + } + + if (questionLines.length > 0) { + questions.push(questionLines.join(' ')); + } + + const question = questions.filter(q => /^[A-Z].*\?$/.test(q?.trim())); + + if (question?.length > 0) { + question[0] = question[0]?.replace(/\n/g, ' '); + const urlRegex = /\bhttps?:\/\/\S+\b/g; + const hasUrl = safeMatch(urlRegex, question[0], ''); + if (hasUrl.length > 0) question.pop(); + } + + const doubleQuestionRegex = /\?{2}/gm; + const doubleQuestion = safeMatch(doubleQuestionRegex, content, false); + + const yesNoPatt = createPattern([yesValue, noValue]); + const hasYN = safeMatch(yesNoPatt, content, false); + + const trueFalsePatt = createPattern([trueValue, falseValue]); + const hasTF = safeMatch(trueFalsePatt, content, false); + + const pollRegex = /\b[1-9A-Ia-i][.)] .*/g; + let optionsPoll = safeMatch(pollRegex, content, []); + const optionsWithLabels = []; + + if (hasYN) { + optionsPoll = ['yes', 'no']; + } + + if (optionsPoll) { + optionsPoll = optionsPoll.map((opt) => { + const formattedOpt = opt.substring(0, MAX_CHAR_LIMIT); + optionsWithLabels.push(formattedOpt); + return `\r${opt[0]}.`; + }); + } + + optionsPoll.reduce((acc, currentValue) => { + const lastElement = acc[acc.length - 1]; + + if (!lastElement) { + acc.push({ + options: [currentValue], + }); + return acc; + } + + const { + options, + } = lastElement; + + const lastOption = options[options.length - 1]; + + const isLastOptionInteger = !!parseInt(lastOption.charAt(1), 10); + const isCurrentValueInteger = !!parseInt(currentValue.charAt(1), 10); + + if (isLastOptionInteger === isCurrentValueInteger) { + if (currentValue.toLowerCase().charCodeAt(1) > lastOption.toLowerCase().charCodeAt(1)) { + options.push(currentValue); + } else { + acc.push({ + options: [currentValue], + }); + } + } else { + acc.push({ + options: [currentValue], + }); + } + return acc; + }, []).filter(({ + options, + }) => options.length > 1 && options.length < 10).forEach((p) => { + const poll = p; + if (doubleQuestion) poll.multiResp = true; + if (poll.options.length <= 5 || MAX_CUSTOM_FIELDS <= 5) { + const maxAnswer = poll.options.length > MAX_CUSTOM_FIELDS + ? MAX_CUSTOM_FIELDS + : poll.options.length; + quickPollOptions.push({ + type: `${pollTypes.Letter}${maxAnswer}`, + poll, + }); + } else { + quickPollOptions.push({ + type: pollTypes.Custom, + poll, + }); + } + }); + + if (question.length > 0 && optionsPoll.length === 0 && !doubleQuestion && !hasYN && !hasTF) { + quickPollOptions.push({ + type: 'R-', + poll: { + question: question[0], + }, + }); + } + + if (quickPollOptions.length > 0) { + content = content.replace(new RegExp(pollRegex), ''); + } + + const ynPoll = PollService.matchYesNoPoll(yesValue, noValue, content); + const ynaPoll = PollService.matchYesNoAbstentionPoll(yesValue, noValue, abstentionValue, content); + const tfPoll = PollService.matchTrueFalsePoll(trueValue, falseValue, content); + + ynPoll.forEach((poll) => quickPollOptions.push({ + type: pollTypes.YesNo, + poll, + })); + + ynaPoll.forEach((poll) => quickPollOptions.push({ + type: pollTypes.YesNoAbstention, + poll, + })); + + tfPoll.forEach((poll) => quickPollOptions.push({ + type: pollTypes.TrueFalse, + poll, + })); + + const pollQuestion = (question?.length > 0 && question[0]?.replace(/ *\([^)]*\) */g, '')) || ''; + + return { + slideId: currentSlide.id, + quickPollOptions, + optionsWithLabels, + pollQuestion, + }; +}; + +export default { + getCurrentSlide, + getSlidePosition, + isPresentationDownloadable, + currentSlidHasContent, + parseCurrentSlideContent, + getCurrentPresentation, + getSlidesLength, +}; diff --git a/src/2.7.12/imports/ui/components/presentation/slide/component.jsx b/src/2.7.12/imports/ui/components/presentation/slide/component.jsx new file mode 100644 index 00000000..3c106ec1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/slide/component.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Slide = ({ imageUri, svgWidth, svgHeight }) => ( + + {imageUri + // some pdfs lose a white background color during the conversion to svg + // their background color is transparent + // that's why we have a white rectangle covering the whole slide area by default + ? ( + + + + + ) + : null} + +); + +Slide.propTypes = { + // Image Uri + imageUri: PropTypes.string.isRequired, + // Width of the slide (Svg coordinate system) + svgWidth: PropTypes.number.isRequired, + // Height of the slide (Svg coordinate system) + svgHeight: PropTypes.number.isRequired, +}; + +export default Slide; diff --git a/src/2.7.12/imports/ui/components/presentation/styles.js b/src/2.7.12/imports/ui/components/presentation/styles.js new file mode 100644 index 00000000..bc8778ca --- /dev/null +++ b/src/2.7.12/imports/ui/components/presentation/styles.js @@ -0,0 +1,184 @@ +import styled from 'styled-components'; +import { + innerToastWidth, + toastIconSide, + smPaddingX, + smPaddingY, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorPrimary, + colorWhite, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { + fontSizeLarger, +} from '/imports/ui/stylesheets/styled-components/typography'; +import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container'; +import ToastStyled from '/imports/ui/components/common/toast/styles'; + +const VisuallyHidden = styled.span` + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; width: 1px; + margin: -1px; padding: 0; border: 0; +`; + +const PresentationSvg = styled.svg` + object-fit: contain; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + + //always show an arrow by default + cursor: default; + + //double click on the whiteboard shouldn't change the cursor + -moz-user-select: -moz-none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +`; + +const PresentationFullscreenButton = styled(FullscreenButtonContainer)` + z-index: 1; + position: absolute; + top: 0; + right: 0; + left: auto; + cursor: pointer; + + [dir="rtl"] & { + right: auto; + left : 0; + } +`; + +const InnerToastWrapper = styled.div` + width: ${innerToastWidth}; +`; + +const ToastIcon = styled.div` + margin-right: ${smPaddingX}; + [dir="rtl"] & { + margin-right: 0; + margin-left: ${smPaddingX}; + } +`; + +const IconWrapper = styled.div` + background-color: ${colorPrimary}; + width: ${toastIconSide}; + height: ${toastIconSide}; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + + & > i { + position: relative; + color: ${colorWhite}; + font-size: ${fontSizeLarger}; + } +`; + +const ToastTextContent = styled.div` + position: relative; + overflow: hidden; + margin-top: ${smPaddingY}; + + & > div:first-of-type { + font-weight: bold; + } +`; + +const PresentationName = styled.div` + text-overflow: ellipsis; + overflow: hidden; +`; + +const ToastDownload = styled.span` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + a { + color: ${colorPrimary}; + cursor: pointer; + text-decoration: none; + + &:focus, + &:hover, + &:active { + color: ${colorPrimary}; + box-shadow: 0; + } + } +`; + +const PresentationContainer = styled.div` + display: flex; + flex-direction: column; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; +`; + +const Presentation = styled.div` + order: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + overflow: hidden; + position: relative; +`; + +const SvgContainer = styled.div` + width: 100%; + position: relative; + display: flex; + justify-content: center; + align-items: flex-start; +`; + +const WhiteboardSizeAvailable = styled.div` + position: absolute; + height: 100%; + width: 100%; + z-index: -1; +`; + +const PresentationToolbar = styled.div` + display: flex; + overflow-x: visible; + order: 2; + position: absolute; + bottom: 0; +`; + +const ToastSeparator = styled(ToastStyled.Separator)``; + +export default { + VisuallyHidden, + PresentationSvg, + PresentationFullscreenButton, + InnerToastWrapper, + ToastIcon, + IconWrapper, + ToastTextContent, + PresentationName, + ToastDownload, + PresentationContainer, + Presentation, + SvgContainer, + WhiteboardSizeAvailable, + PresentationToolbar, + ToastSeparator, +}; diff --git a/src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx b/src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx new file mode 100644 index 00000000..fbbd9595 --- /dev/null +++ b/src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx @@ -0,0 +1,203 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import Icon from '/imports/ui/components/common/icon/component'; +import { ENTER } from '/imports/utils/keyCodes'; +import Styled from './styles'; +import { Meteor } from 'meteor/meteor'; +import TooltipContainer from '/imports/ui/components/common/tooltip/container'; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; + +const messages = defineMessages({ + lowerHandsLabel: { + id: 'app.statusNotifier.lowerHands', + description: 'text displayed to clear all raised hands', + }, + lowerHandDescOneUser: { + id: 'app.statusNotifier.lowerHandDescOneUser', + description: 'text displayed to clear a single user raised hands', + }, + raisedHandsTitle: { + id: 'app.statusNotifier.raisedHandsTitle', + description: 'heading for raised hands toast', + }, + raisedHandDesc: { + id: 'app.statusNotifier.raisedHandDesc', + description: 'label for multiple users with raised hands', + }, + raisedHandDescOneUser: { + id: 'app.statusNotifier.raisedHandDescOneUser', + description: 'label for a single user with raised hand', + }, + and: { + id: 'app.statusNotifier.and', + description: 'used as conjunction word', + }, +}); + +const MAX_AVATAR_COUNT = 3; + +class RaiseHandNotifier extends Component { + constructor(props) { + super(props); + + this.statusNotifierId = null; + + this.audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename + Meteor.settings.public.app.instanceId}/resources/sounds/bbb-handRaise.mp3`); + + this.renderRaisedHands = this.renderRaisedHands.bind(this); + this.getRaisedHandNames = this.getRaisedHandNames.bind(this); + this.raisedHandAvatars = this.raisedHandAvatars.bind(this); + } + + componentDidUpdate(prevProps) { + const { + raiseHandUsers, raiseHandAudioAlert, raiseHandPushAlert, isViewer, isPresenter, + } = this.props; + + if (isViewer && !isPresenter) { + if (this.statusNotifierId) toast.dismiss(this.statusNotifierId); + return false; + } + + if (raiseHandUsers.length === 0) { + return this.statusNotifierId ? toast.dismiss(this.statusNotifierId) : null; + } + + if (raiseHandAudioAlert && raiseHandUsers.length > prevProps.raiseHandUsers.length) { + this.audio.play(); + } + + if (raiseHandPushAlert) { + if (this.statusNotifierId) { + return toast.update(this.statusNotifierId, { + render: this.renderRaisedHands(), + }); + } + + this.statusNotifierId = toast(this.renderRaisedHands(), { + onClose: () => { this.statusNotifierId = null; }, + autoClose: false, + closeOnClick: false, + closeButton: false, + className: 'raiseHandToast', + }); + } + + return true; + } + + getRaisedHandNames() { + const { raiseHandUsers, intl } = this.props; + if (raiseHandUsers.length === 0) return ''; + + const _names = raiseHandUsers.map((u) => u.name); + const { length } = _names; + const and = intl.formatMessage(messages.and); + let formattedNames = ''; + + switch (length) { + case 1: + formattedNames = _names; + break; + case 2: + formattedNames = _names.join(` ${and} `); + break; + case 3: + formattedNames = _names.slice(0, length - 1).join(', '); + formattedNames += ` ${and} ${_names.slice(length - 1)}`; + break; + default: + formattedNames = _names.slice(0, MAX_AVATAR_COUNT).join(', '); + formattedNames += ` ${and} ${length - MAX_AVATAR_COUNT}+ `; + break; + } + + const raisedHandMessageString = length === 1 + ? messages.raisedHandDescOneUser : messages.raisedHandDesc; + return intl.formatMessage(raisedHandMessageString, { 0: formattedNames }); + } + + raisedHandAvatars() { + const { raiseHandUsers, lowerUserHands, intl } = this.props; + let users = raiseHandUsers; + if (raiseHandUsers.length > MAX_AVATAR_COUNT) users = users.slice(0, MAX_AVATAR_COUNT); + + const avatars = users.map((u) => ( + + lowerUserHands(u.userId)} + onKeyDown={(e) => (e.keyCode === ENTER ? lowerUserHands(u.userId) : null)} + data-test="avatarsWrapperAvatar" + moderator={u.role === ROLE_MODERATOR} + avatar={u.avatar} + > + {u.name.slice(0, 2)} + + + )); + + if (raiseHandUsers.length > MAX_AVATAR_COUNT) { + avatars.push( + + {raiseHandUsers.length} + , + ); + } + + return avatars; + } + + renderRaisedHands() { + const { raiseHandUsers, intl, lowerUserHands } = this.props; + const formattedRaisedHands = this.getRaisedHandNames(); + return ( +
+ + + + + + + {this.raisedHandAvatars()} + + +
{intl.formatMessage(messages.raisedHandsTitle)}
+ {formattedRaisedHands} +
+ + { + raiseHandUsers.map((u) => lowerUserHands(u.userId)); + }} + data-test="raiseHandRejection" + /> +
+ ); + } + + render() { return null; } +} + +export default injectIntl(RaiseHandNotifier); + +RaiseHandNotifier.propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + lowerUserHands: PropTypes.func.isRequired, + raiseHandUsers: PropTypes.instanceOf(Array).isRequired, + raiseHandAudioAlert: PropTypes.bool.isRequired, + raiseHandPushAlert: PropTypes.bool.isRequired, +}; diff --git a/src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx b/src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx new file mode 100644 index 00000000..95f11939 --- /dev/null +++ b/src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx @@ -0,0 +1,47 @@ +import React, { useContext } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Auth from '/imports/ui/services/auth'; +import Users from '/imports/api/users'; +import Settings from '/imports/ui/services/settings'; +import { UsersContext } from '/imports/ui/components/components-data/users-context/context'; +import { makeCall } from '/imports/ui/services/api'; +import RaiseHandNotifier from './component'; + +const ROLE_VIEWER = Meteor.settings.public.user.role_viewer; + +const StatusNotifierContainer = (props) => { + const usingUsersContext = useContext(UsersContext); + const { users } = usingUsersContext; + const currentUser = users[Auth.meetingID][Auth.userID]; + const isViewer = currentUser.role === ROLE_VIEWER; + const isPresenter = currentUser.presenter; + return ( + + ); +}; + +export default withTracker(() => { + const AppSettings = Settings.application; + const raiseHandUsers = Users.find({ + meetingId: Auth.meetingID, + raiseHand: true, + }, { + fields: { + raiseHandTime: 1, raiseHand: 1, userId: 1, name: 1, color: 1, role: 1, avatar: 1, + }, + sort: { raiseHandTime: 1 }, + }).fetch(); + const lowerUserHands = (userId) => makeCall('changeRaiseHand', false, userId); + + return { + lowerUserHands, + raiseHandUsers, + raiseHandAudioAlert: AppSettings.raiseHandAudioAlerts, + raiseHandPushAlert: AppSettings.raiseHandPushAlerts, + }; +})(StatusNotifierContainer); diff --git a/src/2.7.12/imports/ui/components/raisehand-notifier/styles.js b/src/2.7.12/imports/ui/components/raisehand-notifier/styles.js new file mode 100644 index 00000000..0c3b1d95 --- /dev/null +++ b/src/2.7.12/imports/ui/components/raisehand-notifier/styles.js @@ -0,0 +1,151 @@ +import styled from 'styled-components'; +import { + avatarSide, + borderSize, + avatarInset, + smPaddingX, + toastIconSide, + toastMargin, + toastMarginMobile, + avatarWrapperOffset, + jumboPaddingY, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorWhite, + colorGrayLighter, + colorGrayLight, + colorPrimary, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { + fontSizeXL, + fontSizeSmall, +} from '/imports/ui/stylesheets/styled-components/typography'; +import { + smallOnly, +} from '/imports/ui/stylesheets/styled-components/breakpoints'; +import Button from '/imports/ui/components/common/button/component'; +import ToastStyled from '/imports/ui/components/common/toast/styles'; + +const Avatar = styled.div` + cursor: pointer; + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + width: ${avatarSide}; + height: ${avatarSide}; + color: ${colorWhite}; + border-radius: 50%; + border: solid ${borderSize} ${colorWhite}; + margin-left: ${avatarInset}; + text-align: center; + padding: 5px 0; + + &:hover, + &:focus { + border: solid ${borderSize} ${colorGrayLighter}; + } +`; + +const AvatarsExtra = styled.div` + background-color: ${colorGrayLight}; + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + width: ${avatarSide}; + height: ${avatarSide}; + color: ${colorWhite}; + border-radius: 50%; + border: solid ${borderSize} ${colorWhite}; + margin-left: ${avatarInset}; + text-align: center; + padding: 5px 0; +`; + +const ToastIcon = styled.div` + margin-right: ${smPaddingX}; + [dir="rtl"] & { + margin-right: 0; + margin-left: ${smPaddingX}; + } +`; + +const IconWrapper = styled.div` + background-color: ${colorPrimary}; + width: ${toastIconSide}; + height: ${toastIconSide}; + border-radius: 50%; + + & > i { + position: relative; + color: ${colorWhite}; + top: ${toastMargin}; + left: ${toastMargin}; + font-size: ${fontSizeXL}; + + [dir="rtl"] & { + left: 0; + right: 10px; + } + @media ${smallOnly} { + { + top: ${toastMarginMobile}; + left: ${toastMarginMobile}; + + [dir="rtl"] & { + left: 0; + right: ${toastMargin}; + } + } + } + } +`; + +const AvatarsWrapper = styled.div` + display: flex; + flex-direction: row; + position: absolute; + top: ${avatarWrapperOffset}; + right: 1rem; + left: auto; + + [dir="rtl"] & { + left: ${jumboPaddingY}; + right: auto; + } +`; + +const ToastMessage = styled.div` + font-size: ${fontSizeSmall}; + margin-top: ${toastMargin}; + + & > div { + font-weight: bold; + } +`; + +const ClearButton = styled(Button)` + position: relative; + width: 100%; + margin-top: ${toastMargin}; + color: ${colorPrimary}; + + &:focus, + &:hover, + &:active { + color: ${colorPrimary}; + box-shadow: 0; + } +`; + +const ToastSeparator = styled(ToastStyled.Separator)``; + +export default { + Avatar, + AvatarsExtra, + ToastIcon, + IconWrapper, + AvatarsWrapper, + ToastMessage, + ClearButton, + ToastSeparator, +}; diff --git a/src/2.7.12/imports/ui/components/recording/component.jsx b/src/2.7.12/imports/ui/components/recording/component.jsx new file mode 100644 index 00000000..772e5283 --- /dev/null +++ b/src/2.7.12/imports/ui/components/recording/component.jsx @@ -0,0 +1,95 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component'; + +const intlMessages = defineMessages({ + startTitle: { + id: 'app.recording.startTitle', + description: 'start recording title', + }, + stopTitle: { + id: 'app.recording.stopTitle', + description: 'stop recording title', + }, + resumeTitle: { + id: 'app.recording.resumeTitle', + description: 'resume recording title', + }, + startDescription: { + id: 'app.recording.startDescription', + description: 'start recording description', + }, + stopDescription: { + id: 'app.recording.stopDescription', + description: 'stop recording description', + }, +}); + +const propTypes = { + intl: PropTypes.object.isRequired, + toggleRecording: PropTypes.func.isRequired, + recordingTime: PropTypes.number, + recordingStatus: PropTypes.bool, + amIModerator: PropTypes.bool, + isMeteorConnected: PropTypes.bool.isRequired, +}; + +const defaultProps = { + recordingTime: -1, + recordingStatus: false, + amIModerator: false, +}; + +class RecordingComponent extends PureComponent { + render() { + const { + intl, + recordingStatus, + recordingTime, + amIModerator, + toggleRecording, + isMeteorConnected, + isOpen, + onRequestClose, + priority, + setIsOpen, + } = this.props; + + let title; + + if (!recordingStatus) { + title = recordingTime >= 0 ? intl.formatMessage(intlMessages.resumeTitle) + : intl.formatMessage(intlMessages.startTitle); + } else { + title = intl.formatMessage(intlMessages.stopTitle); + } + + if (!amIModerator) return null; + + const description = intl.formatMessage(!recordingStatus + ? intlMessages.startDescription + : intlMessages.stopDescription); + + return ( + + ); + } +} + +RecordingComponent.propTypes = propTypes; +RecordingComponent.defaultProps = defaultProps; + +export default injectIntl(RecordingComponent); diff --git a/src/2.7.12/imports/ui/components/recording/container.jsx b/src/2.7.12/imports/ui/components/recording/container.jsx new file mode 100644 index 00000000..bf9eb4ee --- /dev/null +++ b/src/2.7.12/imports/ui/components/recording/container.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import { makeCall } from '/imports/ui/services/api'; +import { RecordMeetings } from '/imports/api/meetings'; +import Auth from '/imports/ui/services/auth'; +import RecordingComponent from './component'; + +const RecordingContainer = props => ; + +export default withTracker(({ setIsOpen }) => { + const { recording, time } = RecordMeetings.findOne({ meetingId: Auth.meetingID }); + + return ({ + toggleRecording: () => { + makeCall('toggleRecording'); + setIsOpen(false); + }, + + recordingStatus: recording, + recordingTime: time, + isMeteorConnected: Meteor.status().connected, + + }); +})(RecordingContainer); diff --git a/src/2.7.12/imports/ui/components/reload-button/component.jsx b/src/2.7.12/imports/ui/components/reload-button/component.jsx new file mode 100644 index 00000000..fa868df9 --- /dev/null +++ b/src/2.7.12/imports/ui/components/reload-button/component.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Styled from './styles'; + +const ReloadButtonComponent = ({ + handleReload, + label, +}) => ( + + + +); + +export default ReloadButtonComponent; diff --git a/src/2.7.12/imports/ui/components/reload-button/styles.js b/src/2.7.12/imports/ui/components/reload-button/styles.js new file mode 100644 index 00000000..882731a7 --- /dev/null +++ b/src/2.7.12/imports/ui/components/reload-button/styles.js @@ -0,0 +1,39 @@ +import styled from 'styled-components'; +import { colorTransparent } from '/imports/ui/stylesheets/styled-components/palette'; +import Button from '/imports/ui/components/common/button/component'; + +const Wrapper = styled.div` + background-color: ${colorTransparent}; + cursor: pointer; + border: 0; + z-index: 2; + margin: 2px; +`; + +const ReloadButton = styled(Button)` + &, + &:active, + &:hover, + &:focus { + border: none !important; + + i { + border: none !important; + background-color: ${colorTransparent} !important; + } + } + padding: 5px; + + &:hover { + border: 0; + } + + i { + font-size: 1rem; + } +`; + +export default { + Wrapper, + ReloadButton, +}; diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/collection.js b/src/2.7.12/imports/ui/components/screenreader-alert/collection.js new file mode 100644 index 00000000..021409dd --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenreader-alert/collection.js @@ -0,0 +1,3 @@ +const ScreenReaderAlertCollection = new Mongo.Collection('Screenreader-alert', { connection: null }); + +export default ScreenReaderAlertCollection; diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/component.jsx b/src/2.7.12/imports/ui/components/screenreader-alert/component.jsx new file mode 100644 index 00000000..9e9947a8 --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenreader-alert/component.jsx @@ -0,0 +1,18 @@ +import { createPortal } from 'react-dom'; +import { useEffect } from 'react'; +import { removeAlert } from './service'; + +const ARIA_ALERT_EXT_TIMEOUT = 15000; + +const ScreenReaderAlert = ({ olderAlert }) => { + useEffect(() => { + if (olderAlert) setTimeout(() => removeAlert(olderAlert.id), ARIA_ALERT_EXT_TIMEOUT); + }, [olderAlert?.id]); + + const ariaAlertsElement = document.getElementById('aria-polite-alert'); + const shouldAddAlert = olderAlert && olderAlert.text && ariaAlertsElement !== null; + + return shouldAddAlert ? createPortal(olderAlert.text, ariaAlertsElement) : null; +}; + +export default ScreenReaderAlert; diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/container.jsx b/src/2.7.12/imports/ui/components/screenreader-alert/container.jsx new file mode 100644 index 00000000..d5e1e71b --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenreader-alert/container.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import ScreenReaderAlert from './component'; +import ScreenReaderAlertCollection from './collection'; + +const ScreenReaderAlertContainer = ({ ...props }) => { + return ( + + ); +}; + +export default withTracker(() => { + const olderAlert = ScreenReaderAlertCollection + .findOne({}, { sort: { insertTime: +1 } }); + + return { olderAlert }; +})(ScreenReaderAlertContainer); diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/service.js b/src/2.7.12/imports/ui/components/screenreader-alert/service.js new file mode 100644 index 00000000..9e8de17d --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenreader-alert/service.js @@ -0,0 +1,19 @@ +import ScreenReaderAlertCollection from './collection'; +import { uniqueId } from '/imports/utils/string-utils'; + +export const addNewAlert = (text) => { + const payload = { + id: uniqueId('alert-'), + insertedTime: Date.now(), + text, + }; + + return ScreenReaderAlertCollection.insert(payload); +}; + +export const removeAlert = (id) => ScreenReaderAlertCollection.remove({ id }); + +export default { + addNewAlert, + removeAlert, +}; diff --git a/src/2.7.12/imports/ui/components/screenshare/component.jsx b/src/2.7.12/imports/ui/components/screenshare/component.jsx new file mode 100644 index 00000000..94af6b95 --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/component.jsx @@ -0,0 +1,578 @@ +import React from 'react'; +import { injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { debounce } from '/imports/utils/debounce'; +import { Session } from 'meteor/session'; +import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container'; +import SwitchButtonContainer from './switch-button/container'; +import Styled from './styles'; +import VolumeSlider from '../external-video-player/volume-slider/component'; +import AutoplayOverlay from '../media/autoplay-overlay/component'; +import logger from '/imports/startup/client/logger'; +import playAndRetry from '/imports/utils/mediaElementPlayRetry'; +import { notify } from '/imports/ui/services/notification'; +import { + SCREENSHARE_MEDIA_ELEMENT_NAME, + isMediaFlowing, + screenshareHasEnded, + screenshareHasStarted, + setOutputDeviceId, + getMediaElement, + getMediaElementDimensions, + attachLocalPreviewStream, + setVolume, + getVolume, + getStats, +} from '/imports/ui/components/screenshare/service'; +import { + isStreamStateHealthy, + subscribeToStreamStateChange, + unsubscribeFromStreamStateChange, +} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service'; +import { ACTIONS } from '/imports/ui/components/layout/enums'; +import Settings from '/imports/ui/services/settings'; +import deviceInfo from '/imports/utils/deviceInfo'; +import { uniqueId } from '/imports/utils/string-utils'; + +const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen; +const MOBILE_HOVER_TIMEOUT = 5000; +const MEDIA_FLOW_PROBE_INTERVAL = 500; +const SCREEN_SIZE_DISPATCH_INTERVAL = 500; + +class ScreenshareComponent extends React.Component { + static renderScreenshareContainerInside(mainText) { + return ( + + {mainText} + + ); + } + + constructor(props) { + super(); + this.state = { + loaded: false, + autoplayBlocked: false, + mediaFlowing: false, + switched: false, + // Volume control hover toolbar + showHoverToolBar: false, + }; + + this.onLoadedData = this.onLoadedData.bind(this); + this.onLoadedMetadata = this.onLoadedMetadata.bind(this); + this.onVideoResize = this.onVideoResize.bind(this); + this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this); + this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this); + this.failedMediaElements = []; + this.onStreamStateChange = this.onStreamStateChange.bind(this); + this.onSwitched = this.onSwitched.bind(this); + this.handleOnVolumeChanged = this.handleOnVolumeChanged.bind(this); + this.dispatchScreenShareSize = this.dispatchScreenShareSize.bind(this); + this.handleOnMuted = this.handleOnMuted.bind(this); + this.dispatchScreenShareSize = this.dispatchScreenShareSize.bind(this); + this.debouncedDispatchScreenShareSize = debounce( + this.dispatchScreenShareSize, + SCREEN_SIZE_DISPATCH_INTERVAL, + { leading: false, trailing: true }, + ); + + const { locales, icon } = props; + this.locales = locales; + this.icon = icon; + + this.volume = getVolume(); + this.mobileHoverSetTimeout = null; + this.mediaFlowMonitor = null; + } + + componentDidMount() { + const { + isLayoutSwapped, + layoutContextDispatch, + intl, + isPresenter, + startPreviewSizeBig, + outputDeviceId, + isSharedNotesPinned, + } = this.props; + + screenshareHasStarted(isPresenter, { outputDeviceId }); + // Autoplay failure handling + window.addEventListener('screensharePlayFailed', this.handlePlayElementFailed); + // Stream health state tracker to propagate UI changes on reconnections + subscribeToStreamStateChange('screenshare', this.onStreamStateChange); + // Attaches the local stream if it exists to serve as the local presenter preview + attachLocalPreviewStream(getMediaElement()); + + this.setState({ switched: startPreviewSizeBig }); + + notify(intl.formatMessage(this.locales.started), 'info', this.icon); + + layoutContextDispatch({ + type: ACTIONS.SET_HAS_SCREEN_SHARE, + value: true, + }); + + if (isLayoutSwapped) { + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: true, + }); + } + Session.set('pinnedNotesLastState', isSharedNotesPinned); + } + + componentDidUpdate(prevProps) { + const { isPresenter, outputDeviceId } = this.props; + if (prevProps.isPresenter && !isPresenter) { + screenshareHasEnded(); + } + + if (prevProps.outputDeviceId !== outputDeviceId && !isPresenter) { + setOutputDeviceId(outputDeviceId); + } + } + + componentWillUnmount() { + const { + intl, + fullscreenContext, + layoutContextDispatch, + toggleSwapLayout, + pinSharedNotes, + } = this.props; + screenshareHasEnded(); + window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed); + unsubscribeFromStreamStateChange('screenshare', this.onStreamStateChange); + + if (Settings.dataSaving.viewScreenshare) { + notify(intl.formatMessage(this.locales.ended), 'info', this.icon); + } else { + notify(intl.formatMessage(this.locales.endedDueToDataSaving), 'info', this.icon); + } + + layoutContextDispatch({ + type: ACTIONS.SET_HAS_SCREEN_SHARE, + value: false, + }); + + if (fullscreenContext) { + layoutContextDispatch({ + type: ACTIONS.SET_FULLSCREEN_ELEMENT, + value: { + element: '', + group: '', + }, + }); + } + + this.clearMediaFlowingMonitor(); + layoutContextDispatch({ + type: ACTIONS.SET_PRESENTATION_IS_OPEN, + value: Session.get('presentationLastState'), + }); + + pinSharedNotes(Session.get('pinnedNotesLastState')); + } + + clearMediaFlowingMonitor() { + if (this.mediaFlowMonitor) { + Meteor.clearInterval(this.mediaFlowMonitor); + this.mediaFlowMonitor = null; + } + } + + handleAllowAutoplay() { + const { autoplayBlocked } = this.state; + + logger.info({ + logCode: 'screenshare_autoplay_allowed', + }, 'Screenshare media autoplay allowed by the user'); + + window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed); + while (this.failedMediaElements.length) { + const mediaElement = this.failedMediaElements.shift(); + if (mediaElement) { + const played = playAndRetry(mediaElement); + if (!played) { + logger.error({ + logCode: 'screenshare_autoplay_handling_failed', + }, 'Screenshare autoplay handling failed to play media'); + } else { + logger.info({ + logCode: 'screenshare_viewer_media_play_success', + }, 'Screenshare viewer media played successfully'); + } + } + } + if (autoplayBlocked) { this.setState({ autoplayBlocked: false }); } + } + + handlePlayElementFailed(e) { + const { mediaElement } = e.detail; + const { autoplayBlocked } = this.state; + + e.stopPropagation(); + this.failedMediaElements.push(mediaElement); + if (!autoplayBlocked) { + logger.info({ + logCode: 'screenshare_autoplay_prompt', + }, 'Prompting user for action to play screenshare media'); + + this.setState({ autoplayBlocked: true }); + } + } + + async monitorMediaFlow() { + let previousStats = await getStats(); + this.mediaFlowMonitor = Meteor.setInterval(async () => { + const { mediaFlowing: prevMediaFlowing } = this.state; + let mediaFlowing; + + const currentStats = await getStats(); + + try { + mediaFlowing = isMediaFlowing(previousStats, currentStats); + } catch (error) { + // Stats processing failed for whatever reason - maintain previous state + mediaFlowing = prevMediaFlowing; + logger.warn({ + logCode: 'screenshare_media_monitor_stats_failed', + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, 'Failed to collect screenshare stats, flow monitor'); + } + + previousStats = currentStats; + + if (prevMediaFlowing !== mediaFlowing) this.setState({ mediaFlowing }); + }, MEDIA_FLOW_PROBE_INTERVAL); + } + + dispatchScreenShareSize() { + const { + layoutContextDispatch, + } = this.props; + + const { width, height } = getMediaElementDimensions(); + const value = { + width, + height, + browserWidth: window.innerWidth, + browserHeight: window.innerHeight, + }; + + layoutContextDispatch({ + type: ACTIONS.SET_SCREEN_SHARE_SIZE, + value, + }); + } + + onLoadedMetadata() { + const element = getMediaElement(); + + // Track HTMLVideo's resize event to propagate stream size changes to the + // layout engine. See this.onVideoResize; + if (element && typeof element.onresize !== 'function') { + element.onresize = this.onVideoResize; + } + + // Dispatch the initial stream size to the layout engine + this.dispatchScreenShareSize(); + } + + onLoadedData() { + this.setState({ loaded: true }); + } + + onSwitched() { + this.setState((prevState) => ({ switched: !prevState.switched })); + } + + handleOnVolumeChanged(volume) { + this.volume = volume; + setVolume(volume); + } + + handleOnMuted(muted) { + if (muted) { + setVolume(0); + } else { + setVolume(this.volume); + } + } + + onVideoResize() { + // Debounced version of the dispatcher to pace things out - we don't want + // to hog the CPU just for resize recalculations... + this.debouncedDispatchScreenShareSize(); + } + + onStreamStateChange(event) { + const { streamState } = event.detail; + const { mediaFlowing } = this.state; + + const isStreamHealthy = isStreamStateHealthy(streamState); + event.stopPropagation(); + + if (isStreamHealthy) { + this.clearMediaFlowingMonitor(); + // Current state is media not flowing - stream is now healthy so flip it + if (!mediaFlowing) this.setState({ mediaFlowing: isStreamHealthy }); + } else if (this.mediaFlowMonitor == null) this.monitorMediaFlow(); + } + + renderFullscreenButton() { + const { intl, fullscreenElementId, fullscreenContext } = this.props; + + if (!ALLOW_FULLSCREEN) return null; + + return ( + + ); + } + + renderAutoplayOverlay() { + const { intl } = this.props; + + return ( + + ); + } + + renderSwitchButton() { + const { showSwitchPreviewSizeButton } = this.props; + const { switched } = this.state; + + if (!showSwitchPreviewSizeButton) return null; + + return ( + + ); + } + + renderMobileVolumeControlOverlay () { + return ( + { this.overlay = ref; }} + onTouchStart={() => { + clearTimeout(this.mobileHoverSetTimeout); + this.setState({ showHoverToolBar: true }); + }} + onTouchEnd={() => { + this.mobileHoverSetTimeout = setTimeout( + () => this.setState({ showHoverToolBar: false }), + MOBILE_HOVER_TIMEOUT, + ); + }} + /> + ); + } + + renderVolumeSlider() { + const { showHoverToolBar } = this.state; + + let toolbarStyle = 'hoverToolbar'; + + if (deviceInfo.isMobile && !showHoverToolBar) { + toolbarStyle = 'dontShowMobileHoverToolbar'; + } + + if (deviceInfo.isMobile && showHoverToolBar) { + toolbarStyle = 'showMobileHoverToolbar'; + } + + return [( + + + + ), + (deviceInfo.isMobile) && this.renderMobileVolumeControlOverlay(), + ]; + } + + renderVideo(switched) { + const { isGloballyBroadcasting } = this.props; + const { mediaFlowing } = this.state; + + return ( + { + this.videoTag = ref; + }} + muted + /> + ); + } + + renderScreensharePresenter() { + const { switched } = this.state; + const { isGloballyBroadcasting, intl } = this.props; + + return ( + { this.screenshareContainer = ref; }} + > + {isGloballyBroadcasting && this.renderSwitchButton()} + {this.renderVideo(switched)} + + { + isGloballyBroadcasting + ? ( +
+ {!switched + && ScreenshareComponent.renderScreenshareContainerInside( + intl.formatMessage(this.locales.presenterSharingLabel), + )} +
+ ) + : ScreenshareComponent.renderScreenshareContainerInside( + intl.formatMessage(this.locales.presenterLoadingLabel), + ) + } +
+ ); + } + + renderScreenshareDefault() { + const { intl, enableVolumeControl } = this.props; + const { loaded } = this.state; + + return ( + { + this.screenshareContainer = ref; + }} + id="screenshareContainer" + > + {loaded && this.renderFullscreenButton()} + {this.renderVideo(true)} + {loaded && enableVolumeControl && this.renderVolumeSlider() } + + + { + !loaded + ? ScreenshareComponent.renderScreenshareContainerInside( + intl.formatMessage(this.locales.viewerLoadingLabel), + ) + : null + } + + + ); + } + + render() { + const { loaded, autoplayBlocked, mediaFlowing} = this.state; + const { + isPresenter, + isGloballyBroadcasting, + top, + left, + right, + width, + height, + zIndex, + fullscreenContext, + } = this.props; + + // Conditions to render the (re)connecting dots and the unhealthy stream + // grayscale: + // 1 - The local media tag has not received any stream data yet + // 2 - The user is a presenter and the stream wasn't globally broadcasted yet + // 3 - The media was loaded, the stream was globally broadcasted BUT the stream + // state transitioned to an unhealthy stream. tl;dr: screen sharing reconnection + const shouldRenderConnectingState = !loaded + || (isPresenter && !isGloballyBroadcasting) + || (!mediaFlowing && loaded && isGloballyBroadcasting); + + const display = (width > 0 && height > 0) ? 'inherit' : 'none'; + const { animations } = Settings.application; + + return ( +
+ {(shouldRenderConnectingState) + && ( + + + + +
+ + + )} + {autoplayBlocked ? this.renderAutoplayOverlay() : null} + {isPresenter ? this.renderScreensharePresenter() : this.renderScreenshareDefault()} +
+ ); + } +} + +export default injectIntl(ScreenshareComponent); + +ScreenshareComponent.propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + isPresenter: PropTypes.bool.isRequired, + layoutContextDispatch: PropTypes.func.isRequired, + enableVolumeControl: PropTypes.bool.isRequired, + outputDeviceId: PropTypes.string, +}; diff --git a/src/2.7.12/imports/ui/components/screenshare/container.jsx b/src/2.7.12/imports/ui/components/screenshare/container.jsx new file mode 100644 index 00000000..1f3f148d --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/container.jsx @@ -0,0 +1,160 @@ +import React, { useContext } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Auth from '/imports/ui/services/auth'; +import { + getSharingContentType, + getBroadcastContentType, + isScreenGloballyBroadcasting, + isCameraAsContentGloballyBroadcasting, + isScreenBroadcasting, + isCameraAsContentBroadcasting, +} from './service'; +import ScreenshareComponent from './component'; +import { layoutSelect, layoutSelectOutput, layoutDispatch } from '../layout/context'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import { UsersContext } from '/imports/ui/components/components-data/users-context/context'; +import AudioService from '/imports/ui/components/audio/service'; +import { shouldEnableVolumeControl } from './service'; +import MediaService from '/imports/ui/components/media/service'; +import { defineMessages } from 'react-intl'; + +const screenshareIntlMessages = defineMessages({ + // SCREENSHARE + label: { + id: 'app.screenshare.screenShareLabel', + description: 'screen share area element label', + }, + presenterLoadingLabel: { + id: 'app.screenshare.presenterLoadingLabel', + }, + viewerLoadingLabel: { + id: 'app.screenshare.viewerLoadingLabel', + }, + presenterSharingLabel: { + id: 'app.screenshare.presenterSharingLabel', + }, + autoplayBlockedDesc: { + id: 'app.media.screenshare.autoplayBlockedDesc', + }, + autoplayAllowLabel: { + id: 'app.media.screenshare.autoplayAllowLabel', + }, + started: { + id: 'app.media.screenshare.start', + description: 'toast to show when a screenshare has started', + }, + ended: { + id: 'app.media.screenshare.end', + description: 'toast to show when a screenshare has ended', + }, + endedDueToDataSaving: { + id: 'app.media.screenshare.endDueToDataSaving', + description: 'toast to show when a screenshare has ended by changing data savings option', + } +}); + +const cameraAsContentIntlMessages = defineMessages({ + // CAMERA AS CONTENT + label: { + id: 'app.cameraAsContent.cameraAsContentLabel', + description: 'screen share area element label', + }, + presenterLoadingLabel: { + id: 'app.cameraAsContent.presenterLoadingLabel', + }, + viewerLoadingLabel: { + id: 'app.cameraAsContent.viewerLoadingLabel', + }, + presenterSharingLabel: { + id: 'app.cameraAsContent.presenterSharingLabel', + }, + autoplayBlockedDesc: { + id: 'app.media.cameraAsContent.autoplayBlockedDesc', + }, + autoplayAllowLabel: { + id: 'app.media.cameraAsContent.autoplayAllowLabel', + }, + started: { + id: 'app.media.cameraAsContent.start', + description: 'toast to show when camera as content has started', + }, + ended: { + id: 'app.media.cameraAsContent.end', + description: 'toast to show when camera as content has ended', + }, + endedDueToDataSaving: { + id: 'app.media.cameraAsContent.endDueToDataSaving', + description: 'toast to show when camera as content has ended by changing data savings option', + }, +}); +import NotesService from '/imports/ui/components/notes/service'; + +const ScreenshareContainer = (props) => { + const screenShare = layoutSelectOutput((i) => i.screenShare); + const fullscreen = layoutSelect((i) => i.fullscreen); + const layoutContextDispatch = layoutDispatch(); + + const { element } = fullscreen; + const fullscreenElementId = 'Screenshare'; + const fullscreenContext = (element === fullscreenElementId); + + const usingUsersContext = useContext(UsersContext); + const { users } = usingUsersContext; + const currentUser = users[Auth.meetingID][Auth.userID]; + const isPresenter = currentUser.presenter; + + const info = { + screenshare: { + icon: "desktop", + locales: screenshareIntlMessages, + startPreviewSizeBig: false, + showSwitchPreviewSizeButton: true, + }, + camera: { + icon: "video", + locales: cameraAsContentIntlMessages, + startPreviewSizeBig: true, + showSwitchPreviewSizeButton: false, + }, + }; + + const getContentType = () => { + return isPresenter ? getSharingContentType() : getBroadcastContentType(); + } + const contentTypeInfo = info[getContentType()]; + const defaultInfo = info.camera; + const selectedInfo = contentTypeInfo ? contentTypeInfo : defaultInfo; + + if (isScreenBroadcasting() || isCameraAsContentBroadcasting()) { + return ( + + ); + } + return null; +}; + +const LAYOUT_CONFIG = Meteor.settings.public.layout; + +export default withTracker(() => { + return { + isGloballyBroadcasting: isScreenGloballyBroadcasting() || isCameraAsContentGloballyBroadcasting(), + toggleSwapLayout: MediaService.toggleSwapLayout, + hidePresentationOnJoin: getFromUserSettings('bbb_hide_presentation_on_join', LAYOUT_CONFIG.hidePresentationOnJoin), + enableVolumeControl: shouldEnableVolumeControl(), + outputDeviceId: AudioService.outputDeviceId(), + isSharedNotesPinned: MediaService.shouldShowSharedNotes(), + pinSharedNotes: NotesService.pinSharedNotes, + }; +})(ScreenshareContainer); diff --git a/src/2.7.12/imports/ui/components/screenshare/service.js b/src/2.7.12/imports/ui/components/screenshare/service.js new file mode 100644 index 00000000..8f8ae5b7 --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/service.js @@ -0,0 +1,427 @@ +import Screenshare from '/imports/api/screenshare'; +import KurentoBridge from '/imports/api/screenshare/client/bridge'; +import BridgeService from '/imports/api/screenshare/client/bridge/service'; +import Settings from '/imports/ui/services/settings'; +import logger from '/imports/startup/client/logger'; +import { stopWatching } from '/imports/ui/components/external-video-player/service'; +import Meetings from '/imports/api/meetings'; +import Auth from '/imports/ui/services/auth'; +import AudioService from '/imports/ui/components/audio/service'; +import { Meteor } from "meteor/meteor"; +import MediaStreamUtils from '/imports/utils/media-stream-utils'; +import ConnectionStatusService from '/imports/ui/components/connection-status/service'; +import browserInfo from '/imports/utils/browserInfo'; +import NotesService from '/imports/ui/components/notes/service'; + +const VOLUME_CONTROL_ENABLED = Meteor.settings.public.kurento.screenshare.enableVolumeControl; +const SCREENSHARE_MEDIA_ELEMENT_NAME = 'screenshareVideo'; + +const DEFAULT_SCREENSHARE_STATS_TYPES = [ + 'outbound-rtp', + 'inbound-rtp', +]; + +const CONTENT_TYPE_CAMERA = "camera"; +const CONTENT_TYPE_SCREENSHARE = "screenshare"; + +let _isSharingScreen = false; +const _isSharingDep = { + value: false, + tracker: new Tracker.Dependency(), +}; + +const _sharingContentTypeDep = { + value: false, + tracker: new Tracker.Dependency(), +}; + +const _cameraAsContentDeviceIdTypeDep = { + value: '', + tracker: new Tracker.Dependency(), +}; + +const isSharing = () => { + _isSharingDep.tracker.depend(); + return _isSharingDep.value; +}; + +const setIsSharing = (isSharing) => { + if (_isSharingDep.value !== isSharing) { + _isSharingDep.value = isSharing; + _isSharingDep.tracker.changed(); + } +}; + +const setSharingContentType = (contentType) => { + if (_sharingContentTypeDep.value !== contentType) { + _sharingContentTypeDep.value = contentType; + _sharingContentTypeDep.tracker.changed(); + } +} + +const getSharingContentType = () => { + _sharingContentTypeDep.tracker.depend(); + return _sharingContentTypeDep.value; +}; + +const getCameraAsContentDeviceId = () => { + _cameraAsContentDeviceIdTypeDep.tracker.depend(); + return _cameraAsContentDeviceIdTypeDep.value; +}; + +const setCameraAsContentDeviceId = (deviceId) => { + if (_cameraAsContentDeviceIdTypeDep.value !== deviceId) { + _cameraAsContentDeviceIdTypeDep.value = deviceId; + _cameraAsContentDeviceIdTypeDep.tracker.changed(); + } +}; + +const _trackStreamTermination = (stream, handler) => { + if (typeof stream !== 'object' || typeof handler !== 'function') { + throw new TypeError('Invalid trackStreamTermination arguments'); + } + let _handler = handler; + + // Dirty, but effective way of checking whether the browser supports the 'inactive' + // event. If the oninactive interface is null, it can be overridden === supported. + // If undefined, it's not; so we fallback to the track 'ended' event. + // The track ended listener should probably be reviewed once we create + // thin wrapper classes for MediaStreamTracks as well, because we'll want a single + // media stream holding multiple tracks in the future + if (stream.oninactive !== undefined) { + if (typeof stream.oninactive === 'function') { + const oldHandler = stream.oninactive; + _handler = () => { + oldHandler(); + handler(); + }; + } + stream.addEventListener('inactive', handler, { once: true }); + } else { + const track = MediaStreamUtils.getVideoTracks(stream)[0]; + if (track) { + track.addEventListener('ended', handler, { once: true }); + if (typeof track.onended === 'function') { + const oldHandler = track.onended; + _handler = () => { + oldHandler(); + handler(); + }; + } + track.onended = _handler; + } + } +}; + +const _isStreamActive = (stream) => { + const tracksAreActive = !stream.getTracks().some(track => track.readyState === 'ended'); + + return tracksAreActive && stream.active; +} + +const _handleStreamTermination = () => { + screenshareHasEnded(); +}; + +// A simplified, trackable version of isScreenBroadcasting that DOES NOT +// account for the presenter's local sharing state. +// It reflects the GLOBAL screen sharing state (akka-apps) +const isScreenGloballyBroadcasting = () => { + const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_SCREENSHARE }, + { fields: { 'screenshare.stream': 1 } }); + + return (!screenshareEntry ? false : !!screenshareEntry.screenshare.stream); +} + +// A simplified, trackable version of isCameraContentBroadcasting that DOES NOT +// account for the presenter's local sharing state. +// It reflects the GLOBAL camera as content sharing state (akka-apps) +const isCameraAsContentGloballyBroadcasting = () => { + const cameraAsContentEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_CAMERA }, + { fields: { 'screenshare.stream': 1 } }); + + return (!cameraAsContentEntry ? false : !!cameraAsContentEntry.screenshare.stream); +} + +// when the meeting information has been updated check to see if it was +// screensharing. If it has changed either trigger a call to receive video +// and display it, or end the call and hide the video +const isScreenBroadcasting = () => { + const sharing = isSharing() && getSharingContentType() == CONTENT_TYPE_SCREENSHARE; + const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_SCREENSHARE }, + { fields: { 'screenshare.stream': 1 } }); + const screenIsShared = !screenshareEntry ? false : !!screenshareEntry.screenshare.stream; + + if (screenIsShared && isSharing) { + setIsSharing(false); + } + + return sharing || screenIsShared; +}; + +// when the meeting information has been updated check to see if it was +// sharing camera as content. If it has changed either trigger a call to receive video +// and display it, or end the call and hide the video +const isCameraAsContentBroadcasting = () => { + const sharing = isSharing() && getSharingContentType() === CONTENT_TYPE_CAMERA; + const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_CAMERA }, + { fields: { 'screenshare.stream': 1 } }); + const cameraAsContentIsShared = !screenshareEntry ? false : !!screenshareEntry.screenshare.stream; + + if (cameraAsContentIsShared && isSharing) { + setIsSharing(false); + } + + return sharing || cameraAsContentIsShared; +}; + + +const screenshareHasAudio = () => { + const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID }, + { fields: { 'screenshare.hasAudio': 1 } }); + + if (!screenshareEntry) { + return false; + } + + return !!screenshareEntry.screenshare.hasAudio; +} + +const getBroadcastContentType = () => { + const screenshareEntry = Screenshare.findOne({meetindId: Auth.meedingID}, + { fields: { 'screenshare.contentType': 1} }); + + if (!screenshareEntry) { + // defaults to contentType: "camera" + return CONTENT_TYPE_CAMERA; + } + + return screenshareEntry.screenshare.contentType; +} + +const screenshareHasEnded = () => { + if (isSharing()) { + setIsSharing(false); + } + if (getSharingContentType() === CONTENT_TYPE_CAMERA) { + setCameraAsContentDeviceId(''); + } + + KurentoBridge.stop(); +}; + +const getMediaElement = () => { + return document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME); +} + +const getMediaElementDimensions = () => { + const element = getMediaElement(); + return { + width: element?.videoWidth ?? 0, + height: element?.videoHeight ?? 0, + }; +}; + +const setVolume = (volume) => { + KurentoBridge.setVolume(volume); +}; + +const getVolume = () => KurentoBridge.getVolume(); + +const shouldEnableVolumeControl = () => VOLUME_CONTROL_ENABLED && screenshareHasAudio(); + +const attachLocalPreviewStream = (mediaElement) => { + const {isTabletApp} = browserInfo; + if (isTabletApp) { + // We don't show preview for mobile app, as the stream is only available in native code + return; + } + const stream = KurentoBridge.gdmStream; + if (stream && mediaElement) { + // Always muted, presenter preview. + BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, true); + } +}; + +const setOutputDeviceId = (outputDeviceId) => { + const screenShareElement = document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME); + const sinkIdSupported = screenShareElement && typeof screenShareElement.setSinkId === 'function'; + const srcStream = screenShareElement?.srcObject; + + if (typeof outputDeviceId === 'string' + && sinkIdSupported + && screenShareElement.sinkId !== outputDeviceId + && srcStream + && srcStream.getAudioTracks().length > 0) { + try { + screenShareElement.setSinkId(outputDeviceId); + logger.debug({ + logCode: 'screenshare_output_device_change', + extraInfo: { + newDeviceId: outputDeviceId, + }, + }, `Screenshare output device changed: to ${outputDeviceId || 'default'}`); + } catch (error) { + logger.error({ + logCode: 'screenshare_output_device_change_failure', + extraInfo: { + errorName: error.name, + errorMessage: error.message, + newDeviceId: outputDeviceId, + }, + }, `Error changing screenshare output device - {${error.name}: ${error.message}}`); + } + } +}; + +const screenshareHasStarted = (isPresenter, options = {}) => { + // Presenter's screen preview is local, so skip + if (!isPresenter) { + viewScreenshare({ outputDeviceId: options.outputDeviceId }); + } +}; + +const shareScreen = async (isPresenter, onFail, options = {}) => { + if (isCameraAsContentBroadcasting()) { + screenshareHasEnded(); + } + // stop external video share if running + const meeting = Meetings.findOne({ meetingId: Auth.meetingID }); + + if (meeting && meeting.externalVideoUrl) { + stopWatching(); + } + + try { + let stream; + let contentType = CONTENT_TYPE_SCREENSHARE; + if (options.stream == null) { + stream = await BridgeService.getScreenStream(); + } else { + contentType = CONTENT_TYPE_CAMERA; + stream = options.stream; + } + _trackStreamTermination(stream, _handleStreamTermination); + + if (!isPresenter) { + MediaStreamUtils.stopMediaStreamTracks(stream); + return; + } + + await KurentoBridge.share(stream, onFail, contentType); + + // Stream might have been disabled in the meantime. I love badly designed + // async components like this screen sharing bridge :) - prlanzarin 09 May 22 + if (!_isStreamActive(stream)) { + _handleStreamTermination(); + return; + } + + // Close Shared Notes if open. + NotesService.pinSharedNotes(false); + + setSharingContentType(contentType); + setIsSharing(true); + } catch (error) { + onFail(error); + } +}; + +const viewScreenshare = (options = {}) => { + const hasAudio = screenshareHasAudio(); + KurentoBridge.view({ hasAudio, outputDeviceId: options.outputDeviceId }) + .catch((error) => { + logger.error({ + logCode: 'screenshare_view_failed', + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, 'Screenshare viewer failure'); + }); +}; + +const screenShareEndAlert = () => AudioService + .playAlertSound(`${Meteor.settings.public.app.cdn + + Meteor.settings.public.app.basename + + Meteor.settings.public.app.instanceId}` + + '/resources/sounds/ScreenshareOff.mp3'); + +const dataSavingSetting = () => Settings.dataSaving.viewScreenshare; + +/** + * Get stats about all active screenshare peers. + * + * For more information see: + * - https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats + * - https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport + + * @param {Array[String]} statsType - An array containing valid RTCStatsType + * values to include in the return object + * + * @returns {Object} The information about each active screen sharing peer. + * The returned format follows the format returned by video's service + * getStats, which considers more than one peer connection to be returned. + * The format is given by: + * { + * peerIdString: RTCStatsReport + * } + */ +const getStats = async (statsTypes = DEFAULT_SCREENSHARE_STATS_TYPES) => { + const screenshareStats = {}; + const peer = KurentoBridge.getPeerConnection(); + + if (!peer) return null; + + const peerStats = await peer.getStats(); + + peerStats.forEach((stat) => { + if (statsTypes.includes(stat.type)) { + screenshareStats[stat.type] = stat; + } + }); + + return { screenshareStats }; +}; + +// This method may throw errors +const isMediaFlowing = (previousStats, currentStats) => { + const bpsData = ConnectionStatusService.calculateBitsPerSecond( + currentStats?.screenshareStats, + previousStats?.screenshareStats, + ); + const bpsDataAggr = Object.values(bpsData) + .reduce((sum, partialBpsData = 0) => sum + parseFloat(partialBpsData), 0); + + return bpsDataAggr > 0; +}; + +export { + SCREENSHARE_MEDIA_ELEMENT_NAME, + isMediaFlowing, + isScreenBroadcasting, + isCameraAsContentBroadcasting, + screenshareHasEnded, + screenshareHasStarted, + screenshareHasAudio, + getBroadcastContentType, + shareScreen, + screenShareEndAlert, + dataSavingSetting, + isSharing, + setIsSharing, + setSharingContentType, + getSharingContentType, + getMediaElement, + getMediaElementDimensions, + attachLocalPreviewStream, + isScreenGloballyBroadcasting, + isCameraAsContentGloballyBroadcasting, + getStats, + setVolume, + getVolume, + shouldEnableVolumeControl, + setCameraAsContentDeviceId, + getCameraAsContentDeviceId, + setOutputDeviceId, +}; diff --git a/src/2.7.12/imports/ui/components/screenshare/styles.js b/src/2.7.12/imports/ui/components/screenshare/styles.js new file mode 100644 index 00000000..b5f948aa --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/styles.js @@ -0,0 +1,105 @@ +import styled from 'styled-components'; +import { + colorWhite, + colorContentBackground, +} from '/imports/ui/stylesheets/styled-components/palette'; +import SpinnerStyles from '/imports/ui/components/common/loading-screen/styles'; + +const ScreenshareContainerInside = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; +`; + +const MainText = styled.h1` + color: ${colorWhite}; + font-size: 1.3rem; + font-weight: 600; +`; + +const ScreenshareVideo = styled.video` + ${({ unhealthyStream }) => unhealthyStream && ` + filter: grayscale(50%) opacity(50%); + `} +`; + +const ScreenshareContainer = styled.div` + position: relative; + background-color: ${colorContentBackground}; + width: 100%; + height: 100%; + + ${({ switched }) => !switched && ` + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + `} +`; + +const ScreenshareContainerDefault = styled.div` + position: absolute; + align-items: center; + justify-content: center; + padding-top: 4rem; +`; + +const SpinnerWrapper = styled.div` + position: absolute; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + width: 100%; + height: 100%; +`; + +const Spinner = styled(SpinnerStyles.Spinner)``; + +const Bounce1 = styled(SpinnerStyles.Bounce1)``; + +const Bounce2 = styled(SpinnerStyles.Bounce2)``; + +const MobileControlsOverlay = styled.span` + position: absolute; + top:0; + left: 0; + width: 100%; + height: 100%; + background-color: transparent; +`; + +const HoverToolbar = styled.div` + ${({ toolbarStyle }) => toolbarStyle === 'hoverToolbar' && ` + display: none; + + #screenshareContainer:hover > & { + display: flex; + } + `} + + ${({ toolbarStyle }) => toolbarStyle === 'dontShowMobileHoverToolbar' && ` + display: none; + `} + + ${({ toolbarStyle }) => toolbarStyle === 'showMobileHoverToolbar' && ` + display: flex; + z-index: 2; + `} +`; + +export default { + ScreenshareContainerInside, + MainText, + ScreenshareVideo, + ScreenshareContainer, + ScreenshareContainerDefault, + SpinnerWrapper, + Spinner, + Bounce1, + Bounce2, + MobileControlsOverlay, + HoverToolbar, +}; diff --git a/src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx b/src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx new file mode 100644 index 00000000..e7bf4ab4 --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + switchButtonShrink: { + id: 'app.switchButton.shrinkLabel', + description: 'shrink label', + }, + switchButtonExpand: { + id: 'app.switchButton.expandLabel', + description: 'expand label', + }, +}); + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + dark: PropTypes.bool, + bottom: PropTypes.bool, + handleSwitch: PropTypes.func, + switched: PropTypes.bool, +}; + +const defaultProps = { + dark: false, + bottom: false, + handleSwitch: () => {}, + switched: false, +}; + +const SwitchButtonComponent = (props) => { + const { + intl, + dark, + bottom, + handleSwitch, + switched, + } = props; + const formattedLabel = intl.formatMessage(switched + ? intlMessages.switchButtonShrink + : intlMessages.switchButtonExpand); + + return ( + + + + ); +}; + +SwitchButtonComponent.propTypes = propTypes; +SwitchButtonComponent.defaultProps = defaultProps; + +export default injectIntl(SwitchButtonComponent); diff --git a/src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx b/src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx new file mode 100644 index 00000000..8b3e5ef1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import SwitchButtonComponent from './component'; + +const SwitchButtonContainer = props => ; + +export default props => ( + +); diff --git a/src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js b/src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js new file mode 100644 index 00000000..94f1c68f --- /dev/null +++ b/src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js @@ -0,0 +1,70 @@ +import styled from 'styled-components'; +import { + colorWhite, + colorTransparent, + colorBlack, +} from '/imports/ui/stylesheets/styled-components/palette'; +import Button from '/imports/ui/components/common/button/component'; + +const SwitchButtonWrapper = styled.div` + position: absolute; + right: 0; + left: auto; + background-color: ${colorTransparent}; + cursor: pointer; + border: 0; + z-index: 2; + margin: 2px; + + [dir="rtl"] & { + right: auto; + left: 1.75rem; + } + + ${({ dark }) => dark && ` + background-color: rgba(0,0,0,.3); + + & button i { + color: ${colorWhite}; + } + `} + + ${({ dark }) => !dark && ` + background-color: ${colorTransparent}; + + & button i { + color: ${colorBlack}; + } + `} + + ${({ bottom }) => bottom && ` + bottom: 0; + `} + + ${({ bottom }) => !bottom && ` + top: 0; + `} +`; + +const SwitchButton = styled(Button)` + padding: 5px; + + &, + &:active, + &:hover, + &:focus { + background-color: ${colorTransparent} !important; + border: none !important; + + i { + border: none !important; + background-color: ${colorTransparent} !important; + font-size: 1rem; + } + } +`; + +export default { + SwitchButtonWrapper, + SwitchButton, +}; diff --git a/src/2.7.12/imports/ui/components/settings/component.jsx b/src/2.7.12/imports/ui/components/settings/component.jsx new file mode 100644 index 00000000..c9f63456 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/component.jsx @@ -0,0 +1,348 @@ +import React, { Component } from 'react'; +import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import DataSaving from '/imports/ui/components/settings/submenus/data-saving/component'; +import Application from '/imports/ui/components/settings/submenus/application/component'; +import Notification from '/imports/ui/components/settings/submenus/notification/component'; +import Transcription from '/imports/ui/components/settings/submenus/transcription/component'; +import { clone } from 'radash'; +import PropTypes from 'prop-types'; +import Styled from './styles'; +import { formatLocaleCode } from '/imports/utils/string-utils'; + +const intlMessages = defineMessages({ + appTabLabel: { + id: 'app.settings.applicationTab.label', + description: 'label for application tab', + }, + audioTabLabel: { + id: 'app.settings.audioTab.label', + description: 'label for audio tab', + }, + videoTabLabel: { + id: 'app.settings.videoTab.label', + description: 'label for video tab', + }, + usersTabLabel: { + id: 'app.settings.usersTab.label', + description: 'label for participants tab', + }, + SettingsLabel: { + id: 'app.settings.main.label', + description: 'General settings label', + }, + CancelLabel: { + id: 'app.settings.main.cancel.label', + description: 'Discard the changes and close the settings menu', + }, + CancelLabelDesc: { + id: 'app.settings.main.cancel.label.description', + description: 'Settings modal cancel button description', + }, + SaveLabel: { + id: 'app.settings.main.save.label', + description: 'Save the changes and close the settings menu', + }, + SaveLabelDesc: { + id: 'app.settings.main.save.label.description', + description: 'Settings modal save button label', + }, + notificationLabel: { + id: 'app.submenu.notification.SectionTitle', // set menu label identical to section title + description: 'label for notification tab', + }, + dataSavingLabel: { + id: 'app.settings.dataSavingTab.label', + description: 'label for data savings tab', + }, + transcriptionLabel: { + id: 'app.settings.transcriptionTab.label', + description: 'label for transcriptions tab', + }, + savedAlertLabel: { + id: 'app.settings.save-notification.label', + description: 'label shown in toast when settings are saved', + }, + on: { + id: 'app.switch.onLabel', + description: 'label for toggle switch on state', + }, + off: { + id: 'app.switch.offLabel', + description: 'label for toggle switch off state', + }, +}); + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + dataSaving: PropTypes.shape({ + viewParticipantsWebcams: PropTypes.bool, + viewScreenshare: PropTypes.bool, + }).isRequired, + transcription: PropTypes.shape({ + partialUtterances: PropTypes.bool, + minUtteraceLength: PropTypes.number, + }).isRequired, + application: PropTypes.shape({ + chatAudioAlerts: PropTypes.bool, + chatPushAlerts: PropTypes.bool, + userJoinAudioAlerts: PropTypes.bool, + userLeaveAudioAlerts: PropTypes.bool, + userLeavePushAlerts: PropTypes.bool, + guestWaitingAudioAlerts: PropTypes.bool, + guestWaitingPushAlerts: PropTypes.bool, + paginationEnabled: PropTypes.bool, + darkTheme: PropTypes.bool, + fallbackLocale: PropTypes.string, + fontSize: PropTypes.string, + locale: PropTypes.string, + microphoneConstraints: PropTypes.objectOf(Object), + }).isRequired, + updateSettings: PropTypes.func.isRequired, + availableLocales: PropTypes.objectOf(PropTypes.array).isRequired, + showToggleLabel: PropTypes.bool.isRequired, + isReactionsEnabled: PropTypes.bool.isRequired, + isGladiaEnabled: PropTypes.bool.isRequired, +}; + +class Settings extends Component { + static setHtmlFontSize(size) { + document.getElementsByTagName('html')[0].style.fontSize = size; + } + + constructor(props) { + super(props); + + const { + dataSaving, application, transcription, selectedTab, + } = props; + + this.state = { + current: { + dataSaving: clone(dataSaving), + application: clone(application), + transcription: clone(transcription), + }, + saved: { + dataSaving: clone(dataSaving), + application: clone(application), + transcription: clone(transcription), + }, + selectedTab: Number.isFinite(selectedTab) && selectedTab >= 0 && selectedTab <= 2 + ? selectedTab + : 0, + }; + + this.updateSettings = props.updateSettings; + this.handleUpdateSettings = this.handleUpdateSettings.bind(this); + this.handleSelectTab = this.handleSelectTab.bind(this); + this.displaySettingsStatus = this.displaySettingsStatus.bind(this); + } + + componentDidMount() { + const { availableLocales } = this.props; + + availableLocales.then((locales) => { + this.setState({ allLocales: locales }); + }); + } + + handleUpdateSettings(key, newSettings) { + const settings = this.state; + settings.current[key] = newSettings; + this.setState(settings); + } + + handleSelectTab(tab) { + this.setState({ + selectedTab: tab, + }); + } + + displaySettingsStatus(status, textOnly = false) { + const { intl } = this.props; + if (textOnly) { + return status ? intl.formatMessage(intlMessages.on) + : intl.formatMessage(intlMessages.off); + } + return ( + + {status ? intl.formatMessage(intlMessages.on) + : intl.formatMessage(intlMessages.off)} + + ); + } + + renderModalContent() { + const { + intl, + isModerator, + isPresenter, + showGuestNotification, + showToggleLabel, + layoutContextDispatch, + selectedLayout, + isScreenSharingEnabled, + isVideoEnabled, + isReactionsEnabled, + isGladiaEnabled, + } = this.props; + + const { + selectedTab, + current, + allLocales, + } = this.state; + + const isDataSavingTabEnabled = isScreenSharingEnabled || isVideoEnabled; + + return ( + + + + + {intl.formatMessage(intlMessages.appTabLabel)} + + + + {intl.formatMessage(intlMessages.notificationLabel)} + + {isDataSavingTabEnabled + ? ( + + + {intl.formatMessage(intlMessages.dataSavingLabel)} + + ) + : null} + {isGladiaEnabled + ? ( + + + {intl.formatMessage(intlMessages.transcriptionLabel)} + + ) + : null} + + + + + + + + {isDataSavingTabEnabled + ? ( + + + + ) + : null} + {isGladiaEnabled + ? ( + + + + ) + : null} + + ); + } + + render() { + const { + intl, + setIsOpen, + isOpen, + priority, + } = this.props; + const { + current, + saved, + } = this.state; + return ( + { + this.updateSettings(current, intlMessages.savedAlertLabel); + + if (saved.application.locale !== current.application.locale) { + const { language } = formatLocaleCode(saved.application.locale); + document.body.classList.remove(`lang-${language}`); + } + + /* We need to use setIsOpen(false) here to prevent submenu state updates, + * from re-opening the modal. + */ + setIsOpen(false); + }, + label: intl.formatMessage(intlMessages.SaveLabel), + description: intl.formatMessage(intlMessages.SaveLabelDesc), + }} + dismiss={{ + callback: () => { + Settings.setHtmlFontSize(saved.application.fontSize); + document.getElementsByTagName('html')[0].lang = saved.application.locale; + setIsOpen(false); + }, + label: intl.formatMessage(intlMessages.CancelLabel), + description: intl.formatMessage(intlMessages.CancelLabelDesc), + }} + {...{ + isOpen, + priority, + }} + > + {this.renderModalContent()} + + ); + } +} + +Settings.propTypes = propTypes; +export default injectIntl(Settings); diff --git a/src/2.7.12/imports/ui/components/settings/container.jsx b/src/2.7.12/imports/ui/components/settings/container.jsx new file mode 100644 index 00000000..99ec9cca --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/container.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import SettingsService from '/imports/ui/services/settings'; +import Settings from './component'; +import { layoutDispatch } from '../layout/context'; +import { isScreenSharingEnabled } from '/imports/ui/services/features'; +import UserReactionService from '/imports/ui/components/user-reaction/service'; +import SpeechService from '/imports/ui/components/audio/captions/speech/service'; + +import { + getUserRoles, + isPresenter, + showGuestNotification, + updateSettings, + getAvailableLocales, +} from './service'; + +const SettingsContainer = (props) => { + const layoutContextDispatch = layoutDispatch(); + + return ; +}; + +export default withTracker((props) => ({ + ...props, + audio: SettingsService.audio, + dataSaving: SettingsService.dataSaving, + application: SettingsService.application, + transcription: SettingsService.transcription, + updateSettings, + availableLocales: getAvailableLocales(), + isPresenter: isPresenter(), + isModerator: getUserRoles() === 'MODERATOR', + showGuestNotification: showGuestNotification(), + showToggleLabel: false, + isScreenSharingEnabled: isScreenSharingEnabled(), + isVideoEnabled: Meteor.settings.public.kurento.enableVideo, + isReactionsEnabled: UserReactionService.isEnabled(), + isGladiaEnabled: SpeechService.isActive() && SpeechService.isGladia(), +}))(SettingsContainer); diff --git a/src/2.7.12/imports/ui/components/settings/service.js b/src/2.7.12/imports/ui/components/settings/service.js new file mode 100644 index 00000000..2317e467 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/service.js @@ -0,0 +1,67 @@ +import Users from '/imports/api/users'; +import Auth from '/imports/ui/services/auth'; +import Settings from '/imports/ui/services/settings'; +import {notify} from '/imports/ui/services/notification'; +import GuestService from '/imports/ui/components/waiting-users/service'; +import SpeechService from '/imports/ui/components/audio/captions/speech/service'; +import Intl from '/imports/ui/services/locale'; + +const getUserRoles = () => { + const user = Users.findOne({ + userId: Auth.userID, + }); + + return user?.role; +}; + +const isPresenter = () => { + const user = Users.findOne({ + userId: Auth.userID, + }); + + return user?.presenter; +}; + +const showGuestNotification = () => { + const guestPolicy = GuestService.getGuestPolicy(); + + // Guest notification only makes sense when guest + // entrance is being controlled by moderators + return guestPolicy === 'ASK_MODERATOR'; +}; + +const isKeepPushingLayoutEnabled = () => Meteor.settings.public.layout.showPushLayoutToggle; + +const updateSettings = (obj, msgDescriptor) => { + Object.keys(obj).forEach(k => (Settings[k] = obj[k])); + Settings.save(); + + if (obj.transcription) { + const { partialUtterances, minUtteranceLength } = obj.transcription; + SpeechService.setSpeechOptions(partialUtterances, parseInt(minUtteranceLength, 10)); + } + + if (msgDescriptor) { + // prevents React state update on unmounted component + setTimeout(() => { + Intl.formatMessage(msgDescriptor).then((txt) => { + notify( + txt, + 'info', + 'settings', + ); + }); + }, 0); + } +}; + +const getAvailableLocales = () => fetch('./locale-list').then(locales => locales.json()); + +export { + getUserRoles, + isPresenter, + showGuestNotification, + updateSettings, + isKeepPushingLayoutEnabled, + getAvailableLocales, +}; diff --git a/src/2.7.12/imports/ui/components/settings/styles.js b/src/2.7.12/imports/ui/components/settings/styles.js new file mode 100644 index 00000000..9e6dbc5a --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/styles.js @@ -0,0 +1,139 @@ +import styled from 'styled-components'; +import { + smPaddingX, + smPaddingY, + mdPaddingY, + mdPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import { + colorGrayDark, + colorPrimary, + colorWhite, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography'; +import { + Tab, Tabs, TabList, TabPanel, +} from 'react-tabs'; +import Icon from '/imports/ui/components/common/icon/component'; + +const ToggleLabel = styled.span` + margin-right: ${smPaddingX}; + + [dir="rtl"] & { + margin: 0 0 0 ${smPaddingX}; + } +`; + +const SettingsTabs = styled(Tabs)` + display: flex; + flex-flow: row; + justify-content: flex-start; + + @media ${smallOnly} { + width: 100%; + flex-flow: column; + } +`; + +const SettingsTabList = styled(TabList)` + display: flex; + flex-flow: column; + margin: 0; + border: none; + padding: 0; + width: calc(100% / 3); + + @media ${smallOnly} { + width: 100%; + flex-flow: row; + flex-wrap: wrap; + justify-content: center; + } +`; + +const SettingsTabSelector = styled(Tab)` + display: flex; + flex-flow: row; + font-size: 0.9rem; + flex: 0 0 auto; + justify-content: flex-start; + border: none !important; + padding: ${mdPaddingY} ${mdPaddingX}; + color: ${colorGrayDark}; + border-radius: .2rem; + cursor: pointer; + margin-bottom: ${smPaddingY}; + align-items: center; + flex-grow: 0; + min-width: 0; + + & > span { + min-width: 0; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + @media ${smallOnly} { + max-width: 100%; + margin: 0 ${smPaddingX} 0 0; + & > i { + display: none; + } + + [dir="rtl"] & { + margin: 0 0 0 ${smPaddingX}; + } + } + + &.is-selected { + color: ${colorWhite}; + background-color: ${colorPrimary}; + font-weight: bold; + + & > i { + color: ${colorWhite}; + } + } +`; + +const SettingsIcon = styled(Icon)` + margin: 0 .5rem 0 0; + font-size: ${fontSizeLarge}; + + [dir="rtl"] & { + margin: 0 0 0 .5rem; + } +`; + +const SettingsTabPanel = styled(TabPanel)` + display: none; + margin: 0 0 0 1rem; + width: calc(100% / 3 * 2); + + [dir="rtl"] & { + margin: 0 1rem 0 0; + } + + &.is-selected { + display: block; + } + + @media ${smallOnly} { + width: 100%; + margin: 0; + padding-left: 1rem; + padding-right: 1rem; + } +`; + +export default { + ToggleLabel, + SettingsTabs, + SettingsTabList, + SettingsTabSelector, + SettingsIcon, + SettingsTabPanel, +}; diff --git a/src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx new file mode 100644 index 00000000..8a3a4308 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx @@ -0,0 +1,633 @@ +import React from 'react'; +import Button from '/imports/ui/components/common/button/component'; +import Toggle from '/imports/ui/components/common/switch/component'; +import LocalesDropdown from '/imports/ui/components/common/locales-dropdown/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import BaseMenu from '../base/component'; +import Styled from './styles'; +import VideoService from '/imports/ui/components/video-provider/service'; +import WakeLockService from '/imports/ui/components/wake-lock/service'; +import { ACTIONS } from '/imports/ui/components/layout/enums'; +import Settings from '/imports/ui/services/settings'; + +const MIN_FONTSIZE = 0; +const SHOW_AUDIO_FILTERS = (Meteor.settings.public.app + .showAudioFilters === undefined) + ? true + : Meteor.settings.public.app.showAudioFilters; +const { animations } = Settings.application; + +const intlMessages = defineMessages({ + applicationSectionTitle: { + id: 'app.submenu.application.applicationSectionTitle', + description: 'Application section title', + }, + animationsLabel: { + id: 'app.submenu.application.animationsLabel', + description: 'animations label', + }, + audioFilterLabel: { + id: 'app.submenu.application.audioFilterLabel', + description: 'audio filters label', + }, + darkThemeLabel: { + id: 'app.submenu.application.darkThemeLabel', + description: 'dark mode label', + }, + fontSizeControlLabel: { + id: 'app.submenu.application.fontSizeControlLabel', + description: 'label for font size ontrol', + }, + increaseFontBtnLabel: { + id: 'app.submenu.application.increaseFontBtnLabel', + description: 'label for button to increase font size', + }, + increaseFontBtnDesc: { + id: 'app.submenu.application.increaseFontBtnDesc', + description: 'adds descriptive context to increase font size button', + }, + decreaseFontBtnLabel: { + id: 'app.submenu.application.decreaseFontBtnLabel', + description: 'label for button to reduce font size', + }, + decreaseFontBtnDesc: { + id: 'app.submenu.application.decreaseFontBtnDesc', + description: 'adds descriptive context to decrease font size button', + }, + languageLabel: { + id: 'app.submenu.application.languageLabel', + description: 'displayed label for changing application locale', + }, + currentValue: { + id: 'app.submenu.application.currentSize', + description: 'current value label', + }, + languageOptionLabel: { + id: 'app.submenu.application.languageOptionLabel', + description: 'default change language option when locales are available', + }, + noLocaleOptionLabel: { + id: 'app.submenu.application.noLocaleOptionLabel', + description: 'default change language option when no locales available', + }, + paginationEnabledLabel: { + id: 'app.submenu.application.paginationEnabledLabel', + description: 'enable/disable video pagination', + }, + wbToolbarsAutoHideLabel: { + id: 'app.submenu.application.wbToolbarsAutoHideLabel', + description: 'enable/disable auto hiding of whitebord toolbars', + }, + wakeLockEnabledLabel: { + id: 'app.submenu.application.wakeLockEnabledLabel', + description: 'enable/disable wake lock', + }, + layoutOptionLabel: { + id: 'app.submenu.application.layoutOptionLabel', + description: 'layout options', + }, + pushLayoutLabel: { + id: 'app.submenu.application.pushLayoutLabel', + description: 'push layout togle', + }, + customLayout: { + id: 'app.layout.style.custom', + description: 'label for custom layout style', + }, + smartLayout: { + id: 'app.layout.style.smart', + description: 'label for smart layout style', + }, + presentationFocusLayout: { + id: 'app.layout.style.presentationFocus', + description: 'label for presentationFocus layout style', + }, + videoFocusLayout: { + id: 'app.layout.style.videoFocus', + description: 'label for videoFocus layout style', + }, + presentationFocusPushLayout: { + id: 'app.layout.style.presentationFocusPush', + description: 'label for presentationFocus layout style (push to all)', + }, + videoFocusPushLayout: { + id: 'app.layout.style.videoFocusPush', + description: 'label for videoFocus layout style (push to all)', + }, + smartPushLayout: { + id: 'app.layout.style.smartPush', + description: 'label for smart layout style (push to all)', + }, + customPushLayout: { + id: 'app.layout.style.customPush', + description: 'label for custom layout style (push to all)', + }, + disableLabel: { + id: 'app.videoDock.webcamDisableLabelAllCams', + }, + autoCloseReactionsBarLabel: { + id: 'app.actionsBar.reactions.autoCloseReactionsBarLabel', + }, +}); + +class ApplicationMenu extends BaseMenu { + static setHtmlFontSize(size) { + document.getElementsByTagName('html')[0].style.fontSize = size; + } + + constructor(props) { + super(props); + + this.state = { + settingsName: 'application', + settings: props.settings, + isLargestFontSize: false, + isSmallestFontSize: false, + showSelect: false, + fontSizes: [ + '12px', + '14px', + '16px', + '18px', + '20px', + ], + audioFilterEnabled: ApplicationMenu.isAudioFilterEnabled(props + .settings.microphoneConstraints), + }; + } + + componentDidMount() { + this.setInitialFontSize(); + } + + componentWillUnmount() { + // fix Warning: Can't perform a React state update on an unmounted component + this.setState = () => {}; + } + + setInitialFontSize() { + const { fontSizes } = this.state; + const clientFont = document.getElementsByTagName('html')[0].style.fontSize; + const hasFont = fontSizes.includes(clientFont); + if (!hasFont) { + fontSizes.push(clientFont); + fontSizes.sort(); + } + const fontIndex = fontSizes.indexOf(clientFont); + this.changeFontSize(clientFont); + this.setState({ + isSmallestFontSize: fontIndex <= MIN_FONTSIZE, + isLargestFontSize: fontIndex >= (fontSizes.length - 1), + fontSizes, + }); + } + + static isAudioFilterEnabled(_constraints) { + if (typeof _constraints === 'undefined') return true; + + const _isConstraintEnabled = (constraintValue) => { + switch (typeof constraintValue) { + case 'boolean': + return constraintValue; + case 'string': + return constraintValue === 'true'; + case 'object': + return !!(constraintValue.exact || constraintValue.ideal); + default: + return false; + } + }; + + let isAnyFilterEnabled = true; + + const constraints = _constraints && (typeof _constraints.advanced === 'object') + ? _constraints.advanced + : _constraints || {}; + + isAnyFilterEnabled = Object.values(constraints).find( + (constraintValue) => _isConstraintEnabled(constraintValue), + ); + + return isAnyFilterEnabled; + } + + handleAudioFilterChange() { + const _audioFilterEnabled = !ApplicationMenu.isAudioFilterEnabled(this + .state.settings.microphoneConstraints); + const _newConstraints = { + autoGainControl: _audioFilterEnabled, + echoCancellation: _audioFilterEnabled, + noiseSuppression: _audioFilterEnabled, + }; + + const obj = this.state; + obj.settings.microphoneConstraints = _newConstraints; + this.handleUpdateSettings(this.state.settings, obj.settings); + } + + handleUpdateFontSize(size) { + const obj = this.state; + obj.settings.fontSize = size; + this.handleUpdateSettings(this.state.settingsName, obj.settings); + } + + changeFontSize(size) { + const { layoutContextDispatch } = this.props; + const obj = this.state; + obj.settings.fontSize = size; + this.setState(obj, () => { + ApplicationMenu.setHtmlFontSize(this.state.settings.fontSize); + this.handleUpdateFontSize(this.state.settings.fontSize); + }); + + layoutContextDispatch({ + type: ACTIONS.SET_FONT_SIZE, + value: parseInt(size.slice(0, -2), 10), + }); + } + + handleIncreaseFontSize() { + const currentFontSize = this.state.settings.fontSize; + const availableFontSizes = this.state.fontSizes; + const maxFontSize = availableFontSizes.length - 1; + const canIncreaseFontSize = availableFontSizes.indexOf(currentFontSize) < maxFontSize; + const fs = canIncreaseFontSize ? availableFontSizes.indexOf(currentFontSize) + 1 : maxFontSize; + this.changeFontSize(availableFontSizes[fs]); + if (fs === maxFontSize) this.setState({ isLargestFontSize: true }); + this.setState({ isSmallestFontSize: false }); + } + + handleDecreaseFontSize() { + const currentFontSize = this.state.settings.fontSize; + const availableFontSizes = this.state.fontSizes; + const canDecreaseFontSize = availableFontSizes.indexOf(currentFontSize) > MIN_FONTSIZE; + const fs = canDecreaseFontSize ? availableFontSizes.indexOf(currentFontSize) - 1 : MIN_FONTSIZE; + this.changeFontSize(availableFontSizes[fs]); + if (fs === MIN_FONTSIZE) this.setState({ isSmallestFontSize: true }); + this.setState({ isLargestFontSize: false }); + } + + handleSelectChange(fieldname, e) { + const obj = this.state; + obj.settings[fieldname] = e.target.value; + this.handleUpdateSettings('application', obj.settings); + } + + renderAudioFilters() { + let audioFilterOption = null; + + if (SHOW_AUDIO_FILTERS) { + const { intl, showToggleLabel, displaySettingsStatus } = this.props; + const { settings } = this.state; + const audioFilterStatus = ApplicationMenu + .isAudioFilterEnabled(settings.microphoneConstraints); + + audioFilterOption = ( + + + + + {displaySettingsStatus(audioFilterStatus)} + this.handleAudioFilterChange()} + ariaLabel={`${intl.formatMessage(intlMessages.audioFilterLabel)} - ${displaySettingsStatus(audioFilterStatus, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + ); + } + + return audioFilterOption; + } + + renderPaginationToggle() { + // See VideoService's method for an explanation + if (!VideoService.shouldRenderPaginationToggle()) return false; + + const { intl, showToggleLabel, displaySettingsStatus } = this.props; + const { settings } = this.state; + + return ( + + + + + {displaySettingsStatus(settings.paginationEnabled)} + this.handleToggle('paginationEnabled')} + ariaLabel={`${intl.formatMessage(intlMessages.paginationEnabledLabel)} - ${displaySettingsStatus(settings.paginationEnabled, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + ); + } + + renderDarkThemeToggle() { + const { intl, showToggleLabel, displaySettingsStatus } = this.props; + const { settings } = this.state; + + const isDarkThemeEnabled = Meteor.settings.public.app.darkTheme.enabled; + if (!isDarkThemeEnabled) return null; + + return ( + + + + + {displaySettingsStatus(settings.darkTheme)} + this.handleToggle('darkTheme')} + showToggleLabel={showToggleLabel} + ariaLabel={`${intl.formatMessage(intlMessages.darkThemeLabel)} - ${displaySettingsStatus(settings.darkTheme, true)}`} + data-test="darkModeToggleBtn" + /> + + + + ); + } + + renderWakeLockToggle() { + if (!WakeLockService.isSupported()) return null; + + const { intl, showToggleLabel, displaySettingsStatus } = this.props; + const { settings } = this.state; + + return ( + + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {intl.formatMessage(intlMessages.wakeLockEnabledLabel)} + + + + + + {displaySettingsStatus(settings.wakeLock)} + this.handleToggle('wakeLock')} + ariaLabel={intl.formatMessage(intlMessages.wakeLockEnabledLabel)} + showToggleLabel={showToggleLabel} + /> + + + + ); + } + + render() { + const { + allLocales, + intl, + showToggleLabel, + displaySettingsStatus, + isReactionsEnabled, + } = this.props; + const { + isLargestFontSize, isSmallestFontSize, settings, + } = this.state; + + // conversions can be found at http://pxtoem.com + const pixelPercentage = { + '12px': '75%', + // 14px is actually 87.5%, rounding up to show more friendly value + '14px': '90%', + '16px': '100%', + // 18px is actually 112.5%, rounding down to show more friendly value + '18px': '110%', + '20px': '125%', + }; + + const ariaValueLabel = intl.formatMessage(intlMessages.currentValue, { 0: `${pixelPercentage[settings.fontSize]}` }); + + const showSelect = allLocales && allLocales.length > 0; + + return ( +
+
+ + {intl.formatMessage(intlMessages.applicationSectionTitle)} + +
+ + + + + + {displaySettingsStatus(settings.animations)} + this.handleToggle('animations')} + ariaLabel={`${intl.formatMessage(intlMessages.animationsLabel)} - ${displaySettingsStatus(settings.animations, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {this.renderAudioFilters()} + {this.renderPaginationToggle()} + {this.renderDarkThemeToggle()} + {this.renderWakeLockToggle()} + + + + + + {displaySettingsStatus(settings.whiteboardToolbarAutoHide)} + this.handleToggle('whiteboardToolbarAutoHide')} + ariaLabel={`${intl.formatMessage(intlMessages.wbToolbarsAutoHideLabel)} - ${displaySettingsStatus(settings.whiteboardToolbarAutoHide, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + + + + + {displaySettingsStatus(settings.selfViewDisable)} + this.handleToggle('selfViewDisable')} + ariaLabel={`${intl.formatMessage(intlMessages.disableLabel)} - ${displaySettingsStatus(settings.selfViewDisable, false)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {isReactionsEnabled && ( + + + + + {displaySettingsStatus(settings.autoCloseReactionsBar)} + this.handleToggle('autoCloseReactionsBar')} + ariaLabel={`${intl.formatMessage(intlMessages.autoCloseReactionsBarLabel)} - ${displaySettingsStatus(settings.autoCloseReactionsBar, false)}`} + showToggleLabel={showToggleLabel} + /> + + + + )} + + + + + + {intl.formatMessage(intlMessages.languageLabel)} + + + + + + {showSelect ? ( + + this.handleSelectChange('locale', e)} + value={settings.locale} + elementId="langSelector" + ariaLabel={intl.formatMessage(intlMessages.languageLabel)} + selectMessage={intl.formatMessage(intlMessages.languageOptionLabel)} + /> + + ) : ( + + + +
+ + )} + + + + + + + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {intl.formatMessage(intlMessages.fontSizeControlLabel)} + + + + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {`${pixelPercentage[settings.fontSize]}`} + + + + + + + +
+ ); + } +} + +export default injectIntl(ApplicationMenu); diff --git a/src/2.7.12/imports/ui/components/settings/submenus/application/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/application/styles.js new file mode 100644 index 00000000..df286bfd --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/application/styles.js @@ -0,0 +1,102 @@ +import styled from 'styled-components'; +import { + colorGrayLabel, + colorPrimary, + colorWhite, + colorGrayLighter, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { borderSize, borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general'; +import SpinnerStyles from '/imports/ui/components/common/loading-screen/styles'; +import Styled from '/imports/ui/components/settings/submenus/styles'; + +const Row = styled(Styled.Row)``; + +const Col = styled(Styled.Col)``; + +const FormElement = styled(Styled.FormElement)``; + +const Label = styled(Styled.Label)``; + +const FormElementRight = styled(Styled.FormElementRight)``; + +const Select = styled(Styled.Select)``; + +const Title = styled(Styled.Title)``; + +const Form = styled(Styled.Form)``; + +const SpinnerOverlay = styled(SpinnerStyles.Spinner)` + & > div { + background-color: black; + } +`; + +const Bounce1 = styled(SpinnerStyles.Bounce1)``; + +const Bounce2 = styled(SpinnerStyles.Bounce2)``; + +const Separator = styled.hr` + margin: 2.5rem 0; + border: 1px solid ${colorGrayLighter}; + opacity: 0.25; +`; + +const FormElementCenter = styled(Styled.FormElementCenter)``; + +const BoldLabel = styled.label` + color: ${colorGrayLabel}; + font-size: 0.9rem; + margin-bottom: 0.5rem; + font-weight: bold; +`; + +const PullContentRight = styled.div` + display: flex; + justify-content: flex-end; + flex-flow: row; + align-items: center; +`; + +const LocalesDropdownSelect = styled.div` + & > select { + &:focus { + box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary}; + border-radius: ${borderSize}; + } + + background-color: ${colorWhite}; + border: ${borderSize} solid ${colorWhite}; + border-radius: ${borderSize}; + border-bottom: 0.1rem solid ${colorGrayLighter}; + color: ${colorGrayLabel}; + width: 100%; + height: 1.75rem; + padding: 1px; + + &:hover, + &:focus { + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + } + } +`; + +export default { + Row, + Col, + FormElement, + Label, + FormElementRight, + Select, + Title, + Form, + SpinnerOverlay, + Bounce1, + Bounce2, + Separator, + FormElementCenter, + BoldLabel, + PullContentRight, + LocalesDropdownSelect, +}; diff --git a/src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx new file mode 100644 index 00000000..0e621b0e --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx @@ -0,0 +1,27 @@ +import React from 'react'; + +export default class BaseMenu extends React.Component { + constructor(props) { + super(props); + + this.handleUpdateSettings = props.handleUpdateSettings; + } + + handleToggle(key) { + const obj = this.state; + obj.settings[key] = !this.state.settings[key]; + + this.setState(obj, () => { + this.handleUpdateSettings(this.state.settingsName, this.state.settings); + }); + } + + handleInput(key, e) { + const obj = this.state; + obj.settings[key] = e.target.value; + + this.setState(obj, () => { + this.handleUpdateSettings(this.state.settingsName, this.state.settings); + }); + } +} diff --git a/src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx new file mode 100644 index 00000000..0a753595 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import Toggle from '/imports/ui/components/common/switch/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import BaseMenu from '../base/component'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + dataSavingLabel: { + id: 'app.settings.dataSavingTab.label', + description: 'label for data savings tab', + }, + webcamLabel: { + id: 'app.settings.dataSavingTab.webcam', + description: 'webcam toggle', + }, + screenShareLabel: { + id: 'app.settings.dataSavingTab.screenShare', + description: 'screenshare toggle', + }, + dataSavingDesc: { + id: 'app.settings.dataSavingTab.description', + description: 'description of data savings tab', + }, +}); + +class DataSaving extends BaseMenu { + constructor(props) { + super(props); + + this.state = { + settingsName: 'dataSaving', + settings: props.settings, + }; + } + + render() { + const { + intl, + showToggleLabel, + displaySettingsStatus, + isScreenSharingEnabled, + isVideoEnabled, + } = this.props; + + const { viewParticipantsWebcams, viewScreenshare } = this.state.settings; + + return ( +
+
+ {intl.formatMessage(intlMessages.dataSavingLabel)} + {intl.formatMessage(intlMessages.dataSavingDesc)} +
+ + {isVideoEnabled + ? ( + + + + + {intl.formatMessage(intlMessages.webcamLabel)} + + + + + + {displaySettingsStatus(viewParticipantsWebcams)} + this.handleToggle('viewParticipantsWebcams')} + ariaLabelledBy="webcamToggle" + ariaLabel={`${intl.formatMessage(intlMessages.webcamLabel)} - ${displaySettingsStatus(viewParticipantsWebcams, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + ) + : null} + {isScreenSharingEnabled + ? ( + + + + + {intl.formatMessage(intlMessages.screenShareLabel)} + + + + + + {displaySettingsStatus(viewScreenshare)} + this.handleToggle('viewScreenshare')} + ariaLabelledBy="screenShare" + ariaLabel={`${intl.formatMessage(intlMessages.screenShareLabel)} - ${displaySettingsStatus(viewScreenshare, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + ) + : null} + +
+ ); + } +} + +export default injectIntl(DataSaving); diff --git a/src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js new file mode 100644 index 00000000..4cf67d41 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js @@ -0,0 +1,29 @@ +import styled from 'styled-components'; +import Styled from '/imports/ui/components/settings/submenus/styles'; + +const Title = styled(Styled.Title)``; + +const SubTitle = styled(Styled.SubTitle)``; + +const Form = styled(Styled.Form)``; + +const Row = styled(Styled.Row)``; + +const Col = styled(Styled.Col)``; + +const FormElement = styled(Styled.FormElement)``; + +const FormElementRight = styled(Styled.FormElementRight)``; + +const Label = styled(Styled.Label)``; + +export default { + Title, + SubTitle, + Form, + Row, + Col, + FormElement, + FormElementRight, + Label, +}; diff --git a/src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx new file mode 100644 index 00000000..1e5fdff9 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx @@ -0,0 +1,264 @@ +import React from 'react'; +import Toggle from '/imports/ui/components/common/switch/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import BaseMenu from '../base/component'; +import Styled from './styles'; +import { isChatEnabled } from '/imports/ui/services/features'; + +const intlMessages = defineMessages({ + notificationSectionTitle: { + id: 'app.submenu.notification.SectionTitle', + description: 'Notification section title', + }, + notificationSectionDesc: { + id: 'app.submenu.notification.Desc', + description: 'provides extra info for notification section', + }, + audioAlertLabel: { + id: 'app.submenu.notification.audioAlertLabel', + description: 'audio notification label', + }, + pushAlertLabel: { + id: 'app.submenu.notification.pushAlertLabel', + description: 'push notifiation label', + }, + messagesLabel: { + id: 'app.submenu.notification.messagesLabel', + description: 'label for chat messages', + }, + userJoinLabel: { + id: 'app.submenu.notification.userJoinLabel', + description: 'label for chat messages', + }, + userLeaveLabel: { + id: 'app.submenu.notification.userLeaveLabel', + description: 'label for user leave notifications', + }, + guestWaitingLabel: { + id: 'app.submenu.notification.guestWaitingLabel', + description: 'label for guests waiting for approval', + }, + raiseHandLabel: { + id: 'app.submenu.notification.raiseHandLabel', + description: 'label for raise hand emoji notifications', + }, +}); + +class NotificationMenu extends BaseMenu { + constructor(props) { + super(props); + + this.state = { + settingsName: 'notification', + settings: props.settings, + }; + } + + render() { + const { + intl, + isModerator, + showGuestNotification, + showToggleLabel, + displaySettingsStatus, + } = this.props; + + const { settings } = this.state; + + return ( +
+
+ + {intl.formatMessage(intlMessages.notificationSectionTitle)} + + + {intl.formatMessage(intlMessages.notificationSectionDesc)} + +
+ + + + + + {intl.formatMessage(intlMessages.audioAlertLabel)} + + + {intl.formatMessage(intlMessages.pushAlertLabel)} + + + + {isChatEnabled() ? ( + + + + {intl.formatMessage(intlMessages.messagesLabel)} + + + + + {displaySettingsStatus(settings.chatAudioAlerts)} + this.handleToggle('chatAudioAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.messagesLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.chatAudioAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {displaySettingsStatus(settings.chatPushAlerts)} + this.handleToggle('chatPushAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.messagesLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.chatPushAlerts, true)}`} + showToggleLabel={showToggleLabel} + data-test="chatPopupAlertsBtn" + /> + + + + ) : null} + + + + + {intl.formatMessage(intlMessages.userJoinLabel)} + + + + + {displaySettingsStatus(settings.userJoinAudioAlerts)} + this.handleToggle('userJoinAudioAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.userJoinLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.userJoinAudioAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {displaySettingsStatus(settings.userJoinPushAlerts)} + this.handleToggle('userJoinPushAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.userJoinLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.userJoinPushAlerts, true)}`} + showToggleLabel={showToggleLabel} + data-test="userJoinPopupAlerts" + /> + + + + + + + + {intl.formatMessage(intlMessages.userLeaveLabel)} + + + + + {displaySettingsStatus(settings.userLeaveAudioAlerts)} + this.handleToggle('userLeaveAudioAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.userLeaveLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.userLeaveAudioAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {displaySettingsStatus(settings.userLeavePushAlerts)} + this.handleToggle('userLeavePushAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.userLeaveLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.userLeavePushAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {isModerator && showGuestNotification ? ( + + + + {intl.formatMessage(intlMessages.guestWaitingLabel)} + + + + + {displaySettingsStatus(settings.guestWaitingAudioAlerts)} + this.handleToggle('guestWaitingAudioAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.guestWaitingLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.guestWaitingAudioAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {displaySettingsStatus(settings.guestWaitingPushAlerts)} + this.handleToggle('guestWaitingPushAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.guestWaitingLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.guestWaitingPushAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + ) : null} + + {isModerator ? ( + + + + {intl.formatMessage(intlMessages.raiseHandLabel)} + + + + + {displaySettingsStatus(settings.raiseHandAudioAlerts)} + this.handleToggle('raiseHandAudioAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.raiseHandLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.raiseHandAudioAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + {displaySettingsStatus(settings.raiseHandPushAlerts)} + this.handleToggle('raiseHandPushAlerts')} + ariaLabel={`${intl.formatMessage(intlMessages.raiseHandLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.raiseHandPushAlerts, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + ) : null} + + +
+ ); + } +} + +export default injectIntl(NotificationMenu); diff --git a/src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js new file mode 100644 index 00000000..36978069 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js @@ -0,0 +1,41 @@ +import styled from 'styled-components'; +import Styled from '/imports/ui/components/settings/submenus/styles'; + +const Title = styled(Styled.Title)``; + +const SubTitle = styled(Styled.SubTitle)``; + +const Form = styled(Styled.Form)``; + +const Row = styled(Styled.Row)``; + +const Col = styled(Styled.Col)``; + +const FormElement = styled(Styled.FormElement)``; + +const FormElementRight = styled(Styled.FormElementRight)``; + +const FormElementCenter = styled(Styled.FormElementCenter)``; + +const Label = styled(Styled.Label)``; + +const ColHeading = styled(Styled.Col)` + display: block; + text-align: center; + font-size: 0.9rem; + margin-bottom: 0.5rem; + font-weight: bold; +`; + +export default { + Title, + SubTitle, + Form, + Row, + Col, + FormElement, + FormElementRight, + FormElementCenter, + Label, + ColHeading, +}; diff --git a/src/2.7.12/imports/ui/components/settings/submenus/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/styles.js new file mode 100644 index 00000000..82dcabf1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/styles.js @@ -0,0 +1,118 @@ +import styled from 'styled-components'; +import { + colorGrayDark, + colorGrayLabel, + colorPrimary, + colorWhite, + colorGrayLighter, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { borderSize, borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general'; + +const Title = styled.h3` + color: ${colorGrayDark}; + font-weight: 400; + font-size: 1.3rem; + margin: 0; + margin-bottom: 1.5rem; +`; + +const SubTitle = styled.h4` + font-size: 0.9rem; + margin-bottom: 1rem; +`; + +const Form = styled.div` + display: flex; + flex-flow: column; +`; + +const Row = styled.div` + display: flex; + flex-flow: row; + flex-grow: 1; + justify-content: space-between; + margin-bottom: 0.7rem; +`; + +const Col = styled.div` + display: flex; + flex-grow: 1; + flex-basis: 0; + + &:last-child { + padding-right: 0; + padding-left: 1rem; + + [dir="rtl"] & { + padding-right: 0.1rem; + padding-left: 0; + } + } +`; + +const FormElement = styled.div` + position: relative; + display: flex; + flex-flow: column; + flex-grow: 1; +`; + +const FormElementRight = styled.div` + position: relative; + flex-grow: 1; + display: flex; + justify-content: flex-end; + flex-flow: row; + align-items: center; +`; + +const FormElementCenter = styled.div` + position: relative; + display: flex; + flex-grow: 1; + justify-content: center; + flex-flow: row; + align-items: center; +`; + +const Label = styled.span` + color: ${colorGrayLabel}; + font-size: 0.9rem; + margin-bottom: 0.5rem; +`; + +const Select = styled.select` + &:focus { + box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary}; + border-radius: ${borderSize}; + } + + background-color: ${colorWhite}; + border: ${borderSize} solid ${colorWhite}; + border-radius: ${borderSize}; + border-bottom: 0.1rem solid ${colorGrayLighter}; + color: ${colorGrayLabel}; + width: 100%; + height: 1.75rem; + padding: 1px; + + &:hover, + &:focus { + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + } +`; + +export default { + Title, + SubTitle, + Form, + Row, + Col, + FormElement, + FormElementRight, + FormElementCenter, + Label, + Select, +}; diff --git a/src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx new file mode 100644 index 00000000..c17b02e6 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import Toggle from '/imports/ui/components/common/switch/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import BaseMenu from '../base/component'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + transcriptionLabel: { + id: 'app.submenu.transcription.sectionTitle', + }, + transcriptionDesc: { + id: 'app.submenu.transcription.desc', + }, + partialUtterancesLabel: { + id: 'app.settings.transcriptionTab.partialUtterances', + }, + minUtteranceLengthLabel: { + id: 'app.settings.transcriptionTab.minUtteranceLength', + }, +}); + +class Transcription extends BaseMenu { + constructor(props) { + super(props); + + this.state = { + settingsName: 'transcription', + settings: props.settings, + }; + } + + render() { + const { + intl, + showToggleLabel, + displaySettingsStatus, + } = this.props; + + const { partialUtterances, minUtteranceLength } = this.state.settings; + + return ( +
+
+ {intl.formatMessage(intlMessages.transcriptionLabel)} + {intl.formatMessage(intlMessages.transcriptionDesc)} +
+ + + + + + + {intl.formatMessage(intlMessages.partialUtterancesLabel)} + + + + + + this.handleToggle('partialUtterances')} + ariaLabelledBy="partialUtterances" + ariaLabel={`${intl.formatMessage(intlMessages.partialUtterancesLabel)} - ${displaySettingsStatus(partialUtterances, true)}`} + showToggleLabel={showToggleLabel} + /> + + + + + + + + {intl.formatMessage(intlMessages.minUtteranceLengthLabel)} + + + + + + this.handleInput('minUtteranceLength', e) } + type="number" + max="5" + min="0" + > + + + + + +
+ ); + } +} + +export default injectIntl(Transcription); diff --git a/src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js new file mode 100644 index 00000000..4cf67d41 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js @@ -0,0 +1,29 @@ +import styled from 'styled-components'; +import Styled from '/imports/ui/components/settings/submenus/styles'; + +const Title = styled(Styled.Title)``; + +const SubTitle = styled(Styled.SubTitle)``; + +const Form = styled(Styled.Form)``; + +const Row = styled(Styled.Row)``; + +const Col = styled(Styled.Col)``; + +const FormElement = styled(Styled.FormElement)``; + +const FormElementRight = styled(Styled.FormElementRight)``; + +const Label = styled(Styled.Label)``; + +export default { + Title, + SubTitle, + Form, + Row, + Col, + FormElement, + FormElementRight, + Label, +}; diff --git a/src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx new file mode 100644 index 00000000..c9c6e4f5 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Toggle from '/imports/ui/components/common/switch/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import BaseMenu from '../base/component'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + videoSectionTitle: { + id: 'app.submenu.video.title', + description: 'Heading for video submenu section', + }, + videoSourceLabel: { + id: 'app.submenu.video.videoSourceLabel', + description: 'Label for video source section', + }, + videoOptionLabel: { + id: 'app.submenu.video.videoOptionLabel', + description: 'default video source option label', + }, + videoQualityLabel: { + id: 'app.submenu.video.videoQualityLabel', + description: 'Label for video quality section', + }, + qualityOptionLabel: { + id: 'app.submenu.video.qualityOptionLabel', + description: 'default quality option label', + }, + participantsCamLabel: { + id: 'app.submenu.video.participantsCamLabel', + description: 'Label for participants cam section', + }, +}); + +class VideoMenu extends BaseMenu { + constructor(props) { + super(props); + + this.state = { + settingsName: 'video', + settings: props.settings, + }; + } + + render() { + const { intl } = this.props; + + return ( +
+
+ + {intl.formatMessage(intlMessages.videoSectionTitle)} + +
+ + + + + + + {intl.formatMessage(intlMessages.videoSourceLabel)} + + + + + + + + + + {intl.formatMessage(intlMessages.videoQualityLabel)} + + + + + + + + + + + + {intl.formatMessage(intlMessages.participantsCamLabel)} + + + + + + this.handleToggle('viewParticipantsWebcams')} + ariaLabelledBy="viewCamLabel" + ariaLabel={intl.formatMessage(intlMessages.participantsCamLabel)} + /> + + + + +
+ ); + } +} + +export default injectIntl(VideoMenu); diff --git a/src/2.7.12/imports/ui/components/settings/submenus/video/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/video/styles.js new file mode 100644 index 00000000..6a169a65 --- /dev/null +++ b/src/2.7.12/imports/ui/components/settings/submenus/video/styles.js @@ -0,0 +1,37 @@ +import styled from 'styled-components'; +import Styled from '/imports/ui/components/settings/submenus/styles'; +import { colorLink } from '/imports/ui/stylesheets/styled-components/palette'; + +const Title = styled(Styled.Title)``; + +const Form = styled(Styled.Form)``; + +const Row = styled(Styled.Row)``; + +const Col = styled(Styled.Col)``; + +const FormElement = styled(Styled.FormElement)``; + +const FormElementRight = styled(Styled.FormElementRight)``; + +const Label = styled(Styled.Label)``; + +const LabelSmall = styled(Label)` + color: ${colorLink}; + font-size: 0.7rem; + margin-bottom: 0.3rem; +`; + +const Select = styled(Styled.Select)``; + +export default { + Title, + Form, + Row, + Col, + FormElement, + FormElementRight, + Label, + LabelSmall, + Select, +}; diff --git a/src/2.7.12/imports/ui/components/shortcut-help/component.jsx b/src/2.7.12/imports/ui/components/shortcut-help/component.jsx new file mode 100644 index 00000000..9ae793be --- /dev/null +++ b/src/2.7.12/imports/ui/components/shortcut-help/component.jsx @@ -0,0 +1,459 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import browserInfo from '/imports/utils/browserInfo'; +import deviceInfo from '/imports/utils/deviceInfo'; +import ModalSimple from '/imports/ui/components/common/modal/simple/component'; +import Styled from './styles'; +import StyledSettings from '../settings/styles'; +import withShortcutHelper from './service'; +import { isChatEnabled } from '/imports/ui/services/features'; +import { uniqueId } from '/imports/utils/string-utils'; + +const intlMessages = defineMessages({ + title: { + id: 'app.shortcut-help.title', + description: 'modal title label', + }, + closeLabel: { + id: 'app.shortcut-help.closeLabel', + description: 'label for close button', + }, + closeDesc: { + id: 'app.shortcut-help.closeDesc', + description: 'description for close button', + }, + accessKeyNotAvailable: { + id: 'app.shortcut-help.accessKeyNotAvailable', + description: 'message shown in place of access key table if not supported', + }, + comboLabel: { + id: 'app.shortcut-help.comboLabel', + description: 'heading for key combo column', + }, + alternativeLabel: { + id: 'app.shortcut-help.alternativeLabel', + description: 'heading for key alternatives column', + }, + functionLabel: { + id: 'app.shortcut-help.functionLabel', + description: 'heading for shortcut function column', + }, + openoptions: { + id: 'app.shortcut-help.openOptions', + description: 'describes the open options shortcut', + }, + toggleuserlist: { + id: 'app.shortcut-help.toggleUserList', + description: 'describes the toggle userlist shortcut', + }, + togglemute: { + id: 'app.shortcut-help.toggleMute', + description: 'describes the toggle mute shortcut', + }, + togglepublicchat: { + id: 'app.shortcut-help.togglePublicChat', + description: 'describes the toggle public chat shortcut', + }, + hideprivatechat: { + id: 'app.shortcut-help.hidePrivateChat', + description: 'describes the hide public chat shortcut', + }, + closeprivatechat: { + id: 'app.shortcut-help.closePrivateChat', + description: 'describes the close private chat shortcut', + }, + openactions: { + id: 'app.shortcut-help.openActions', + description: 'describes the open actions shortcut', + }, + opendebugwindow: { + id: 'app.shortcut-help.openDebugWindow', + description: 'describes the open debug window shortcut', + }, + openstatus: { + id: 'app.shortcut-help.openStatus', + description: 'describes the open status shortcut', + }, + joinaudio: { + id: 'app.audio.joinAudio', + description: 'describes the join audio shortcut', + }, + leaveaudio: { + id: 'app.audio.leaveAudio', + description: 'describes the leave audio shortcut', + }, + raisehand: { + id: 'app.shortcut-help.raiseHand', + description: 'describes the toggle raise hand shortcut', + }, + togglePan: { + id: 'app.shortcut-help.togglePan', + description: 'describes the toggle pan shortcut', + }, + toggleFullscreen: { + id: 'app.shortcut-help.toggleFullscreen', + description: 'describes the toggle full-screen shortcut', + }, + nextSlideDesc: { + id: 'app.shortcut-help.nextSlideDesc', + description: 'describes the next slide shortcut', + }, + previousSlideDesc: { + id: 'app.shortcut-help.previousSlideDesc', + description: 'describes the previous slide shortcut', + }, + togglePanKey: { + id: 'app.shortcut-help.togglePanKey', + description: 'describes the toggle pan shortcut key', + }, + toggleFullscreenKey: { + id: 'app.shortcut-help.toggleFullscreenKey', + description: 'describes the toggle full-screen shortcut key', + }, + nextSlideKey: { + id: 'app.shortcut-help.nextSlideKey', + description: 'describes the next slide shortcut key', + }, + previousSlideKey: { + id: 'app.shortcut-help.previousSlideKey', + description: 'describes the previous slide shortcut key', + }, + select: { + id: 'app.shortcut-help.select', + description: 'describes the selection tool shortcut key', + }, + pencil: { + id: 'app.shortcut-help.pencil', + description: 'describes the pencil tool shortcut key', + }, + eraser: { + id: 'app.shortcut-help.eraser', + description: 'describes the eraser tool shortcut key', + }, + rectangle: { + id: 'app.shortcut-help.rectangle', + description: 'describes the rectangle shape tool shortcut key', + }, + elipse: { + id: 'app.shortcut-help.elipse', + description: 'describes the elipse shape tool shortcut key', + }, + triangle: { + id: 'app.shortcut-help.triangle', + description: 'describes the triangle shape tool shortcut key', + }, + line: { + id: 'app.shortcut-help.line', + description: 'describes the line shape tool shortcut key', + }, + arrow: { + id: 'app.shortcut-help.arrow', + description: 'describes the arrow shape tool shortcut key', + }, + text: { + id: 'app.shortcut-help.text', + description: 'describes the text tool shortcut key', + }, + note: { + id: 'app.shortcut-help.note', + description: 'describes the sticky note shortcut key', + }, + general: { + id: 'app.shortcut-help.general', + description: 'general tab heading', + }, + presentation: { + id: 'app.shortcut-help.presentation', + description: 'presentation tab heading', + }, + whiteboard: { + id: 'app.shortcut-help.whiteboard', + description: 'whiteboard tab heading', + }, + zoomIn: { + id: 'app.shortcut-help.zoomIn', + description: 'describes the zoom in shortcut key', + }, + zoomOut: { + id: 'app.shortcut-help.zoomOut', + description: 'describes the zoom out shortcut key', + }, + zoomFit: { + id: 'app.shortcut-help.zoomFit', + description: 'describes the zoom to fit shortcut key', + }, + zoomSelect: { + id: 'app.shortcut-help.zoomSelect', + description: 'describes the zoom to selection shortcut key', + }, + flipH: { + id: 'app.shortcut-help.flipH', + description: 'describes the flip horozontally shortcut key', + }, + flipV: { + id: 'app.shortcut-help.flipV', + description: 'describes the flip vertically shortcut key', + }, + lock: { + id: 'app.shortcut-help.lock', + description: 'describes the lock / unlock shape shortcut key', + }, + moveToFront: { + id: 'app.shortcut-help.moveToFront', + description: 'describes the move to front shortcut key', + }, + moveToBack: { + id: 'app.shortcut-help.moveToBack', + description: 'describes the move to back shortcut key', + }, + moveForward: { + id: 'app.shortcut-help.moveForward', + description: 'describes the move forward shortcut key', + }, + moveBackward: { + id: 'app.shortcut-help.moveBackward', + description: 'describes the move backward shortcut key', + }, + undo: { + id: 'app.shortcut-help.undo', + description: 'describes the undo shortcut key', + }, + redo: { + id: 'app.shortcut-help.redo', + description: 'describes the redo shortcut key', + }, + cut: { + id: 'app.shortcut-help.cut', + description: 'describes the cut shortcut key', + }, + copy: { + id: 'app.shortcut-help.copy', + description: 'describes the cut shortcut key', + }, + paste: { + id: 'app.shortcut-help.paste', + description: 'describes the paste shortcut key', + }, + selectAll: { + id: 'app.shortcut-help.selectAll', + description: 'describes the select all shortcut key', + }, + delete: { + id: 'app.shortcut-help.delete', + description: 'describes the delete shortcut key', + }, + duplicate: { + id: 'app.shortcut-help.duplicate', + description: 'describes the duplicate shortcut key', + } +}); + + +const renderItem = (func, key) => { + return ( + + {func} + {key} + + ); +} + +const renderItemWhiteBoard = (func, key, alt) => { + return ( + + {func} + {key} + {alt} + + ); +} + +const ShortcutHelpComponent = (props) => { + const { intl, shortcuts, + isOpen, + onRequestClose, + priority, + } = props; + const { browserName } = browserInfo; + const { isIos, isMacos } = deviceInfo; + const [ selectedTab, setSelectedTab] = React.useState(0); + + let accessMod = null; + + // different browsers use different access modifier keys + // on different systems when using accessKey property. + // Overview how different browsers behave: https://www.w3schools.com/jsref/prop_html_accesskey.asp + switch (browserName) { + case 'Chrome': + case 'Microsoft Edge': + accessMod = 'Alt'; + break; + case 'Firefox': + accessMod = 'Alt + Shift'; + break; + default: + break; + } + + // all Browsers on iOS are using Control + Alt as access modifier + if (isIos) { + accessMod = 'Control + Alt'; + } + // all Browsers on MacOS are using Control + Option as access modifier + if (isMacos) { + accessMod = 'Control + Option'; + } + + const generalShortcutItems = shortcuts.map((shortcut) => { + if (!isChatEnabled() && shortcut.descId.indexOf('Chat') !== -1) return null; + return renderItem( + `${intl.formatMessage(intlMessages[`${shortcut.descId.toLowerCase()}`])}`, + `${accessMod} + ${shortcut.accesskey}` + ); + }); + + const shortcutItems = []; + shortcutItems.push(renderItem(intl.formatMessage(intlMessages.togglePan), + intl.formatMessage(intlMessages.togglePanKey))); + shortcutItems.push(renderItem(intl.formatMessage(intlMessages.toggleFullscreen), + intl.formatMessage(intlMessages.toggleFullscreenKey))); + shortcutItems.push(renderItem(intl.formatMessage(intlMessages.nextSlideDesc), + intl.formatMessage(intlMessages.nextSlideKey))); + shortcutItems.push(renderItem(intl.formatMessage(intlMessages.previousSlideDesc), + intl.formatMessage(intlMessages.previousSlideKey))); + + const whiteboardShortcutItems = []; + //tools + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.select), '1', 'V')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.pencil), '2', 'D, P')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.eraser), '3', 'E')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.rectangle), '4', 'R')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.elipse), '5', 'O')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.triangle), '6', 'G')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.line), '7', 'L')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.arrow), '8', 'A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.text), '9', 'T')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.note), '0', 'S')); + //views + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomIn), 'Ctrl +', 'Ctrl M. Wheel up')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomOut), 'Ctrl -', 'Ctrl M. Wheel down')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomFit), 'Shift 1', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomSelect), 'Shift 2', 'N/A')); +//transform + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.flipH), 'Shift H', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.flipV), 'Shift V', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.lock), 'Ctrl Shift L', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveToFront), 'Shift ]', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveForward), ']', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveBackward), '[', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveToBack), 'Shift [', 'N/A')); + //edit + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.undo), 'Ctrl Z', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.redo), 'Ctrl Shift Z', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.cut), 'Ctrl X', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.copy), 'Ctrl C', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.paste), 'Ctrl V', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.selectAll), 'Ctrl A', 'N/A')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.delete), 'Del', 'Backspace')); + whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.duplicate), 'Ctrl D', 'N/A')); + + return ( + + setSelectedTab(tab)} + selectedIndex={selectedTab} + role="presentation" + > + + + + {intl.formatMessage(intlMessages.general)} + + + + + {intl.formatMessage(intlMessages.presentation)} + + + + + {intl.formatMessage(intlMessages.whiteboard)} + + + + + {!accessMod ?

{intl.formatMessage(intlMessages.accessKeyNotAvailable)}

+ : ( + + + + + {intl.formatMessage(intlMessages.functionLabel)} + {intl.formatMessage(intlMessages.comboLabel)} + + {generalShortcutItems} + + + + ) + } +
+ + + + + + {intl.formatMessage(intlMessages.functionLabel)} + {intl.formatMessage(intlMessages.comboLabel)} + + {shortcutItems} + + + + + + + + + + {intl.formatMessage(intlMessages.functionLabel)} + {intl.formatMessage(intlMessages.comboLabel)} + {intl.formatMessage(intlMessages.alternativeLabel)} + + {whiteboardShortcutItems} + + + + + +
+
+ ); +}; + +ShortcutHelpComponent.defaultProps = { + intl: {}, +}; + +ShortcutHelpComponent.propTypes = { + intl: PropTypes.object.isRequired, + shortcuts: PropTypes.arrayOf(PropTypes.shape({ + accesskey: PropTypes.string.isRequired, + descId: PropTypes.string.isRequired, + })).isRequired, +}; + +export default withShortcutHelper(injectIntl(ShortcutHelpComponent)); diff --git a/src/2.7.12/imports/ui/components/shortcut-help/service.jsx b/src/2.7.12/imports/ui/components/shortcut-help/service.jsx new file mode 100644 index 00000000..a0e983d7 --- /dev/null +++ b/src/2.7.12/imports/ui/components/shortcut-help/service.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import getFromUserSettings from '/imports/ui/services/users-settings'; + +const BASE_SHORTCUTS = Meteor.settings.public.app.shortcuts; + +const withShortcutHelper = (WrappedComponent, param) => (props) => { + const ENABLED_SHORTCUTS = getFromUserSettings('bbb_shortcuts', null); + let shortcuts = Object.values(BASE_SHORTCUTS); + + if (ENABLED_SHORTCUTS) { + shortcuts = Object.values(BASE_SHORTCUTS).map((el) => { + const obj = { ...el }; + obj.descId = obj.descId.toLowerCase(); + return obj; + }).filter(el => ENABLED_SHORTCUTS.includes(el.descId.toLowerCase())); + } + + if (param !== undefined) { + if (!Array.isArray(param)) { + shortcuts = shortcuts + .filter(el => el.descId.toLowerCase() === param.toLowerCase()) + .map(el => el.accesskey) + .pop(); + } else { + shortcuts = shortcuts + .filter(el => param.map(p => p.toLowerCase()).includes(el.descId.toLowerCase())) + .reduce((acc, current) => { + acc[current.descId.toLowerCase()] = current.accesskey; + return acc; + }, {}); + } + } + + return ( + + ); +}; + +export default withShortcutHelper; diff --git a/src/2.7.12/imports/ui/components/shortcut-help/styles.js b/src/2.7.12/imports/ui/components/shortcut-help/styles.js new file mode 100644 index 00000000..6e9170a1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/shortcut-help/styles.js @@ -0,0 +1,66 @@ +import styled from 'styled-components'; +import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general'; +import { colorOffWhite, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette'; +import { Tabs } from 'react-tabs'; +import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable'; +import StyledSettings from '../settings/styles'; + +import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; + +const KeyCell = styled.td` + text-align: center; + padding: ${smPaddingX}; + margin: auto; + width: 6rem; + min-width: 6rem; +`; + +const DescCell = styled.td` + padding: ${smPaddingX}; + margin: auto; +`; + +const ShortcutTable = styled.table` + border-collapse: collapse; + margin: 0; + width: 100%; + + > tbody > tr:nth-child(even) { + background-color: ${colorOffWhite}; + color: ${colorPrimary}; + } +`; + +const SettingsTabs = styled(Tabs)` + display: flex; + flex-flow: row; + justify-content: flex-start; + margin-top: 1rem; + + @media ${smallOnly} { + width: 100%; + flex-flow: column; + } +`; + +const TableWrapper = styled(ScrollboxVertical)` + height: 50vh; + width: 100%; +`; + +const TabPanel = styled(StyledSettings.SettingsTabPanel)` + margin-top: ${smPaddingX}; + + @media ${smallOnly} { + padding: 0; + } +`; + +export default { + KeyCell, + DescCell, + ShortcutTable, + SettingsTabs, + TableWrapper, + TabPanel, +}; diff --git a/src/2.7.12/imports/ui/components/sidebar-content/component.jsx b/src/2.7.12/imports/ui/components/sidebar-content/component.jsx new file mode 100644 index 00000000..6628f83a --- /dev/null +++ b/src/2.7.12/imports/ui/components/sidebar-content/component.jsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Resizable from 're-resizable'; +import { ACTIONS, PANELS } from '../layout/enums'; +import ChatContainer from '/imports/ui/components/chat/container'; +import NotesContainer from '/imports/ui/components/notes/container'; +import PollContainer from '/imports/ui/components/poll/container'; +import CaptionsContainer from '/imports/ui/components/captions/container'; +import BreakoutRoomContainer from '/imports/ui/components/breakout-room/container'; +import TimerContainer from '/imports/ui/components/timer/container'; +import WaitingUsersPanel from '/imports/ui/components/waiting-users/container'; +import Styled from './styles'; +import ErrorBoundary from '/imports/ui/components/common/error-boundary/component'; +import FallbackView from '/imports/ui/components/common/fallback-errors/fallback-view/component'; + +const propTypes = { + top: PropTypes.number.isRequired, + left: PropTypes.number, + right: PropTypes.number, + zIndex: PropTypes.number.isRequired, + minWidth: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + maxWidth: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + isResizable: PropTypes.bool.isRequired, + resizableEdge: PropTypes.objectOf(PropTypes.bool).isRequired, + contextDispatch: PropTypes.func.isRequired, +}; + +const defaultProps = { + left: null, + right: null, +}; + +const SidebarContent = (props) => { + const { + top, + left, + right, + zIndex, + minWidth, + width, + maxWidth, + minHeight, + height, + maxHeight, + isResizable, + resizableEdge, + contextDispatch, + sidebarContentPanel, + amIPresenter, + isSharedNotesPinned, + } = props; + + const [resizableWidth, setResizableWidth] = useState(width); + const [resizableHeight, setResizableHeight] = useState(height); + const [isResizing, setIsResizing] = useState(false); + const [resizeStartWidth, setResizeStartWidth] = useState(0); + const [resizeStartHeight, setResizeStartHeight] = useState(0); + + useEffect(() => { + if (!isResizing) { + setResizableWidth(width); + setResizableHeight(height); + } + }, [width, height]); + + const setSidebarContentSize = (dWidth, dHeight) => { + const newWidth = resizeStartWidth + dWidth; + const newHeight = resizeStartHeight + dHeight; + + setResizableWidth(newWidth); + setResizableHeight(newHeight); + + contextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_SIZE, + value: { + width: newWidth, + height: newHeight, + browserWidth: window.innerWidth, + browserHeight: window.innerHeight, + }, + }); + }; + + const smallSidebar = width < (maxWidth / 2); + const pollDisplay = sidebarContentPanel === PANELS.POLL ? 'inherit' : 'none'; + + return ( + { + setIsResizing(true); + setResizeStartWidth(resizableWidth); + setResizeStartHeight(resizableHeight); + }} + onResize={(...[, , , delta]) => setSidebarContentSize(delta.width, delta.height)} + onResizeStop={() => { + setIsResizing(false); + setResizeStartWidth(0); + setResizeStartHeight(0); + }} + style={{ + position: 'absolute', + top, + left, + right, + zIndex, + width, + height, + }} + handleStyles={{ + left: { height: '100vh' }, + right: { height: '100vh' }, + }} + > + {sidebarContentPanel === PANELS.CHAT + && ( + + + + )} + {!isSharedNotesPinned && ( + + )} + {sidebarContentPanel === PANELS.CAPTIONS && } + {sidebarContentPanel === PANELS.BREAKOUT && } + {sidebarContentPanel === PANELS.TIMER && } + {sidebarContentPanel === PANELS.WAITING_USERS && } + {sidebarContentPanel === PANELS.POLL && ( + + + + )} + + ); +}; + +SidebarContent.propTypes = propTypes; +SidebarContent.defaultProps = defaultProps; +export default SidebarContent; diff --git a/src/2.7.12/imports/ui/components/sidebar-content/container.jsx b/src/2.7.12/imports/ui/components/sidebar-content/container.jsx new file mode 100644 index 00000000..f567749b --- /dev/null +++ b/src/2.7.12/imports/ui/components/sidebar-content/container.jsx @@ -0,0 +1,26 @@ +import React, { useContext } from 'react'; +import SidebarContent from './component'; +import { layoutSelectInput, layoutSelectOutput, layoutDispatch } from '../layout/context'; +import { UsersContext } from '../components-data/users-context/context'; +import Auth from '/imports/ui/services/auth'; + +const SidebarContentContainer = () => { + const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent); + const sidebarContentOutput = layoutSelectOutput((i) => i.sidebarContent); + const layoutContextDispatch = layoutDispatch(); + const { sidebarContentPanel } = sidebarContentInput; + const usingUsersContext = useContext(UsersContext); + const { users } = usingUsersContext; + const amIPresenter = users[Auth.meetingID][Auth.userID].presenter; + + return ( + + ); +}; + +export default SidebarContentContainer; diff --git a/src/2.7.12/imports/ui/components/sidebar-content/styles.js b/src/2.7.12/imports/ui/components/sidebar-content/styles.js new file mode 100644 index 00000000..04c43f15 --- /dev/null +++ b/src/2.7.12/imports/ui/components/sidebar-content/styles.js @@ -0,0 +1,43 @@ +import styled from 'styled-components'; +import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; +import { borderSize, navbarHeight } from '/imports/ui/stylesheets/styled-components/general'; +import { smallOnly, mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints'; + +const Poll = styled.div` + position: absolute; + display: flex; + flex-flow: column; + overflow-y: auto; + overflow-x: hidden; + outline: transparent; + outline-width: ${borderSize}; + outline-style: solid; + order: 2; + height: 100%; + background-color: ${colorWhite}; + min-width: 20em; + padding: 1rem; + + @media ${smallOnly} { + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 5; + height: auto; + top: ${navbarHeight}; + overflow: auto; + &.no-padding { + padding: 0; + } + } + + @media ${mediumUp} { + position: relative; + order: 1; + } +`; + +export default { + Poll, +}; diff --git a/src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx b/src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx new file mode 100644 index 00000000..583ed73c --- /dev/null +++ b/src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Resizable from 're-resizable'; +import { ACTIONS } from '../layout/enums'; +import UserListContainer from '../user-list/container'; + +const propTypes = { + top: PropTypes.number.isRequired, + left: PropTypes.number, + right: PropTypes.number, + zIndex: PropTypes.number.isRequired, + minWidth: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + maxWidth: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + isResizable: PropTypes.bool.isRequired, + resizableEdge: PropTypes.objectOf(PropTypes.bool).isRequired, + contextDispatch: PropTypes.func.isRequired, +}; + +const defaultProps = { + left: null, + right: null, +}; + +const SidebarNavigation = (props) => { + const { + top, + left, + right, + zIndex, + minWidth, + width, + maxWidth, + height, + isResizable, + resizableEdge, + contextDispatch, + } = props; + + const [resizableWidth, setResizableWidth] = useState(width); + const [isResizing, setIsResizing] = useState(false); + const [resizeStartWidth, setResizeStartWidth] = useState(0); + + useEffect(() => { + if (!isResizing) setResizableWidth(width); + }, [width]); + + const setSidebarNavWidth = (dWidth) => { + const newWidth = resizeStartWidth + dWidth; + + setResizableWidth(newWidth); + + contextDispatch({ + type: ACTIONS.SET_SIDEBAR_NAVIGATION_SIZE, + value: { + width: newWidth, + browserWidth: window.innerWidth, + browserHeight: window.innerHeight, + }, + }); + }; + + return ( + { + setIsResizing(true); + setResizeStartWidth(resizableWidth); + }} + onResize={(...[, , , delta]) => setSidebarNavWidth(delta.width)} + onResizeStop={() => { + setIsResizing(false); + setResizeStartWidth(0); + }} + style={{ + position: 'absolute', + top, + left, + right, + zIndex, + width, + height, + }} + > + + + ); +}; + +SidebarNavigation.propTypes = propTypes; +SidebarNavigation.defaultProps = defaultProps; +export default SidebarNavigation; diff --git a/src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx b/src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx new file mode 100644 index 00000000..cb122057 --- /dev/null +++ b/src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { layoutDispatch, layoutSelectOutput } from '../layout/context'; +import SidebarNavigation from './component'; + +const SidebarNavigationContainer = () => { + const sidebarNavigation = layoutSelectOutput((i) => i.sidebarNavigation); + const layoutContextDispatch = layoutDispatch(); + + if (sidebarNavigation.display === false) return null; + + return ( + + ); +}; + +export default SidebarNavigationContainer; diff --git a/src/2.7.12/imports/ui/components/subscriptions/component.jsx b/src/2.7.12/imports/ui/components/subscriptions/component.jsx new file mode 100644 index 00000000..908b85dd --- /dev/null +++ b/src/2.7.12/imports/ui/components/subscriptions/component.jsx @@ -0,0 +1,167 @@ +import { Component } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Auth from '/imports/ui/services/auth'; +import logger from '/imports/startup/client/logger'; +import GroupChat from '/imports/api/group-chat'; +import Annotations from '/imports/api/annotations'; +import Users from '/imports/api/users'; +import { Annotations as AnnotationsLocal } from '/imports/ui/components/whiteboard/service'; +import { + localCollectionRegistry, +} from '/client/collection-mirror-initializer'; +import SubscriptionRegistry, { subscriptionReactivity } from '../../services/subscription-registry/subscriptionRegistry'; +import { isChatEnabled } from '/imports/ui/services/features'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled; +const SUBSCRIPTIONS = [ + 'users', 'meetings', 'polls', 'presentations', 'slides', 'slide-positions', 'captions', + 'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat', + 'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'meeting-time-remaining', + 'local-settings', 'users-typing', 'record-meetings', 'video-streams', + 'connection-status', 'voice-call-states', 'external-video-meetings', 'breakouts', 'breakouts-history', + 'pads', 'pads-sessions', 'pads-updates', 'notifications', 'audio-captions', + 'layout-meetings', 'user-reaction', 'timer', +]; +const { + localBreakoutsSync, + localBreakoutsHistorySync, + localGuestUsersSync, + localMeetingsSync, + localUsersSync, +} = localCollectionRegistry; + +const EVENT_NAME = 'bbb-group-chat-messages-subscription-has-stoppped'; +const EVENT_NAME_SUBSCRIPTION_READY = 'bbb-group-chat-messages-subscriptions-ready'; + +let oldRole = ''; + +class Subscriptions extends Component { + componentDidUpdate() { + const { subscriptionsReady } = this.props; + if (subscriptionsReady) { + Session.set('subscriptionsReady', true); + const event = new Event(EVENT_NAME_SUBSCRIPTION_READY); + window.dispatchEvent(event); + Session.set('globalIgnoreDeletes', false); + } + } + + render() { + const { children } = this.props; + return children; + } +} + +export default withTracker(() => { + const { credentials } = Auth; + const { meetingId, requesterUserId } = credentials; + const userWillAuth = Session.get('userWillAuth'); + // This if exist because when a unauth user try to subscribe to a publisher + // it returns a empty collection to the subscription + // and not rerun when the user is authenticated + if (userWillAuth) return {}; + + if (Session.get('codeError')) { + return { + subscriptionsReady: true, + }; + } + + const subscriptionErrorHandler = { + onError: (error) => { + logger.error({ + logCode: 'startup_client_subscription_error', + extraInfo: { error }, + }, 'Error while subscribing to collections'); + Session.set('codeError', error.error); + }, + }; + + const currentUser = Users.findOne({ intId: requesterUserId }, { fields: { role: 1 } }); + + let subscriptionsHandlers = SUBSCRIPTIONS.map((name) => { + let subscriptionHandlers = subscriptionErrorHandler; + if ((!TYPING_INDICATOR_ENABLED && name.indexOf('typing') !== -1) + || (!isChatEnabled() && name.indexOf('chat') !== -1)) return null; + + if (name === 'users') { + subscriptionHandlers = { + ...subscriptionHandlers, + onStop: () => { + const event = new Event(EVENT_NAME); + window.dispatchEvent(event); + }, + }; + } + + return SubscriptionRegistry.createSubscription(name, subscriptionHandlers); + }); + + if (currentUser && (oldRole !== currentUser?.role)) { + // stop subscription from the client-side as the server-side only watch moderators + if (oldRole === 'VIEWER' && currentUser?.role === 'MODERATOR') { + // let this withTracker re-execute when a subscription is stopped + subscriptionReactivity.depend(); + localBreakoutsSync.setIgnoreDeletes(true); + localBreakoutsHistorySync.setIgnoreDeletes(true); + localGuestUsersSync.setIgnoreDeletes(true); + localMeetingsSync.setIgnoreDeletes(true); + localUsersSync.setIgnoreDeletes(true); + // Prevent data being removed by subscription stop + // stop role dependent subscriptions + [ + SubscriptionRegistry.getSubscription('meetings'), + SubscriptionRegistry.getSubscription('users'), + SubscriptionRegistry.getSubscription('breakouts'), + SubscriptionRegistry.getSubscription('breakouts-history'), + SubscriptionRegistry.getSubscription('connection-status'), + SubscriptionRegistry.getSubscription('guestUser'), + ].forEach((item) => { + if (item) item.stop(); + }); + } + oldRole = currentUser?.role; + } + + subscriptionsHandlers = subscriptionsHandlers.filter(obj => obj); + const ready = subscriptionsHandlers.every(handler => handler.ready()); + let groupChatMessageHandler = {}; + + if (isChatEnabled() && ready) { + const subHandler = { + ...subscriptionErrorHandler, + }; + + groupChatMessageHandler = Meteor.subscribe('group-chat-msg', subHandler); + } + + // TODO: Refactor all the late subscribers + let usersPersistentDataHandler = {}; + if (ready) { + usersPersistentDataHandler = Meteor.subscribe('users-persistent-data'); + const annotationsHandler = Meteor.subscribe('annotations', { + onReady: () => { + AnnotationsLocal.remove({}); + Annotations.find({}, { reactive: false }).forEach((a) => { + try { + AnnotationsLocal.insert(a); + } catch (e) { + // TODO + } + }); + annotationsHandler.stop(); + }, + ...subscriptionErrorHandler, + }); + + Object.values(localCollectionRegistry).forEach( + (localCollection) => localCollection.checkForStaleData(), + ); + } + + return { + subscriptionsReady: ready, + subscriptionsHandlers, + }; +})(Subscriptions); diff --git a/src/2.7.12/imports/ui/components/text-input/component.jsx b/src/2.7.12/imports/ui/components/text-input/component.jsx new file mode 100644 index 00000000..d513e4dd --- /dev/null +++ b/src/2.7.12/imports/ui/components/text-input/component.jsx @@ -0,0 +1,181 @@ +import React, { PureComponent } from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { isMobile } from '/imports/utils/deviceInfo'; +import logger from '/imports/startup/client/logger'; +import ClickOutside from '/imports/ui/components/click-outside/component'; +import Styled from './styles'; + +const EMOJI_BUTTON = Meteor.settings.public.app.enableEmojiButton; + +const propTypes = { + placeholder: PropTypes.string, + send: PropTypes.func.isRequired, + emojiPickerDown: PropTypes.bool, +}; + +const defaultProps = { + placeholder: '', + send: () => logger.warn({ logCode: 'text_input_send_function' }, `Missing`), + emojiPickerDown: false, + enableEmoji: true, +}; + +const messages = defineMessages({ + sendLabel: { + id: 'app.textInput.sendLabel', + description: 'Text input send button label', + }, +}); + +class TextInput extends PureComponent { + constructor(props) { + super(props); + + this.state = { + message: '', + showEmojiPicker: false + }; + } + + hasClickOutsideActions() { + return this.emojiEnabled(); + } + + handleOnChange(e) { + const message = e.target.value; + this.setState({ message }); + } + + handleOnClick() { + const { send } = this.props; + const { message } = this.state; + + send(message); + this.setState({ + message: '', + showEmojiPicker: false, + }); + } + + handleOnKeyDown(e) { + if (e.keyCode === 13 && e.shiftKey === false) { + e.preventDefault(); + this.handleOnClick(); + } else if (e.keyCode === 27) { //Escape key + const { showEmojiPicker } = this.state; + + //if the emoji picker is opened, close it + if (showEmojiPicker) { + this.setState({ showEmojiPicker: false }); + } + } + } + + handleEmojiButtonClick() { + const { showEmojiPicker } = this.state; + + if (this.textarea) this.textarea.focus(); + this.setState({ showEmojiPicker: !showEmojiPicker }); + } + + handleEmojiSelect(emojiObject) { + const { message } = this.state; + if (this.textarea) this.textarea.focus(); + this.setState({ message: message + emojiObject.native }); + } + + handleClickOutside() { + if (this.emojiEnabled()) { + const { showEmojiPicker } = this.state; + if (showEmojiPicker) { + this.setState({ showEmojiPicker: false }); + } + } + } + + emojiEnabled() { + if (isMobile) return false; + + const { enableEmoji } = this.props; + return enableEmoji && EMOJI_BUTTON; + } + + renderEmojiPicker() { + const { showEmojiPicker } = this.state; + + if (this.emojiEnabled() && showEmojiPicker) { + return ( + this.handleEmojiSelect(emojiObject)} + /> + ); + } + + return null; + } + + renderEmojiButton = () => { + if (!this.emojiEnabled()) return null; + + return ( + this.handleEmojiButtonClick()} + > + + + ); + } + + renderInput() { + const { + intl, + maxLength, + placeholder, + emojiPickerDown, + } = this.props; + + const { message } = this.state; + + return ( + + this.handleOnChange(e)} + onKeyDown={(e) => this.handleOnKeyDown(e)} + onPaste={(e) => { e.stopPropagation(); }} + onCut={(e) => { e.stopPropagation(); }} + onCopy={(e) => { e.stopPropagation(); }} + placeholder={placeholder} + value={message} + /> + this.handleOnClick()} + /> + + ); + } + + render() { + return this.hasClickOutsideActions() ? ( + this.handleClickOutside()} + > + {this.renderInput()} + + ) : this.renderInput(); + } +} + +TextInput.propTypes = propTypes; +TextInput.defaultProps = defaultProps; + +export default injectIntl(TextInput); diff --git a/src/2.7.12/imports/ui/components/text-input/styles.js b/src/2.7.12/imports/ui/components/text-input/styles.js new file mode 100644 index 00000000..92f4fd0f --- /dev/null +++ b/src/2.7.12/imports/ui/components/text-input/styles.js @@ -0,0 +1,79 @@ +import styled from 'styled-components'; +import { + smPaddingX, + smPaddingY, + borderRadius, + borderSize, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorText, + colorGrayLighter, + colorBlueLight, + colorPrimary, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography'; +import TextareaAutosize from 'react-autosize-textarea'; +import Button from '/imports/ui/components/common/button/component'; + +const Wrapper = styled.div` + display: flex; + flex-direction: row; +`; + +const TextArea = styled(TextareaAutosize)` + flex: 1; + background: #fff; + background-clip: padding-box; + margin: 0; + color: ${colorText}; + -webkit-appearance: none; + padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25); + resize: none; + transition: none; + border-radius: ${borderRadius}; + font-size: ${fontSizeBase}; + min-height: 2.5rem; + max-height: 10rem; + border: 1px solid ${colorGrayLighter}; + box-shadow: 0 0 0 1px ${colorGrayLighter}; + + &:hover { + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + } + + &:active, + &:focus { + outline: transparent; + outline-width: ${borderSize}; + outline-style: solid; + } + + &:focus { + outline: none; + border-radius: ${borderSize}; + box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + } +`; + +const TextInputButton = styled(Button)` + margin:0 0 0 ${smPaddingX}; + align-self: center; + font-size: 0.9rem; + + [dir="rtl"] & { + margin: 0 ${smPaddingX} 0 0; + -webkit-transform: scale(-1, 1); + -moz-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + transform: scale(-1, 1); + } +`; + +export default { + Wrapper, + TextArea, + TextInputButton, +}; diff --git a/src/2.7.12/imports/ui/components/timer/component.jsx b/src/2.7.12/imports/ui/components/timer/component.jsx new file mode 100644 index 00000000..01c995b7 --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/component.jsx @@ -0,0 +1,449 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Service from './service'; +import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; +import Styled from './styles'; + +const intlMessages = defineMessages({ + hideTimerLabel: { + id: 'app.timer.hideTimerLabel', + description: 'Label for hiding timer button', + }, + title: { + id: 'app.timer.title', + description: 'Title for timer', + }, + stopwatch: { + id: 'app.timer.button.stopwatch', + description: 'Stopwatch switch button', + }, + timer: { + id: 'app.timer.button.timer', + description: 'Timer switch button', + }, + start: { + id: 'app.timer.button.start', + description: 'Timer start button', + }, + stop: { + id: 'app.timer.button.stop', + description: 'Timer stop button', + }, + reset: { + id: 'app.timer.button.reset', + description: 'Timer reset button', + }, + hours: { + id: 'app.timer.hours', + description: 'Timer hours label', + }, + minutes: { + id: 'app.timer.minutes', + description: 'Timer minutes label', + }, + seconds: { + id: 'app.timer.seconds', + description: 'Timer seconds label', + }, + songs: { + id: 'app.timer.music', + description: 'Music title label', + }, + noTrack: { + id: 'app.timer.noTrack', + description: 'No track radio label', + }, + track1: { + id: 'app.timer.track1', + description: 'Track 1 radio label', + }, + track2: { + id: 'app.timer.track2', + description: 'Track 2 radio label', + }, + track3: { + id: 'app.timer.track3', + description: 'Track 3 radio label', + }, +}); + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + timer: PropTypes.shape({ + stopwatch: PropTypes.bool, + running: PropTypes.bool, + time: PropTypes.string, + accumulated: PropTypes.number, + timestamp: PropTypes.number, + }).isRequired, + layoutContextDispatch: PropTypes.shape().isRequired, + timeOffset: PropTypes.number.isRequired, + isRTL: PropTypes.bool.isRequired, + isActive: PropTypes.bool.isRequired, + isModerator: PropTypes.bool.isRequired, + currentTrack: PropTypes.string.isRequired, + isResizing: PropTypes.bool.isRequired, +}; + +class Timer extends Component { + static handleOnTrackChange(event) { + Service.setTrack(event.target.value); + } + + constructor(props) { + super(props); + + this.timeRef = React.createRef(); + this.interval = null; + + this.updateTime = this.updateTime.bind(this); + } + + componentDidMount() { + const { timer } = this.props; + const { running } = timer; + + const { current } = this.timeRef; + if (current && running) { + this.interval = setInterval(this.updateTime, Service.getInterval()); + } + } + + componentDidUpdate(prevProps) { + const { timer } = this.props; + const { timer: prevTimer } = prevProps; + + this.updateInterval(prevTimer, timer); + } + + componentWillUnmount() { + clearInterval(this.interval); + } + + handleControlClick() { + const { timer } = this.props; + + if (timer.running) { + Service.stopTimer(); + } else { + Service.startTimer(); + } + } + + handleOnHoursChange(event) { + const { timer } = this.props; + const { target } = event; + + if (target && target.value) { + const hours = parseInt(target.value, 10); + Service.setHours(hours, timer.time); + } + } + + handleOnMinutesChange(event) { + const { timer } = this.props; + const { target } = event; + + if (target && target.value) { + const minutes = parseInt(target.value, 10); + Service.setMinutes(minutes, timer.time); + } + } + + handleOnSecondsChange(event) { + const { timer } = this.props; + const { target } = event; + + if (target && target.value) { + const seconds = parseInt(target.value, 10); + Service.setSeconds(seconds, timer.time); + } + } + + handleSwitchToStopwatch() { + const { timer } = this.props; + + if (!timer.stopwatch) { + Service.switchTimer(true); + } + } + + handleSwitchToTimer() { + const { timer } = this.props; + + if (timer.stopwatch) { + Service.switchTimer(false); + } + } + + getTime() { + const { + timer, + timeOffset, + } = this.props; + + const { + stopwatch, + running, + time, + accumulated, + timestamp, + } = timer; + + const elapsedTime = Service.getElapsedTime(running, timestamp, timeOffset, accumulated); + + let updatedTime; + if (stopwatch) { + updatedTime = elapsedTime; + } else { + updatedTime = Math.max(time - elapsedTime, 0); + } + + return Service.getTimeAsString(updatedTime, stopwatch); + } + + updateTime() { + const { current } = this.timeRef; + if (current) { + current.textContent = this.getTime(); + } + } + + updateInterval(prevTimer, timer) { + const { running } = timer; + const { running: prevRunning } = prevTimer; + + if (!prevRunning && running) { + this.interval = setInterval(this.updateTime, Service.getInterval()); + } + + if (prevRunning && !running) { + clearInterval(this.interval); + } + } + + renderControls() { + const { + intl, + timer, + } = this.props; + + const { running } = timer; + + const label = running ? intlMessages.stop : intlMessages.start; + const color = running ? 'danger' : 'primary'; + + return ( + + this.handleControlClick()} + /> + Service.resetTimer()} + /> + + ); + } + + renderSongSelectorRadios() { + const { + intl, + timer, + currentTrack, + } = this.props; + + const { + stopwatch, + } = timer; + + return ( + + + {intl.formatMessage(intlMessages.songs)} + + + {Service.TRACKS.map((track) => ( + + + + ))} + + + ); + } + + renderTimer() { + const { + intl, + timer, + } = this.props; + + const { + time, + stopwatch, + } = timer; + + if (stopwatch) return this.renderControls(); + + const timeArray = Service.getTimeAsString(time).split(':'); + + const hasHours = timeArray.length === 3; + + const hours = hasHours ? timeArray[0] : '00'; + const minutes = hasHours ? timeArray[1] : timeArray[0]; + const seconds = hasHours ? timeArray[2] : timeArray[1]; + + return ( +
+ + + this.handleOnHoursChange(event)} + /> + + {intl.formatMessage(intlMessages.hours)} + + + : + + this.handleOnMinutesChange(event)} + /> + + {intl.formatMessage(intlMessages.minutes)} + + + : + + this.handleOnSecondsChange(event)} + /> + + {intl.formatMessage(intlMessages.seconds)} + + + + { Service.isMusicEnabled() + ? this.renderSongSelectorRadios() : null} + {this.renderControls()} +
+ ); + } + + renderContent() { + const { + intl, + isResizing, + timer, + } = this.props; + + const { stopwatch } = timer; + + return ( + + + {this.getTime()} + + + this.handleSwitchToStopwatch()} + color={stopwatch ? 'primary' : 'secondary'} + /> + this.handleSwitchToTimer()} + color={!stopwatch ? 'primary' : 'secondary'} + /> + + {this.renderTimer()} + + ); + } + + render() { + const { + intl, + isRTL, + isActive, + isModerator, + layoutContextDispatch, + timer, + } = this.props; + + if (!isActive || !isModerator) { + Service.closePanel(layoutContextDispatch); + return null; + } + + const { stopwatch } = timer; + const message = stopwatch ? intlMessages.stopwatch : intlMessages.timer; + + return ( + + + + Service.closePanel(layoutContextDispatch)} + aria-label={intl.formatMessage(intlMessages.hideTimerLabel)} + label={intl.formatMessage(message)} + icon={isRTL ? 'right_arrow' : 'left_arrow'} + /> + + + {this.renderContent()} + + ); + } +} + +Timer.propTypes = propTypes; + +export default injectWbResizeEvent(injectIntl(Timer)); diff --git a/src/2.7.12/imports/ui/components/timer/container.jsx b/src/2.7.12/imports/ui/components/timer/container.jsx new file mode 100644 index 00000000..af2883df --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/container.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Timer from './component'; +import Service from './service'; +import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context'; + +const TimerContainer = ({ children, ...props }) => { + const layoutContextDispatch = layoutDispatch(); + const cameraDock = layoutSelectInput((i) => i.cameraDock); + const { isResizing } = cameraDock; + return ( + + {children} + + ); +}; + +export default withTracker(() => { + const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; + return { + isRTL, + isActive: Service.isActive(), + isModerator: Service.isModerator(), + timeOffset: Service.getTimeOffset(), + timer: Service.getTimer(), + currentTrack: Service.getCurrentTrack(), + }; +})(TimerContainer); diff --git a/src/2.7.12/imports/ui/components/timer/indicator/component.jsx b/src/2.7.12/imports/ui/components/timer/indicator/component.jsx new file mode 100644 index 00000000..01e2c7ad --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/indicator/component.jsx @@ -0,0 +1,460 @@ +import React, { Component } from 'react'; +import Icon from '/imports/ui/components/common/icon/component'; +import TimerService from '/imports/ui/components/timer/service'; +import logger from '/imports/startup/client/logger'; +import Tooltip from '/imports/ui/components/common/tooltip/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import Styled from './styles'; + +const CDN = Meteor.settings.public.app.cdn; +const BASENAME = Meteor.settings.public.app.basename; +const HOST = CDN + BASENAME; +const trackName = Meteor.settings.public.timer.music; +const TAB_TIMER_INDICATOR = Meteor.settings.public.timer.tabIndicator; + +const propTypes = { + timer: PropTypes.shape({ + stopwatch: PropTypes.bool, + running: PropTypes.bool, + time: PropTypes.number, + accumulated: PropTypes.number, + timestamp: PropTypes.number, + }).isRequired, + isTimerActive: PropTypes.bool.isRequired, + isMusicActive: PropTypes.bool.isRequired, + sidebarNavigationIsOpen: PropTypes.bool.isRequired, + sidebarContentIsOpen: PropTypes.bool.isRequired, + timeOffset: PropTypes.number.isRequired, + isModerator: PropTypes.bool.isRequired, + currentTrack: PropTypes.bool.isRequired, +}; + +const intlMessages = defineMessages({ + toolTipTimerStopped: { + id: 'app.timer.toolTipTimerStopped', + }, + toolTipTimerRunning: { + id: 'app.timer.toolTipTimerRunning', + }, + toolTipStopwatchStopped: { + id: 'app.timer.toolTipStopwatchStopped', + }, + toolTipStopwatchRunning: { + id: 'app.timer.toolTipStopwatchRunning', + }, + toolTipTimerStoppedMod: { + id: 'app.timer.toolTipTimerStoppedMod', + }, + toolTipTimerRunningMod: { + id: 'app.timer.toolTipTimerRunningMod', + }, + toolTipStopwatchStoppedMod: { + id: 'app.timer.toolTipStopwatchStoppedMod', + }, + toolTipStopwatchRunningMod: { + id: 'app.timer.toolTipStopwatchRunningMod', + }, +}); + +class Indicator extends Component { + constructor(props) { + super(props); + + this.timeRef = React.createRef(); + this.interval = null; + + this.alarm = null; + this.music = null; + + // We need to avoid trigger on mount + this.triggered = true; + + this.alreadyNotified = false; + + this.updateTime = this.updateTime.bind(this); + } + + componentDidMount() { + const { timer } = this.props; + const { running } = timer; + + this.alarm = new Audio(`${HOST}/resources/sounds/alarm.mp3`); + this.setUpMusic(); + + this.triggered = this.initTriggered(); + + const { current } = this.timeRef; + if (current && running) { + this.interval = setInterval(this.updateTime, TimerService.getInterval()); + } + } + + componentDidUpdate(prevProps) { + const { timer, isTimerActive } = this.props; + const { timer: prevTimer, isTimerActive: prevTimerActive } = prevProps; + + if (this.shouldPlayMusic()) { + this.playMusic(); + } + + if (!isTimerActive && prevTimerActive) { + this.updateTabTitleTimer(true, this.getTime()); + } + + this.updateInterval(prevTimer, timer); + this.updateAlarmTrigger(prevTimer, timer); + } + + componentWillUnmount() { + clearInterval(this.interval); + this.stopMusic(); + } + + getTime() { + const { + timer, + timeOffset, + } = this.props; + + const { + stopwatch, + running, + time, + accumulated, + timestamp, + } = timer; + + const elapsedTime = TimerService.getElapsedTime(running, timestamp, timeOffset, accumulated); + + let updatedTime; + if (stopwatch) { + updatedTime = elapsedTime; + } else { + updatedTime = Math.max(time - elapsedTime, 0); + } + + if (this.shouldNotifyTimerEnded(updatedTime)) { + TimerService.timerEnded(); + } + + if (this.shouldStopMusic(updatedTime)) { + this.stopMusic(); + } + + if (this.soundAlarm(updatedTime)) { + this.play(); + } + + return TimerService.getTimeAsString(updatedTime, stopwatch); + } + + setUpMusic() { + const { currentTrack } = this.props; + if (trackName[currentTrack] === undefined) return; + if (this.music === null) { + this.music = new Audio(`${HOST}/resources/sounds/${trackName[currentTrack]}.mp3`); + this.music.volume = TimerService.getMusicVolume(); + this.music.addEventListener('timeupdate', () => { + const buffer = 0.19; + // Start playing the music before it ends to make the loop gapless + if (this.music.currentTime > this.music.duration - buffer) { + this.music.currentTime = 0; + this.music.play(); + } + }); + } else { + this.music.src = `${HOST}/resources/sounds/${trackName[currentTrack]}.mp3`; + } + this.music.track = currentTrack; + } + + updateInterval(prevTimer, timer) { + const { running } = timer; + const { running: prevRunning } = prevTimer; + + if (!prevRunning && running) { + this.interval = setInterval(this.updateTime, TimerService.getInterval()); + } + + if (prevRunning && !running) { + clearInterval(this.interval); + } + } + + updateAlarmTrigger(prevTimer, timer) { + const { + accumulated, + timestamp, + } = timer; + + const { timestamp: prevTimestamp } = prevTimer; + + const reseted = timestamp !== prevTimestamp && accumulated === 0; + + if (reseted) { + this.triggered = false; + this.alreadyNotified = false; + } + } + + initTriggered() { + const { + timer, + timeOffset, + } = this.props; + + const { + stopwatch, + running, + } = timer; + + if (stopwatch || !running) return false; + + const { + time, + accumulated, + timestamp, + } = timer; + + const elapsedTime = TimerService.getElapsedTime(running, timestamp, timeOffset, accumulated); + const updatedTime = Math.max(time - elapsedTime, 0); + + if (updatedTime === 0) return true; + + return false; + } + + play() { + if (this.alarm && !this.triggered) { + this.triggered = true; + this.alarm.play().catch((error) => { + logger.error({ + logCode: 'timer_sound_error', + extraInfo: { error }, + }, `Timer beep failed: ${error}`); + }); + } + } + + soundAlarm(time) { + const { timer } = this.props; + const { + running, + stopwatch, + } = timer; + + const enabled = TimerService.isAlarmEnabled(); + const zero = time === 0; + + return enabled && running && zero && !stopwatch; + } + + playMusic() { + const handleUserInteraction = () => { + this.music.play(); + window.removeEventListener('click', handleUserInteraction); + window.removeEventListener('auxclick', handleUserInteraction); + window.removeEventListener('keydown', handleUserInteraction); + window.removeEventListener('touchstart', handleUserInteraction); + }; + + const playMusicAfterUserInteraction = () => { + window.addEventListener('click', handleUserInteraction); + window.addEventListener('auxclick', handleUserInteraction); + window.addEventListener('keydown', handleUserInteraction); + window.addEventListener('touchstart', handleUserInteraction); + }; + + this.handleUserInteraction = handleUserInteraction; + + if (this.music !== null) { + this.music.play() + .catch((error) => { + if (error.name === 'NotAllowedError') { + playMusicAfterUserInteraction(); + } + }); + } + } + + stopMusic() { + if (this.music !== null) { + this.music.pause(); + this.music.currentTime = 0; + window.removeEventListener('click', this.handleUserInteraction); + window.removeEventListener('auxclick', this.handleUserInteraction); + window.removeEventListener('keydown', this.handleUserInteraction); + window.removeEventListener('touchstart', this.handleUserInteraction); + } + } + + shouldPlayMusic() { + const { + timer, + isMusicActive, + } = this.props; + + const { + running, + stopwatch, + } = timer; + const validMusic = this.music != null; + if (!validMusic) return false; + + const musicIsPlaying = !this.music.paused; + + return !musicIsPlaying && isMusicActive && running && !stopwatch; + } + + shouldStopMusic(time) { + const { + timer, + isTimerActive, + isMusicActive, + currentTrack, + } = this.props; + + const { + running, + stopwatch, + } = timer; + + const zero = time === 0; + const validMusic = this.music != null; + const musicIsPlaying = validMusic && !this.music.paused; + const trackChanged = this.music?.track !== currentTrack; + + const reachedZeroOrStopped = (running && zero) || (!running); + + return musicIsPlaying + && (!isMusicActive || stopwatch || reachedZeroOrStopped || !isTimerActive || trackChanged); + } + + shouldNotifyTimerEnded(time) { + const { timer } = this.props; + const { + running, + stopwatch, + } = timer; + + if (stopwatch || !running) return false; + + const reachedZero = time === 0; + + if (reachedZero && !this.alreadyNotified) { + this.alreadyNotified = true; + return true; + } + return false; + } + + updateTime() { + const { current } = this.timeRef; + if (current) { + current.textContent = this.getTime(); + this.updateTabTitleTimer(false, current.textContent); + } + } + + updateTabTitleTimer(deactivation, timeString) { + if (!TAB_TIMER_INDICATOR) return; + + const matchTimerString = /\[[0-9][0-9]:[0-9][0-9]:[0-9][0-9]\]/g; + + if (deactivation) { + document.title = document.title.replace(matchTimerString, ''); + } else { + if (RegExp(matchTimerString).test(document.title)) { + document.title = document.title.replace(matchTimerString, ''); + document.title = '[' + timeString + '] ' + document.title; + } else { + document.title = '[' + timeString + '] ' + document.title; + } + } + } + + render() { + const { isTimerActive } = this.props; + const time = this.getTime(); + if (!isTimerActive) return null; + + const { + isModerator, + timer, + sidebarNavigationIsOpen, + sidebarContentIsOpen, + currentTrack, + intl, + } = this.props; + const { stopwatch, running } = timer; + const trackChanged = this.music?.track !== currentTrack; + if (trackChanged) { + this.setUpMusic(); + } + + const onClick = running ? TimerService.stopTimer : TimerService.startTimer; + this.updateTabTitleTimer(false, time); + + const title = () => { + if (isModerator) { + if (stopwatch && !running) { + return intl.formatMessage(intlMessages.toolTipStopwatchStoppedMod); + } if (stopwatch && running) { + return intl.formatMessage(intlMessages.toolTipStopwatchRunningMod); + } if (!stopwatch) { + if (running) { + return intl.formatMessage(intlMessages.toolTipTimerRunningMod); + } if (!running) { + return intl.formatMessage(intlMessages.toolTipTimerStoppedMod); + } + } + } + if (stopwatch && !running) { + return intl.formatMessage(intlMessages.toolTipStopwatchStopped); + } if (stopwatch && running) { + return intl.formatMessage(intlMessages.toolTipStopwatchRunning); + } if (!stopwatch) { + if (running) { + return intl.formatMessage(intlMessages.toolTipTimerRunning); + } if (!running) { + return intl.formatMessage(intlMessages.toolTipTimerStopped); + } + } + }; + return ( + + + + + + + + + + {time} + + + + + + + ); + } +} + +Indicator.propTypes = propTypes; + +export default injectIntl(Indicator); diff --git a/src/2.7.12/imports/ui/components/timer/indicator/container.jsx b/src/2.7.12/imports/ui/components/timer/indicator/container.jsx new file mode 100644 index 00000000..4e8e2e35 --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/indicator/container.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Indicator from './component'; +import TimerService from '/imports/ui/components/timer/service'; +import { layoutSelectInput } from '/imports/ui/components/layout/context'; + +const IndicatorContainer = (props) => { + const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation); + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const sidebarNavigationIsOpen = sidebarNavigation.isOpen; + const sidebarContentIsOpen = sidebarContent.isOpen; + + return ( + + ); +}; + +export default withTracker(() => ({ + timer: TimerService.getTimer(), + timeOffset: TimerService.getTimeOffset(), + isModerator: TimerService.isModerator(), + isTimerActive: TimerService.isActive(), + isMusicActive: TimerService.isMusicActive(), + currentTrack: TimerService.getCurrentTrack(), +}))(IndicatorContainer); diff --git a/src/2.7.12/imports/ui/components/timer/indicator/styles.js b/src/2.7.12/imports/ui/components/timer/indicator/styles.js new file mode 100644 index 00000000..810f449c --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/indicator/styles.js @@ -0,0 +1,132 @@ +import styled from 'styled-components'; +import { phoneLandscape, smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; +import { borderRadius } from '/imports/ui/stylesheets/styled-components/general'; +import { + colorSuccess, + colorDanger, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeBase, fontSizeXS } from '/imports/ui/stylesheets/styled-components/typography'; + +const colorTimerRunning = `${colorSuccess}`; +const colorTimerStopped = `${colorDanger}`; +const timerMarginSM = '.5rem'; +const timerPaddingSM = '.25rem'; +const timerPaddingXL = '1.62rem'; +const timerMaxWidth = '10rem'; +const timerFontWeight = '400'; +const timerBorderRadius = '2rem'; + +const TimerWrapper = styled.div` + overflow: hidden; + margin-left: auto; +`; + +const Timer = styled.div` + margin-top: 0.5rem; + display: flex; + max-height: ${timerPaddingXL}); +`; + +const timerRunning = ` + background-color: ${colorTimerRunning}; + border: solid 2px ${colorTimerRunning}; +`; + +const timerStopped = ` + background-color: ${colorTimerStopped}; + border: solid 2px ${colorTimerStopped}; +`; + +const disabledStyle = ` + cursor: default; +`; + +const hiddenStyle = ` + @media ${smallOnly} { + visibility: hidden; + } +`; + +const TimerButton = styled.div` + @include highContrastOutline(); + cursor: pointer; + color: white; + font-weight: ${timerFontWeight}; + border-radius: ${timerBorderRadius} ${timerBorderRadius}; + font-size: ${fontSizeBase}; + margin-left: ${borderRadius}; + margin-right: ${borderRadius}; + + @media ${phoneLandscape} { + height: 1rem; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: ${timerMaxWidth}; + + @media ${phoneLandscape} { + font-size: ${fontSizeXS}; + } + } + + i { + font-size: var(--font-size-small); + width: 1rem; + height: 1rem; + border-radius: 50%; + + @media ${phoneLandscape} { + height: ${timerMarginSM}; + width: ${timerMarginSM}; + font-size: ${fontSizeXS}; + } + } + + ${({ running }) => (running ? timerRunning : timerStopped)}; + ${({ disabled }) => disabled && disabledStyle}; + ${({ hide }) => hide && hiddenStyle}; +`; + +const time = ` + box-sizing: border-box; + display: flex; + align-self: center; + padding: 0 ${timerPaddingSM} 0 0; +`; + +const TimerContent = styled.div` + ${time} + display: flex; + + [dir="ltr"] & { + span:first-child { + padding: 0 ${timerPaddingSM}; + } + } + + [dir="rtl"] & { + span:last-child { + padding: 0 ${timerPaddingSM}; + } + } +`; + +const TimerIcon = styled.span` + ${time} +`; + +const TimerTime = styled.span` + ${time} +`; + +export default { + TimerWrapper, + Timer, + TimerButton, + TimerContent, + TimerIcon, + TimerTime, +}; diff --git a/src/2.7.12/imports/ui/components/timer/service.js b/src/2.7.12/imports/ui/components/timer/service.js new file mode 100644 index 00000000..dcdce7d4 --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/service.js @@ -0,0 +1,348 @@ +import { Meteor } from 'meteor/meteor'; +import Timer from '/imports/api/timer'; +import Auth from '/imports/ui/services/auth'; +import { makeCall } from '/imports/ui/services/api'; +import { Session } from 'meteor/session'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/client/logger'; +import { ACTIONS, PANELS } from '../layout/enums'; + +const TIMER_CONFIG = Meteor.settings.public.timer; +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; +const OFFSET_INTERVAL = TIMER_CONFIG.interval.offset; + +const MILLI_IN_HOUR = 3600000; +const MILLI_IN_MINUTE = 60000; +const MILLI_IN_SECOND = 1000; + +const MAX_HOURS = 23; + +const TRACKS = [ + 'noTrack', + 'track1', + 'track2', + 'track3', +]; + +const isMusicEnabled = () => TIMER_CONFIG.music.enabled; + +const getCurrentTrack = () => { + const timer = Timer.findOne( + { meetingId: Auth.meetingID }, + { fields: { track: 1 } }, + ); + + if (timer) return isMusicEnabled() && timer.track; + + return false; +}; + +const isEnabled = () => TIMER_CONFIG.enabled; + +const getMaxHours = () => MAX_HOURS; + +const isAlarmEnabled = () => isEnabled() && TIMER_CONFIG.alarm; + +const isMusicActive = () => getCurrentTrack() !== TRACKS[0]; + +const getMusicVolume = () => TIMER_CONFIG.music.volume; + +const getMusicTrack = () => TIMER_CONFIG.music.track; + +const isActive = () => { + const timer = Timer.findOne( + { meetingId: Auth.meetingID }, + { fields: { active: 1 } }, + ); + + if (timer) return timer.active; + return false; +}; + +const getDefaultTime = () => TIMER_CONFIG.time * MILLI_IN_MINUTE; + +const getInterval = () => TIMER_CONFIG.interval.clock; + +const isRunning = () => { + const timer = Timer.findOne( + { meetingId: Auth.meetingID }, + { fields: { running: 1 } }, + ); + + if (timer) return timer.running; + return false; +}; + +const isStopwatch = () => { + const timer = Timer.findOne( + { meetingId: Auth.meetingID }, + { fields: { stopwatch: 1 } }, + ); + + if (timer) return timer.stopwatch; + return false; +}; + +const startTimer = () => makeCall('startTimer'); + +const stopTimer = () => makeCall('stopTimer'); + +const switchTimer = (stopwatch) => makeCall('switchTimer', stopwatch); + +const setTimer = (time) => makeCall('setTimer', time); + +const resetTimer = () => makeCall('resetTimer'); + +const activateTimer = (layoutContextDispatch) => { + makeCall('activateTimer'); + //Set an observer to switch to timer tab as soon as the timer is activated + const handle =Timer.find({ meetingId: Auth.meetingID }).observeChanges({ + changed(id, timer) { + if (timer.active === true) { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.TIMER, + }); + } + handle.stop(); + } + }); + }; + +const deactivateTimer = () => makeCall('deactivateTimer'); + +const timerEnded = () => makeCall('timerEnded'); + +const setTrack = (track) => { + makeCall('setTrack', track); +}; + +const fetchTimeOffset = () => { + const t0 = Date.now(); + + makeCall('getServerTime').then((result) => { + if (result === 0) return; + const t3 = Date.now(); + + const ts = result; + const rtt = t3 - t0; + const timeOffset = Math.round(ts - rtt / 2 - t0); + + Session.set('timeOffset', timeOffset); + }); +}; + +const getTimeOffset = () => { + const timeOffset = Session.get('timeOffset'); + + if (timeOffset) return timeOffset; + + return 0; +}; + +const getElapsedTime = (running, timestamp, timeOffset, accumulated) => { + if (!running) return accumulated; + + const now = Date.now(); + + return accumulated + Math.abs(now - timestamp + timeOffset); +}; + +const getStopwatch = () => { + const timer = Timer.findOne( + { meetingId: Auth.meetingID }, + { fields: { stopwatch: 1 } }, + ); + + if (timer) return timer.stopwatch; + + return true; +}; + +const getTimer = () => { + const timer = Timer.findOne( + { meetingId: Auth.meetingID }, + { + fields: + { + stopwatch: 1, + running: 1, + time: 1, + accumulated: 1, + timestamp: 1, + }, + }, + ); + + if (timer) { + const { + stopwatch, + running, + time, + accumulated, + timestamp, + } = timer; + + return { + stopwatch, + running, + time, + accumulated, + timestamp, + }; + } + + return { + stopwatch: true, + running: false, + time: getDefaultTime(), + accumulated: 0, + timestamp: 0, + }; +}; + +const getTimeAsString = (time) => { + const milliseconds = time; + + const hours = Math.floor(milliseconds / MILLI_IN_HOUR); + const mHours = hours * MILLI_IN_HOUR; + + const minutes = Math.floor((milliseconds - mHours) / MILLI_IN_MINUTE); + const mMinutes = minutes * MILLI_IN_MINUTE; + + const seconds = Math.floor((milliseconds - mHours - mMinutes) / MILLI_IN_SECOND); + + let timeAsString = ''; + + if (hours < 10) { + timeAsString += `0${hours}:`; + } else { + timeAsString += `${hours}:`; + } + + if (minutes < 10) { + timeAsString += `0${minutes}:`; + } else { + timeAsString += `${minutes}:`; + } + + if (seconds < 10) { + timeAsString += `0${seconds}`; + } else { + timeAsString += `${seconds}`; + } + + return timeAsString; +}; + +const closePanel = (layoutContextDispatch) => { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); +}; + +const togglePanel = (sidebarContentPanel, layoutContextDispatch) => { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: sidebarContentPanel !== PANELS.TIMER, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: sidebarContentPanel === PANELS.TIMER + ? PANELS.NONE + : PANELS.TIMER, + }); +}; + +const isModerator = () => Users.findOne( + { userId: Auth.userID }, + { fields: { role: 1 } }, +).role === ROLE_MODERATOR; + +const setHours = (hours, time) => { + if (!Number.isNaN(hours) && hours >= 0 && hours <= MAX_HOURS) { + const currentHours = Math.floor(time / MILLI_IN_HOUR); + + const diff = (hours - currentHours) * MILLI_IN_HOUR; + setTimer(time + diff); + } else { + Logger.warn('Invalid time'); + } +}; + +const setMinutes = (minutes, time) => { + if (!Number.isNaN(minutes) && minutes >= 0 && minutes <= 59) { + const currentHours = Math.floor(time / MILLI_IN_HOUR); + const mHours = currentHours * MILLI_IN_HOUR; + + const currentMinutes = Math.floor((time - mHours) / MILLI_IN_MINUTE); + + const diff = (minutes - currentMinutes) * MILLI_IN_MINUTE; + setTimer(time + diff); + } else { + Logger.warn('Invalid time'); + } +}; + +const setSeconds = (seconds, time) => { + if (!Number.isNaN(seconds) && seconds >= 0 && seconds <= 59) { + const currentHours = Math.floor(time / MILLI_IN_HOUR); + const mHours = currentHours * MILLI_IN_HOUR; + + const currentMinutes = Math.floor((time - mHours) / MILLI_IN_MINUTE); + const mMinutes = currentMinutes * MILLI_IN_MINUTE; + + const currentSeconds = Math.floor((time - mHours - mMinutes) / MILLI_IN_SECOND); + + const diff = (seconds - currentSeconds) * MILLI_IN_SECOND; + setTimer(time + diff); + } else { + Logger.warn('Invalid time'); + } +}; + +export default { + OFFSET_INTERVAL, + TRACKS, + isActive, + isEnabled, + isMusicEnabled, + isMusicActive, + getCurrentTrack, + getMusicVolume, + getMusicTrack, + isRunning, + isStopwatch, + isAlarmEnabled, + startTimer, + stopTimer, + switchTimer, + setHours, + setMinutes, + setSeconds, + resetTimer, + activateTimer, + deactivateTimer, + fetchTimeOffset, + setTrack, + getTimeOffset, + getElapsedTime, + getInterval, + getMaxHours, + getStopwatch, + getTimer, + getTimeAsString, + closePanel, + togglePanel, + isModerator, + timerEnded, +}; diff --git a/src/2.7.12/imports/ui/components/timer/styles.js b/src/2.7.12/imports/ui/components/timer/styles.js new file mode 100644 index 00000000..13776c37 --- /dev/null +++ b/src/2.7.12/imports/ui/components/timer/styles.js @@ -0,0 +1,266 @@ +import styled from 'styled-components'; +import { + borderSize, + borderSizeLarge, + mdPaddingX, + mdPaddingY, + pollHeaderOffset, + toastContentWidth, + borderRadius, +} from '../../stylesheets/styled-components/general'; +import { + colorGrayDark, + colorGrayLighter, + colorGrayLightest, + colorGray, + colorBlueLight, + colorWhite, + colorPrimary, +} from '../../stylesheets/styled-components/palette'; +import { TextElipsis } from '../../stylesheets/styled-components/placeholders'; +import Button from '/imports/ui/components/common/button/component'; + +const TimerSidebarContent = styled.div` + background-color: ${colorWhite}; + padding: + ${mdPaddingX} + ${mdPaddingY} + ${mdPaddingX} + ${mdPaddingX}; + + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: space-around; + overflow: hidden; + height: 100%; + transform: translateZ(0); +`; + +const TimerHeader = styled.header` + position: relative; + top: ${pollHeaderOffset}; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +`; + +const TimerTitle = styled.div` + ${TextElipsis}; + flex: 1; + + & > button, button:hover { + max-width: ${toastContentWidth}; + } +`; + +const TimerMinimizeButton = styled(Button)` + position: relative; + background-color: ${colorWhite}; + display: block; + margin: ${borderSizeLarge}; + margin-bottom: ${borderSize}; + padding-left: 0; + padding-right: inherit; + + [dir="rtl"] & { + padding-left: inherit; + padding-right: 0; + } + + > i { + color: ${colorGrayDark}; + font-size: smaller; + + [dir="rtl"] & { + -webkit-transform: scale(-1, 1); + -moz-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + transform: scale(-1, 1); + } + } + + &:hover { + background-color: ${colorWhite}; + } +`; + +const TimerContent = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: auto; +`; + +const TimerCurrent = styled.span` + border-bottom: 1px solid ${colorGrayLightest}; + border-top: 1px solid ${colorGrayLightest}; + display: flex; + font-size: xxx-large; + justify-content: center; +`; + +const TimerType = styled.div` + display: flex; + width: 100%; + justify-content: center; + padding-top: 2rem; +`; + +const TimerSwitchButton = styled(Button)` + width: 100%; + height: 2rem; + margin: 0 .5rem; +`; + +const StopwatchTime = styled.div` + display: flex; + margin-top: 4rem; + width: 100%; + height: 3rem; + font-size: x-large; + justify-content: center; + + input { + width: 5rem; + } +`; + +const StopwatchTimeInput = styled.div` + display: flex; + flex-direction: column; + + .label { + display: flex; + font-size: small; + justify-content: center; + } +`; + +const StopwatchTimeInputLabel = styled.div` + display: flex; + font-size: small; + justify-content: center; +`; + +const StopwatchTimeColon = styled.span` + align-self: center; + padding: 0 .25rem; +`; + +const TimerSongsWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-flow: column; + margin-top: 4rem; + margin-bottom: -2rem; +`; + +const TimerRow = ` + display: flex; + flex-flow: row; + flex-grow: 1; +`; + +const TimerCol = ` + display: flex; + flex-flow: column; + flex-grow: 1; + flex-basis: 0; +`; +const TimerSongsTitle = styled.div` + ${TimerRow} + display: flex; + font-weight: bold; + font-size: 1.1rem; + opacity: ${({ stopwatch }) => (stopwatch ? '50%' : '100%')} +`; + +const TimerTracks = styled.div` + ${TimerCol} + display: flex; + margin-top: 0.8rem; + margin-bottom: 2rem; + + .row { + margin: 0.5rem auto; + } + + label { + display: flex; + } + + input { + margin: auto 0.5rem; + } +`; + +const TimerTrackItem = styled.div` + ${TimerRow} +`; + +const TimerControls = styled.div` + display: flex; + width: 100%; + justify-content: center; + margin-top: 4rem; +`; + +const TimerControlButton = styled(Button)` + width: 6rem; + margin: 0 1rem; +`; + +const TimerInput = styled.input` + flex: 1; + border: 1px solid ${colorGrayLighter}; + width: 50%; + text-align: center; + padding: .25rem; + border-radius: ${borderRadius}; + background-clip: padding-box; + outline: none; + + &::placeholder { + color: ${colorGray}; + opacity: 1; + } + + &:focus { + border-radius: ${borderSize}; + box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + } + + &:disabled, + &[disabled] { + cursor: not-allowed; + opacity: .75; + background-color: rgba(167,179,189,0.25); + } +`; + +export default { + TimerSidebarContent, + TimerHeader, + TimerTitle, + TimerMinimizeButton, + TimerContent, + TimerCurrent, + TimerType, + TimerSwitchButton, + StopwatchTime, + StopwatchTimeInput, + StopwatchTimeInputLabel, + StopwatchTimeColon, + TimerSongsWrapper, + TimerSongsTitle, + TimerTracks, + TimerTrackItem, + TimerControls, + TimerControlButton, + TimerInput, +}; diff --git a/src/2.7.12/imports/ui/components/user-avatar/component.jsx b/src/2.7.12/imports/ui/components/user-avatar/component.jsx new file mode 100644 index 00000000..8b3f223b --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-avatar/component.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Settings from '/imports/ui/services/settings'; +import Styled from './styles'; + +const propTypes = { + children: PropTypes.node, + moderator: PropTypes.bool, + presenter: PropTypes.bool, + talking: PropTypes.bool, + muted: PropTypes.bool, + listenOnly: PropTypes.bool, + voice: PropTypes.bool, + noVoice: PropTypes.bool, + color: PropTypes.string, + emoji: PropTypes.bool, + avatar: PropTypes.string, + className: PropTypes.string, + isSkeleton: PropTypes.bool, +}; + +const defaultProps = { + children: <>, + moderator: false, + presenter: false, + talking: false, + muted: false, + listenOnly: false, + voice: false, + noVoice: false, + color: '#000', + emoji: false, + avatar: '', + className: '', + isSkeleton: false, +}; + +const { animations } = Settings.application; + +const UserAvatar = ({ + children, + moderator, + presenter, + className, + talking, + muted, + listenOnly, + color, + voice, + emoji, + avatar, + noVoice, + whiteboardAccess, + isSkeleton, +}) => ( + <> + {isSkeleton && ({children})} + + {!isSkeleton && ( + + )} + +); + +UserAvatar.propTypes = propTypes; +UserAvatar.defaultProps = defaultProps; + +export default UserAvatar; diff --git a/src/2.7.12/imports/ui/components/user-avatar/styles.js b/src/2.7.12/imports/ui/components/user-avatar/styles.js new file mode 100644 index 00000000..e47ecd30 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-avatar/styles.js @@ -0,0 +1,279 @@ +import styled, { css, keyframes } from 'styled-components'; +import { + userIndicatorsOffset, + mdPaddingY, + indicatorPadding, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorPrimary, + colorWhite, + userListBg, + colorSuccess, + colorDanger, + colorOffWhite, +} from '/imports/ui/stylesheets/styled-components/palette'; + +const Content = styled.div` + color: ${colorWhite}; + top: 50%; + position: absolute; + text-align: center; + left: 0; + right: 0; + font-size: 110%; + text-transform: capitalize; + + &, + & > * { + line-height: 0; // to keep centralized vertically + } +`; + +const Image = styled.div` + display: flex; + height: 100%; + width: 100%; + justify-content: center; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +`; + +const Img = styled.img` + object-fit: cover; + overflow: hidden; + + ${({ moderator }) => moderator && ` + border-radius: 3px; + `} + + ${({ moderator }) => !moderator && ` + border-radius: 50%; + `} +`; + +const pulse = keyframes` + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1.5); + } +`; + +const Talking = styled.div` + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border-radius: inherit; + + ${({ talking }) => talking && css` + background-color: currentColor; + `} + + ${({ talking, animations }) => talking && animations && css` + animation: ${pulse} 1s infinite ease-in; + `} + + &::before { + ${({ talking, animations }) => talking && !animations && ` + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: currentColor; + border-radius: inherit; + box-shadow: 0 0 0 4px currentColor; + opacity: .5; + `} + } +`; + +const Avatar = styled.div` + position: relative; + height: 2.25rem; + width: 2.25rem; + border-radius: 50%; + text-align: center; + font-size: .85rem; + border: 2px solid transparent; + user-select: none; + + ${({ animations }) => animations && ` + transition: .3s ease-in-out; + `} + + &:after, + &:before { + content: ""; + position: absolute; + width: 0; + height: 0; + color: inherit; + top: auto; + left: auto; + bottom: ${userIndicatorsOffset}; + right: ${userIndicatorsOffset}; + border: 1.5px solid ${userListBg}; + border-radius: 50%; + background-color: ${colorSuccess}; + color: ${colorWhite}; + opacity: 0; + font-family: 'bbb-icons'; + font-size: .65rem; + line-height: 0; + text-align: center; + z-index: 1; + + [dir="rtl"] & { + left: ${userIndicatorsOffset}; + right: auto; + padding-left: 0; + } + + ${({ animations }) => animations && ` + transition: .3s ease-in-out; + `} + } + + ${({ moderator }) => moderator && ` + border-radius: 5px; + `} + + ${({ presenter }) => presenter && ` + &:before { + content: "\\00a0\\e90b\\00a0"; + opacity: 1; + top: ${userIndicatorsOffset}; + left: ${userIndicatorsOffset}; + bottom: auto; + right: auto; + border-radius: 5px; + background-color: ${colorPrimary}; + height: 1.2rem; + width: 1.2rem; + display: flex; + justify-content: center; + align-items: center; + + [dir="rtl"] & { + left: auto; + right: ${userIndicatorsOffset}; + } + } + `} + + ${({ whiteboardAccess }) => whiteboardAccess && ` + &:before { + content: "\\00a0\\e925\\00a0"; + border-radius: 50% !important; + opacity: 1; + top: ${userIndicatorsOffset}; + left: ${userIndicatorsOffset}; + bottom: auto; + right: auto; + border-radius: 5px; + background-color: ${colorPrimary}; + height: 1.2rem; + width: 1.2rem; + display: flex; + justify-content: center; + align-items: center; + + [dir="rtl"] & { + left: auto; + right: ${userIndicatorsOffset}; + transform: scale(-1, 1); + } + } + `} + + ${({ voice }) => voice && ` + &:after { + content: "\\00a0\\e931\\00a0"; + background-color: ${colorSuccess}; + top: 1.375rem; + left: 1.375rem; + right: auto; + + [dir="rtl"] & { + left: auto; + right: 1.375rem; + } + opacity: 1; + width: 1.2rem; + height: 1.2rem; + display: flex; + justify-content: center; + align-items: center; + } + `} + + ${({ muted }) => muted && ` + &:after { + content: "\\00a0\\e932\\00a0"; + background-color: ${colorDanger}; + opacity: 1; + width: 1.2rem; + height: 1.2rem; + display: flex; + justify-content: center; + align-items: center; + } + `} + + ${({ listenOnly }) => listenOnly && ` + &:after { + content: "\\00a0\\e90c\\00a0"; + opacity: 1; + width: 1.2rem; + height: 1.2rem; + display: flex; + justify-content: center; + align-items: center; + } + `} + + ${({ noVoice }) => noVoice && ` + &:after { + content: ""; + background-color: ${colorOffWhite}; + top: 1.375rem; + left: 1.375rem; + right: auto; + + [dir="rtl"] & { + left: auto; + right: 1.375rem; + } + + opacity: 1; + width: 1.2rem; + height: 1.2rem; + } + `} +`; + +const Skeleton = styled.div` + & .react-loading-skeleton { + height: 2.25rem; + width: 2.25rem; + } +`; + +export default { + Content, + Image, + Img, + Talking, + Avatar, + Skeleton, +}; diff --git a/src/2.7.12/imports/ui/components/user-info/component.jsx b/src/2.7.12/imports/ui/components/user-info/component.jsx new file mode 100644 index 00000000..d4f9c1ed --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-info/component.jsx @@ -0,0 +1,70 @@ +import React, { Component } from 'react'; +import { defineMessages } from 'react-intl'; +import PropTypes from 'prop-types'; + +import ModalSimple from '/imports/ui/components/common/modal/simple/component'; + +import Service from './service'; + +import Styled from './styles'; + +const propTypes = { + intl: PropTypes.object.isRequired, + meetingId: PropTypes.string.isRequired, + requesterUserId: PropTypes.string.isRequired, +}; + +const intlMessages = defineMessages({ + title: { + id: 'app.user-info.title', + description: 'User info title label', + }, +}); + +class UserInfoComponent extends Component { + renderUserInfo() { + const { UserInfo } = this.props; + const userInfoList = UserInfo.map((user, index, array) => { + const infoList = user.userInfo.map((info) => { + const key = Object.keys(info)[0]; + return ( + + {key} + {info[key]} + + ); + }); + if (array.length > 1) { + infoList.unshift( + + {`User ${index + 1}`} + , + ); + } + return infoList; + }); + return ( + + + {userInfoList} + + + ); + } + + render() { + const { intl } = this.props; + return ( + Service.handleCloseUserInfo()} + > + {this.renderUserInfo()} + + ); + } +} + +UserInfoComponent.propTypes = propTypes; + +export default UserInfoComponent; diff --git a/src/2.7.12/imports/ui/components/user-info/container.jsx b/src/2.7.12/imports/ui/components/user-info/container.jsx new file mode 100644 index 00000000..2d9c7509 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-info/container.jsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { injectIntl } from 'react-intl'; +import UserInfo from './component'; + +const UserInfoContainer = props => ; + +export default injectIntl(UserInfoContainer); diff --git a/src/2.7.12/imports/ui/components/user-info/service.js b/src/2.7.12/imports/ui/components/user-info/service.js new file mode 100644 index 00000000..f561bb9d --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-info/service.js @@ -0,0 +1,7 @@ +import { makeCall } from '/imports/ui/services/api'; + +export default { + handleCloseUserInfo: () => { + makeCall('removeUserInformation'); + }, +}; diff --git a/src/2.7.12/imports/ui/components/user-info/styles.js b/src/2.7.12/imports/ui/components/user-info/styles.js new file mode 100644 index 00000000..3ead0b64 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-info/styles.js @@ -0,0 +1,33 @@ +import styled from 'styled-components'; +import { mdPaddingX, borderSize } from '/imports/ui/stylesheets/styled-components/general'; +import { colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette'; + +const KeyCell = styled.td` + padding: ${mdPaddingX}; + border: ${borderSize} solid ${colorGrayLighter}; +`; + +const ValueCell = styled.td` + padding: ${mdPaddingX}; + border: ${borderSize} solid ${colorGrayLighter}; +`; + +const UserInfoTable = styled.table` + border: ${borderSize} solid ${colorGrayLighter}; + border-collapse: collapse; + border: none; + + width: 90%; + margin: auto; + + table-layout: fixed; + + & > td { + word-wrap: break-word; + }`; + +export default { + KeyCell, + ValueCell, + UserInfoTable, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx b/src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx new file mode 100644 index 00000000..7cef3fb3 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from '/imports/ui/components/common/icon/component'; +import CaptionsService from '/imports/ui/components/captions/service'; +import { defineMessages, injectIntl } from 'react-intl'; +import Styled from './styles'; +import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums'; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + locale: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + tabIndex: PropTypes.number.isRequired, +}; + +const intlMessages = defineMessages({ + captionLabel: { + id: 'app.captions.label', + description: 'used for captions button aria label', + }, + captionTitle: { + id: 'app.captions.title', + description: 'title for the transcription pad button on the sidebar', + }, +}); + +const CaptionsListItem = (props) => { + const { + intl, + locale, + name, + tabIndex, + sidebarContentPanel, + layoutContextDispatch, + } = props; + + const handleClickToggleCaptions = () => { + if (sidebarContentPanel !== PANELS.CAPTIONS) { + CaptionsService.setCaptionsLocale(locale); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.CAPTIONS, + }); + } else { + const captionsLocale = CaptionsService.getCaptionsLocale(); + if (captionsLocale !== locale) { + CaptionsService.setCaptionsLocale(locale); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); + } + } + }; + + return ( + {}} + > + + {intl.formatMessage(intlMessages.captionTitle)} + + ); +}; + +CaptionsListItem.propTypes = propTypes; + +export default injectIntl(CaptionsListItem); diff --git a/src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js b/src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js new file mode 100644 index 00000000..d9cf463a --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; + +const ListItem = styled(StyledContent.ListItem)``; + +export default { + ListItem, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx b/src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx new file mode 100644 index 00000000..85f7e7b3 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { debounce } from '/imports/utils/debounce'; +import withShortcutHelper from '/imports/ui/components/shortcut-help/service'; +import Styled from './styles'; +import UserAvatar from '/imports/ui/components/user-avatar/component'; +import { ACTIONS, PANELS } from '../../layout/enums'; +import Icon from '/imports/ui/components/common/icon/component'; + +const DEBOUNCE_TIME = 1000; +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id; + +let globalAppplyStateToProps = () => {}; + +const throttledFunc = debounce(() => { + globalAppplyStateToProps(); +}, DEBOUNCE_TIME, { trailing: true, leading: true }); + +const intlMessages = defineMessages({ + titlePublic: { + id: 'app.chat.titlePublic', + description: 'title for public chat', + }, + unreadPlural: { + id: 'app.userList.chatListItem.unreadPlural', + description: 'singular aria label for new message', + }, + unreadSingular: { + id: 'app.userList.chatListItem.unreadSingular', + description: 'plural aria label for new messages', + }, +}); + +const propTypes = { + chat: PropTypes.shape({ + userId: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + unreadCounter: PropTypes.number.isRequired, + }).isRequired, + idChatOpen: PropTypes.string.isRequired, + compact: PropTypes.bool.isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + tabIndex: PropTypes.number, + isPublicChat: PropTypes.func.isRequired, + shortcuts: PropTypes.string, +}; + +const defaultProps = { + shortcuts: '', + tabIndex: -1, +}; + +const ChatListItem = (props) => { + const { + chat, + activeChatId, + idChatOpen, + compact, + intl, + tabIndex, + isPublicChat, + shortcuts: TOGGLE_CHAT_PUB_AK, + sidebarContentIsOpen, + sidebarContentPanel, + layoutContextDispatch, + } = props; + + const chatPanelOpen = sidebarContentIsOpen && sidebarContentPanel === PANELS.CHAT; + + const isCurrentChat = chat.chatId === activeChatId && chatPanelOpen; + + const [stateUreadCount, setStateUreadCount] = useState(0); + + if (chat.unreadCounter !== stateUreadCount && (stateUreadCount < chat.unreadCounter)) { + globalAppplyStateToProps = () => { + setStateUreadCount(chat.unreadCounter); + }; + throttledFunc(); + } else if (chat.unreadCounter !== stateUreadCount && (stateUreadCount > chat.unreadCounter)) { + setStateUreadCount(chat.unreadCounter); + } + + useEffect(() => { + if (chat.userId !== PUBLIC_CHAT_KEY && chat.userId === idChatOpen) { + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: chat.chatId, + }); + } + }, [idChatOpen, sidebarContentIsOpen, sidebarContentPanel, chat]); + + const handleClickToggleChat = () => { + // Verify if chat panel is open + + if (sidebarContentIsOpen && sidebarContentPanel === PANELS.CHAT) { + if (idChatOpen === chat.chatId) { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: false, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.NONE, + }); + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: '', + }); + } else { + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: chat.chatId, + }); + } + } else { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.CHAT, + }); + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: chat.chatId, + }); + } + }; + + const localizedChatName = isPublicChat(chat) + ? intl.formatMessage(intlMessages.titlePublic) + : chat.name; + + const arialabel = `${localizedChatName} ${ + stateUreadCount > 1 + ? intl.formatMessage(intlMessages.unreadPlural, { 0: stateUreadCount }) + : intl.formatMessage(intlMessages.unreadSingular)}`; + + return ( + { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + } + }} + > + + + {chat.icon + ? ( + + + + ) : ( + + {chat.name.toLowerCase().slice(0, 2)} + + )} + + + {!compact + ? ( + + {isPublicChat(chat) + ? intl.formatMessage(intlMessages.titlePublic) : chat.name} + + ) : null} + + {(stateUreadCount > 0) + ? ( + + + + ) + : null} + + + ); +}; + +ChatListItem.propTypes = propTypes; +ChatListItem.defaultProps = defaultProps; + +export default withShortcutHelper(injectIntl(ChatListItem), 'togglePublicChat'); diff --git a/src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx b/src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx new file mode 100644 index 00000000..680164be --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import ChatListItem from './component'; +import { layoutSelect, layoutSelectInput, layoutDispatch } from '../../layout/context'; +import Service from '/imports/ui/components/user-list/service'; + +const ChatListItemContainer = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const idChatOpen = layoutSelect((i) => i.idChatOpen); + const layoutContextDispatch = layoutDispatch(); + + const { sidebarContentPanel } = sidebarContent; + const sidebarContentIsOpen = sidebarContent.isOpen; + + const { isPublicChat } = Service; + + return ( + + ); +}; + +export default ChatListItemContainer; diff --git a/src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js b/src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js new file mode 100644 index 00000000..aebc0b34 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js @@ -0,0 +1,108 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import ContentStyled from '/imports/ui/components/user-list/user-list-content/styles'; +import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography'; +import { + lgPaddingY, + smPaddingY, + borderSize, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorGrayDark, + colorOffWhite, + listItemBgHover, + colorGrayLight, +} from '/imports/ui/stylesheets/styled-components/palette'; + +const ChatListItemLink = styled.div` + display: flex; + flex-grow: 1; + align-items: center; + text-decoration: none; + width: 100%; +`; + +const ChatIcon = styled.div` + flex: 0 0 2.2rem; +`; + +const ChatName = styled.div` + display: flex; + align-items: center; + flex-grow: 1; + justify-content: center; + width: 50%; + padding-right: ${smPaddingY}; +`; + +const ChatNameMain = styled.span` + margin: 0; + min-width: 0; + display: inline-block; + white-space: nowrap; + overflow: hidden; + font-weight: 400; + font-size: ${fontSizeSmall}; + color: ${colorGrayDark}; + flex-grow: 1; + line-height: 2; + text-align: left; + padding: 0 0 0 ${lgPaddingY}; + text-overflow: ellipsis; + + [dir="rtl"] & { + text-align: right; + padding: 0 ${lgPaddingY} 0 0; + } + + ${({ active }) => active && ` + background-color: ${listItemBgHover}; + `} +`; + +const ChatListItem = styled(Styled.ListItem)` + cursor: pointer; + text-decoration: none; + flex-grow: 1; + line-height: 2; + color: ${colorGrayDark}; + background-color: ${colorOffWhite}; + padding-top: ${lgPaddingY}; + padding-bottom: ${lgPaddingY}; + padding-left: ${lgPaddingY}; + padding-right: 0; + margin-left: ${borderSize}; + margin-top: ${borderSize}; + margin-bottom: ${borderSize}; + margin-right: 0; + + [dir="rtl"] & { + padding-left: 0; + padding-right: ${lgPaddingY}; + margin-left: 0; + margin-right: ${borderSize}; + } +`; + +const ChatThumbnail = styled.div` + display: flex; + flex-flow: column; + color: ${colorGrayLight}; + justify-content: center; + font-size: 175%; +`; + +const UnreadMessages = styled(ContentStyled.UnreadMessages)``; +const UnreadMessagesText = styled(ContentStyled.UnreadMessagesText)``; + +export default { + ChatListItemLink, + ChatIcon, + ChatName, + ChatNameMain, + ChatListItem, + ChatThumbnail, + UnreadMessages, + UnreadMessagesText, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/component.jsx b/src/2.7.12/imports/ui/components/user-list/component.jsx new file mode 100644 index 00000000..caf0204d --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/component.jsx @@ -0,0 +1,43 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; +import Styled from './styles'; +import CustomLogo from './custom-logo/component'; +import UserContentContainer from './user-list-content/container'; + +const propTypes = { + compact: PropTypes.bool, + CustomLogoUrl: PropTypes.string.isRequired, + showBranding: PropTypes.bool.isRequired, +}; + +const defaultProps = { + compact: false, +}; + +class UserList extends PureComponent { + render() { + const { + compact, + CustomLogoUrl, + showBranding, + } = this.props; + + return ( + + { + showBranding + && !compact + && CustomLogoUrl + ? : null + } + + + ); + } +} + +UserList.propTypes = propTypes; +UserList.defaultProps = defaultProps; + +export default injectWbResizeEvent(UserList); diff --git a/src/2.7.12/imports/ui/components/user-list/container.jsx b/src/2.7.12/imports/ui/components/user-list/container.jsx new file mode 100644 index 00000000..baacd19f --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/container.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import Service from '/imports/ui/components/user-list/service'; +import UserList from './component'; + +const UserListContainer = (props) => ; + +export default withTracker(({ compact }) => ( + { + CustomLogoUrl: Service.getCustomLogoUrl(), + showBranding: getFromUserSettings('bbb_display_branding_area', Meteor.settings.public.app.branding.displayBrandingArea), + } +))(UserListContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx b/src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx new file mode 100644 index 00000000..44df7ab1 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Styled from './styles'; + +const CustomLogo = props => ( +
+ + custom branding logo + + +
+); + +export default CustomLogo; diff --git a/src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js b/src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js new file mode 100644 index 00000000..04036cfc --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js @@ -0,0 +1,28 @@ +import styled from 'styled-components'; + +import { colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette'; +import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography'; +import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general'; + +const Separator = styled.div` + height: 1px; + background-color: ${colorGrayLighter}; + margin-bottom: calc(${lineHeightComputed} * .5); +`; + +const Branding = styled.div` + padding: ${smPaddingX}; + width: 100%; + & > img { + max-height: 3rem; + max-width: 100%; + display: block; + margin-left: auto; + margin-right: auto; + } +`; + +export default { + Separator, + Branding, +} diff --git a/src/2.7.12/imports/ui/components/user-list/service.js b/src/2.7.12/imports/ui/components/user-list/service.js new file mode 100644 index 00000000..1f3bdd0e --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/service.js @@ -0,0 +1,842 @@ +import React from 'react'; +import Users from '/imports/api/users'; +import VoiceUsers from '/imports/api/voice-users'; +import GroupChat from '/imports/api/group-chat'; +import Breakouts from '/imports/api/breakouts'; +import Meetings from '/imports/api/meetings'; +import UserReaction from '/imports/api/user-reaction'; +import Auth from '/imports/ui/services/auth'; +import Storage from '/imports/ui/services/storage/session'; +import { EMOJI_STATUSES } from '/imports/utils/statuses'; +import { makeCall } from '/imports/ui/services/api'; +import KEY_CODES from '/imports/utils/keyCodes'; +import AudioService from '/imports/ui/components/audio/service'; +import VideoService from '/imports/ui/components/video-provider/service'; +import UserReactionService from '/imports/ui/components/user-reaction/service'; +import logger from '/imports/startup/client/logger'; +import WhiteboardService from '/imports/ui/components/whiteboard/service'; +import { Session } from 'meteor/session'; +import Settings from '/imports/ui/services/settings'; +import { notify } from '/imports/ui/services/notification'; +import { FormattedMessage } from 'react-intl'; +import { getDateString } from '/imports/utils/string-utils'; +import { indexOf } from '/imports/utils/array-utils'; +import { isEmpty, throttle } from 'radash'; +import ChatService from '/imports/ui/components/chat/service'; + +const CHAT_CONFIG = Meteor.settings.public.chat; +const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; +const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; +const ROLE_VIEWER = Meteor.settings.public.user.role_viewer; +const USER_STATUS_ENABLED = Meteor.settings.public.userStatus.enabled; + +const DIAL_IN_CLIENT_TYPE = 'dial-in-user'; + +// session for closed chat list +const CLOSED_CHAT_LIST_KEY = 'closedChatList'; +// session for chats the current user started +const STARTED_CHAT_LIST_KEY = 'startedChatList'; + +const CUSTOM_LOGO_URL_KEY = 'CustomLogoUrl'; + +export const setCustomLogoUrl = (path) => Storage.setItem(CUSTOM_LOGO_URL_KEY, path); + +export const setModeratorOnlyMessage = (msg) => Storage.setItem('ModeratorOnlyMessage', msg); + +const getCustomLogoUrl = () => Storage.getItem(CUSTOM_LOGO_URL_KEY); + +const sortByWhiteboardAccess = (a, b) => { + const _a = a.whiteboardAccess; + const _b = b.whiteboardAccess; + if (!_b && _a) return -1; + if (!_a && _b) return 1; + return 0; +}; + +const sortUsersByUserId = (a, b) => { + if (a.userId > b.userId) { + return -1; + } if (a.userId < b.userId) { + return 1; + } + + return 0; +}; + +const sortUsersByName = (a, b) => { + const aName = a.sortName || ''; + const bName = b.sortName || ''; + + // Extending for sorting strings with non-ASCII characters + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#sorting_non-ascii_characters + return aName.localeCompare(bName); +}; + +const sortByPropTime = (propName, propTimeName, nullValue, a, b) => { + const aObjTime = a[propName] && a[propName] !== nullValue && a[propTimeName] + ? a[propTimeName] : Number.MAX_SAFE_INTEGER; + + const bObjTime = b[propName] && b[propName] !== nullValue && b[propTimeName] + ? b[propTimeName] : Number.MAX_SAFE_INTEGER; + + if (aObjTime < bObjTime) { + return -1; + } if (aObjTime > bObjTime) { + return 1; + } + return 0; +}; + +const sortUsersByEmoji = (a, b) => sortByPropTime('emoji', 'emojiTime', 'none', a, b); +const sortUsersByAway = (a, b) => sortByPropTime('away', 'awayTime', false, a, b); +const sortUsersByRaiseHand = (a, b) => sortByPropTime('raiseHand', 'raiseHandTime', false, a, b); +const sortUsersByReaction = (a, b) => sortByPropTime('reaction', 'reactionTime', 'none', a, b); + +const sortUsersByModerator = (a, b) => { + if (a.role === ROLE_MODERATOR && b.role === ROLE_MODERATOR) { + return 0; + } if (a.role === ROLE_MODERATOR) { + return -1; + } if (b.role === ROLE_MODERATOR) { + return 1; + } + + return 0; +}; + +const sortUsersByPhoneUser = (a, b) => { + if (!a.clientType === DIAL_IN_CLIENT_TYPE && !b.clientType === DIAL_IN_CLIENT_TYPE) { + return 0; + } if (!a.clientType === DIAL_IN_CLIENT_TYPE) { + return -1; + } if (!b.clientType === DIAL_IN_CLIENT_TYPE) { + return 1; + } + + return 0; +}; + +// current user's name is always on top +const sortUsersByCurrent = (a, b) => { + if (a.userId === Auth.userID) { + return -1; + } if (b.userId === Auth.userID) { + return 1; + } + + return 0; +}; + +const sortUsers = (a, b) => { + let sort = sortUsersByCurrent(a, b); + if (sort === 0) sort = sortUsersByModerator(a, b); + if (sort === 0) sort = sortUsersByRaiseHand(a, b); + if (sort === 0) sort = sortUsersByAway(a, b); + if (sort === 0) sort = sortUsersByReaction(a, b); + if (sort === 0) sort = sortUsersByEmoji(a, b); + if (sort === 0) sort = sortUsersByPhoneUser(a, b); + if (sort === 0) sort = sortByWhiteboardAccess(a, b); + if (sort === 0) sort = sortUsersByName(a, b); + if (sort === 0) sort = sortUsersByUserId(a, b); + + return sort; +}; + +const isPublicChat = (chat) => ( + chat.userId === PUBLIC_CHAT_ID +); + +const userFindSorting = { + emojiTime: 1, + role: 1, + phoneUser: 1, + name: 1, + userId: 1, +}; + +const addWhiteboardAccess = (users) => { + const whiteboardId = WhiteboardService.getCurrentWhiteboardId(); + + if (whiteboardId) { + const multiUserWhiteboard = WhiteboardService.getMultiUser(whiteboardId); + return users.map((user) => { + const whiteboardAccess = multiUserWhiteboard.includes(user.userId); + + return { + ...user, + whiteboardAccess, + }; + }); + } + + return users.map((user) => { + const whiteboardAccess = false; + return { + ...user, + whiteboardAccess, + }; + }); +}; + +const addIsSharingWebcam = (users) => { + const usersId = VideoService.getUsersIdFromVideoStreams(); + + return users.map((user) => { + const isSharingWebcam = usersId.includes(user.userId); + + return { + ...user, + isSharingWebcam, + }; + }); +}; + +const addUserReaction = (users) => { + const usersReactions = UserReaction.find({ + meetingId: Auth.meetingID, + }).fetch(); + + return users.map((user) => { + let reaction = ''; + const obj = usersReactions.find((us) => us.userId === user.userId); + if (obj !== undefined) { + ({ reaction } = obj); + } + + return { + ...user, + reaction, + }; + }); +}; + +// TODO I think this method is no longer used, verify +const getUsers = () => { + let users = Users + .find({ + meetingId: Auth.meetingID, + }, userFindSorting) + .fetch(); + + const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { role: 1, locked: 1 } }); + if (currentUser && currentUser.role === ROLE_VIEWER && currentUser.locked) { + const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'lockSettingsProps.hideUserList': 1 } }); + if (meeting && meeting.lockSettingsProps && meeting.lockSettingsProps.hideUserList) { + const moderatorOrCurrentUser = (u) => u.role === ROLE_MODERATOR || u.userId === Auth.userID; + users = users.filter(moderatorOrCurrentUser); + } + } + + return addIsSharingWebcam(addUserReaction(addWhiteboardAccess(users))).sort(sortUsers); +}; + +const formatUsers = (contextUsers, videoUsers, whiteboardUsers, reactionUsers) => { + let users = contextUsers.filter((user) => user.loggedOut === false && user.left === false); + + const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { role: 1, locked: 1 } }); + if (currentUser && currentUser.role === ROLE_VIEWER && currentUser.locked) { + const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'lockSettingsProps.hideUserList': 1 } }); + if (meeting && meeting.lockSettingsProps && meeting.lockSettingsProps.hideUserList) { + const moderatorOrCurrentUser = (u) => u.role === ROLE_MODERATOR || u.userId === Auth.userID; + users = users.filter(moderatorOrCurrentUser); + } + } + + return users.map((user) => { + const isSharingWebcam = videoUsers?.includes(user.userId); + const whiteboardAccess = whiteboardUsers?.includes(user.userId); + const reaction = reactionUsers?.includes(user.userId) + ? UserReactionService.getUserReaction(user.userId) + : { reaction: 'none', reactionTime: 0 }; + + return { + ...user, + isSharingWebcam, + whiteboardAccess, + ...reaction, + }; + }).sort(sortUsers); +}; + +const getUserCount = () => Users.find({ meetingId: Auth.meetingID }).count(); + +const hasBreakoutRoom = () => Breakouts.find({ parentMeetingId: Auth.meetingID }, + { fields: {} }).count() > 0; + +const isMe = (userId) => userId === Auth.userID; + +const getActiveChats = ({ groupChatsMessages, groupChats, users }) => { + if (isEmpty(groupChats) && isEmpty(users)) return []; + + const chatIds = Object.keys(groupChats); + const lastTimeWindows = chatIds.reduce((acc, chatId) => { + const chat = groupChatsMessages[chatId]; + const lastTimewindowKey = chat?.lastTimewindow; + const lastTimeWindow = lastTimewindowKey?.split('-')[1]; + return { + ...acc, + chatId: lastTimeWindow, + }; + }, {}); + + chatIds.sort((a, b) => { + if (a === PUBLIC_GROUP_CHAT_ID) { + return -1; + } + + if (lastTimeWindows[a] === lastTimeWindows[b]) { + return 0; + } + + return 1; + }); + + const chatInfo = chatIds.map((chatId) => { + const contextChat = groupChatsMessages[chatId]; + const isPublicChatId = chatId === PUBLIC_GROUP_CHAT_ID; + let unreadMessagesCount = 0; + if (contextChat) { + const unreadTimewindows = contextChat.unreadTimeWindows; + // eslint-disable-next-line + for (const unreadTimeWindowId of unreadTimewindows) { + const timeWindow = (isPublicChatId + ? contextChat?.preJoinMessages[unreadTimeWindowId] + || contextChat?.posJoinMessages[unreadTimeWindowId] + : contextChat?.messageGroups[unreadTimeWindowId]); + unreadMessagesCount += timeWindow.content.length; + } + } + + if (chatId !== PUBLIC_GROUP_CHAT_ID) { + const groupChatsParticipants = groupChats[chatId].participants; + const otherParticipant = groupChatsParticipants.filter((user) => user.id !== Auth.userID)[0]; + const user = users[otherParticipant.id]; + const startedChats = Session.get(STARTED_CHAT_LIST_KEY) || []; + + return { + color: user?.color || '#7b1fa2', + isModerator: user?.role === ROLE_MODERATOR, + name: user?.name || otherParticipant.name, + avatar: user?.avatar, + chatId, + unreadCounter: unreadMessagesCount, + userId: user?.userId || otherParticipant.id, + shouldDisplayInChatList: groupChats[chatId].createdBy === Auth.userID + || startedChats.includes(chatId) + || !!contextChat, + }; + } + + return { + userId: PUBLIC_CHAT_ID, + name: 'Public Chat', + icon: 'group_chat', + chatId: PUBLIC_CHAT_ID, + unreadCounter: unreadMessagesCount, + shouldDisplayInChatList: true, + }; + }); + + const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || []; + const removeClosedChats = chatInfo.filter((chat) => !currentClosedChats.find(closedChat => closedChat.chatId === chat.chatId) + && chat.shouldDisplayInChatList); + const sortByChatIdAndUnread = removeClosedChats.sort((a, b) => { + if (a.chatId === PUBLIC_GROUP_CHAT_ID) { + return -1; + } + if (b.chatId === PUBLIC_CHAT_ID) { + return 0; + } + if (a.unreadCounter > b.unreadCounter) { + return -1; + } else if (b.unreadCounter > a.unreadCounter) { + return 1; + } else { + if (a.name.toLowerCase() < b.name.toLowerCase()) { + return -1; + } + if (a.name.toLowerCase() > b.name.toLowerCase()) { + return 1; + } + return 0; + } + }); + return sortByChatIdAndUnread; +}; + +const isVoiceOnlyUser = (userId) => userId.toString().startsWith('v_'); + +const isMeetingLocked = (id) => { + const meeting = Meetings.findOne({ meetingId: id }, + { fields: { lockSettingsProps: 1, usersProp: 1 } }); + let isLocked = false; + + if (meeting.lockSettingsProps !== undefined) { + const { lockSettingsProps: lockSettings, usersProp } = meeting; + + if (lockSettings.disableCam + || lockSettings.disableMic + || lockSettings.disablePrivateChat + || lockSettings.disablePublicChat + || lockSettings.disableNotes + || lockSettings.hideUserList + || lockSettings.hideViewersCursor + || lockSettings.hideViewersAnnotation + || usersProp.webcamsOnlyForModerator) { + isLocked = true; + } + } + + return isLocked; +}; + +const getUsersProp = () => { + const meeting = Meetings.findOne( + { meetingId: Auth.meetingID }, + { + fields: { + 'usersProp.allowModsToUnmuteUsers': 1, + 'usersProp.allowModsToEjectCameras': 1, + 'usersProp.authenticatedGuest': 1, + 'usersProp.allowPromoteGuestToModerator': 1, + }, + }, + ); + + if (meeting.usersProp) return meeting.usersProp; + + return { + allowModsToUnmuteUsers: false, + allowModsToEjectCameras: false, + authenticatedGuest: false, + allowPromoteGuestToModerator: false, + }; +}; + +const curatedVoiceUser = (intId) => { + const voiceUser = VoiceUsers.findOne({ intId }); + return { + isVoiceUser: voiceUser ? voiceUser.joined : false, + isMuted: voiceUser ? voiceUser.muted && !voiceUser.listenOnly : false, + isTalking: voiceUser ? voiceUser.talking && !voiceUser.muted : false, + isListenOnly: voiceUser ? voiceUser.listenOnly : false, + }; +}; + +const getAvailableActions = ( + amIModerator, isBreakoutRoom, subjectUser, subjectVoiceUser, usersProp, amIPresenter, +) => { + const isDialInUser = isVoiceOnlyUser(subjectUser.userId) || subjectUser.phone_user; + const amISubjectUser = isMe(subjectUser.userId); + const isSubjectUserModerator = subjectUser.role === ROLE_MODERATOR; + const isSubjectUserGuest = subjectUser.guest; + + const hasAuthority = amIModerator || amISubjectUser; + const allowedToChatPrivately = !amISubjectUser && !isDialInUser; + const allowedToMuteAudio = hasAuthority + && subjectVoiceUser.isVoiceUser + && !subjectVoiceUser.isMuted + && !subjectVoiceUser.isListenOnly; + + const allowedToUnmuteAudio = hasAuthority + && subjectVoiceUser.isVoiceUser + && !subjectVoiceUser.isListenOnly + && subjectVoiceUser.isMuted + && (amISubjectUser || usersProp.allowModsToUnmuteUsers); + + const allowedToResetStatus = hasAuthority + && subjectUser.emoji !== EMOJI_STATUSES.none + && !isDialInUser; + + // if currentUser is a moderator, allow removing other users + const allowedToRemove = amIModerator + && !amISubjectUser + && !isBreakoutRoom; + + const allowedToSetPresenter = amIModerator + && !subjectUser.presenter + && !isDialInUser; + + const allowedToPromote = amIModerator + && !amISubjectUser + && !isSubjectUserModerator + && !isDialInUser + && !isBreakoutRoom + && !(isSubjectUserGuest + && usersProp.authenticatedGuest + && !usersProp.allowPromoteGuestToModerator); + + const allowedToDemote = amIModerator + && !amISubjectUser + && isSubjectUserModerator + && !isDialInUser + && !isBreakoutRoom + && !(isSubjectUserGuest + && usersProp.authenticatedGuest + && !usersProp.allowPromoteGuestToModerator); + + const allowedToChangeStatus = amISubjectUser && USER_STATUS_ENABLED; + + const allowedToChangeUserLockStatus = amIModerator + && !isSubjectUserModerator + && isMeetingLocked(Auth.meetingID); + + const allowedToChangeWhiteboardAccess = amIPresenter + && !amISubjectUser; + + const allowedToEjectCameras = amIModerator + && !amISubjectUser + && usersProp.allowModsToEjectCameras; + + const allowedToSetAway = amISubjectUser && !USER_STATUS_ENABLED; + + return { + allowedToChatPrivately, + allowedToMuteAudio, + allowedToUnmuteAudio, + allowedToResetStatus, + allowedToRemove, + allowedToSetPresenter, + allowedToPromote, + allowedToDemote, + allowedToChangeStatus, + allowedToChangeUserLockStatus, + allowedToChangeWhiteboardAccess, + allowedToEjectCameras, + allowedToSetAway, + }; +}; + +const normalizeEmojiName = (emoji) => ( + emoji in EMOJI_STATUSES ? EMOJI_STATUSES[emoji] : emoji +); + +const setEmojiStatus = throttle({ interval: 1000 }, (userId, emoji) => { + const statusAvailable = (Object.keys(EMOJI_STATUSES).includes(emoji)); + return statusAvailable + ? makeCall('setEmojiStatus', Auth.userID, emoji) + : makeCall('setEmojiStatus', userId, 'none'); +}); + +const setUserAway = throttle({ interval: 1000 }, (userId, away) => { + return makeCall('changeAway', away); +}, 250, { leading: false, trailing: true }); + +const setUserRaiseHand = throttle({ interval: 1000 }, (userId, raiseHand) => { + return makeCall('changeRaiseHand', raiseHand); +}, 250, { leading: false, trailing: true }); + +const clearAllEmojiStatus = () => { + makeCall('clearAllUsersEmoji'); +}; + +const clearAllReactions = () => { + makeCall('clearAllUsersReaction'); +}; + +const assignPresenter = (userId) => { makeCall('assignPresenter', userId); }; + +const removeUser = (userId, banUser) => { + if (isVoiceOnlyUser(userId)) { + makeCall('ejectUserFromVoice', userId, banUser); + } else { + makeCall('removeUser', userId, banUser); + } +}; + +const toggleVoice = (userId) => { + if (userId === Auth.userID) { + AudioService.toggleMuteMicrophone(); + } else { + makeCall('toggleVoice', userId); + logger.info({ + logCode: 'usermenu_option_mute_toggle_audio', + extraInfo: { logType: 'moderator_action', userId }, + }, 'moderator muted user microphone'); + } +}; + +const ejectUserCameras = (userId) => { + makeCall('ejectUserCameras', userId); +}; + +const getEmoji = () => { + const currentUser = Users.findOne({ userId: Auth.userID }, + { fields: { emoji: 1 } }); + + if (!currentUser) { + return false; + } + + return currentUser.emoji; +}; + +const muteAllUsers = (userId) => { makeCall('muteAllUsers', userId); }; + +const muteAllExceptPresenter = (userId) => { makeCall('muteAllExceptPresenter', userId); }; + +const changeRole = (userId, role) => { makeCall('changeRole', userId, role); }; + +const focusFirstDropDownItem = () => { + const dropdownContent = document.querySelector('div[data-test="dropdownContent"][style="visibility: visible;"]'); + if (!dropdownContent) return; + const list = dropdownContent.getElementsByTagName('li'); + list[0].focus(); +}; + +const roving = (...args) => { + const [ + event, + changeState, + elementsList, + element, + ] = args; + + this.selectedElement = element; + const numberOfChilds = elementsList.childElementCount; + const menuOpen = Session.get('dropdownOpen') || false; + + if (menuOpen) { + const menuChildren = document.activeElement.getElementsByTagName('li'); + + if ([KEY_CODES.ESCAPE, KEY_CODES.ARROW_LEFT].includes(event.keyCode)) { + Session.set('dropdownOpen', false); + document.activeElement.click(); + } + + if ([KEY_CODES.ARROW_UP].includes(event.keyCode)) { + menuChildren[menuChildren.length - 1].focus(); + } + + if ([KEY_CODES.ARROW_DOWN].includes(event.keyCode)) { + for (let i = 0; i < menuChildren.length; i += 1) { + if (menuChildren[i].hasAttribute('tabIndex')) { + menuChildren[i].focus(); + break; + } + } + } + + return; + } + + if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.keyCode)) { + Session.set('dropdownOpen', false); + changeState(null); + } + + if (event.keyCode === KEY_CODES.ARROW_DOWN) { + const firstElement = elementsList.firstChild; + let elRef = element && numberOfChilds > 1 ? element.nextSibling : firstElement; + + elRef = elRef || firstElement; + changeState(elRef); + } + + if (event.keyCode === KEY_CODES.ARROW_UP) { + const lastElement = elementsList.lastChild; + let elRef = element ? element.previousSibling : lastElement; + elRef = elRef || lastElement; + changeState(elRef); + } + + if ([KEY_CODES.ARROW_RIGHT, KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event.keyCode)) { + const tether = document.activeElement.firstChild; + const dropdownTrigger = tether.firstChild; + dropdownTrigger?.click(); + focusFirstDropDownItem(); + } +}; + +const hasPrivateChatBetweenUsers = (senderId, receiverId) => GroupChat + .findOne({ users: { $all: [receiverId, senderId] } }); + +const getGroupChatPrivate = (senderUserId, receiver) => { + const chat = hasPrivateChatBetweenUsers(senderUserId, receiver.userId); + if (!chat) { + makeCall('createGroupChat', receiver); + } else { + const startedChats = Session.get(STARTED_CHAT_LIST_KEY) || []; + if (indexOf(startedChats, chat.chatId) < 0) { + startedChats.push(chat.chatId); + Session.set(STARTED_CHAT_LIST_KEY, startedChats); + } + + const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY); + + if (ChatService.isChatClosed(chat.chatId)) { + const closedChats = currentClosedChats.filter(closedChat => closedChat.chatId !== chat.chatId); + Storage.setItem(CLOSED_CHAT_LIST_KEY,closedChats); + } + } +}; + +const toggleUserChatLock = (userId, isLocked) => { + makeCall('toggleUserChatLock', userId, isLocked); +} + +const toggleUserLock = (userId, lockStatus) => { + makeCall('toggleUserLock', userId, lockStatus); +}; + +const requestUserInformation = (userId) => { + makeCall('requestUserInformation', userId); +}; + +const sortUsersByFirstName = (a, b) => { + const aUser = { sortName: a.firstName ? a.firstName : '' }; + const bUser = { sortName: b.firstName ? b.firstName : '' }; + + return sortUsersByName(aUser, bUser); +}; + +const sortUsersByLastName = (a, b) => { + const aUser = { sortName: a.lastName ? a.lastName : '' }; + const bUser = { sortName: b.lastName ? b.lastName : '' }; + + return sortUsersByName(aUser, bUser); +}; + +const isUserPresenter = (userId = Auth.userID) => { + const user = Users.findOne({ userId }, + { fields: { presenter: 1 } }); + return user ? user.presenter : false; +}; + +export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => { + const mimeType = 'text/plain'; + const userNamesObj = getUsers() + .map((u) => { + const name = u.name.split(' '); + return ({ + firstName: name[0], + middleNames: name.length > 2 ? name.slice(1, name.length - 1) : null, + lastName: name.length > 1 ? name[name.length - 1] : null, + }); + }); + + const getUsernameString = (user) => { + const { firstName, middleNames, lastName } = user; + return `${firstName || ''} ${middleNames && middleNames.length > 0 ? middleNames.join(' ') : ''} ${lastName || ''}`; + }; + + const namesByFirstName = userNamesObj.sort(sortUsersByFirstName) + .map((u) => getUsernameString(u)).join('\r\n'); + + const namesByLastName = userNamesObj.sort(sortUsersByLastName) + .map((u) => getUsernameString(u)).join('\r\n'); + + const namesListsString = `${docTitle}\r\n\r\n${fnSortedLabel}\r\n${namesByFirstName} + \r\n\r\n${lnSortedLabel}\r\n${namesByLastName}`.replace(/ {2}/g, ' '); + + const link = document.createElement('a'); + const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'meetingProp.name': 1 } }); + link.setAttribute('download', `bbb-${meeting.meetingProp.name}[users-list]_${getDateString()}.txt`); + link.setAttribute( + 'href', + `data: ${mimeType};charset=utf-16,${encodeURIComponent(namesListsString)}`, + ); + return link; +}; + +const UserJoinedMeetingAlert = (obj) => { + const { + userJoinAudioAlerts, + userJoinPushAlerts, + } = Settings.application; + + if (!userJoinAudioAlerts && !userJoinPushAlerts) return; + + if (userJoinAudioAlerts) { + AudioService.playAlertSound(`${Meteor.settings.public.app.cdn + + Meteor.settings.public.app.basename + + Meteor.settings.public.app.instanceId}` + + '/resources/sounds/userJoin.mp3'); + } + + if (userJoinPushAlerts) { + notify( + , + obj.notificationType, + obj.icon, + ); + } +} + +const UserLeftMeetingAlert = (obj) => { + const { + userLeaveAudioAlerts, + userLeavePushAlerts, + } = Settings.application; + + if (!userLeaveAudioAlerts && !userLeavePushAlerts) return; + + if (userLeaveAudioAlerts) { + AudioService.playAlertSound(`${Meteor.settings.public.app.cdn + + Meteor.settings.public.app.basename + + Meteor.settings.public.app.instanceId}` + + '/resources/sounds/notify.mp3'); + } + + if (userLeavePushAlerts) { + notify( + , + obj.notificationType, + obj.icon, + ); + } +} + +export default { + sortUsersByName, + sortUsers, + setEmojiStatus, + setUserAway, + setUserRaiseHand, + clearAllEmojiStatus, + clearAllReactions, + assignPresenter, + removeUser, + toggleVoice, + muteAllUsers, + muteAllExceptPresenter, + changeRole, + getUsers, + formatUsers, + getActiveChats, + getAvailableActions, + curatedVoiceUser, + normalizeEmojiName, + isMeetingLocked, + isPublicChat, + roving, + getCustomLogoUrl, + getGroupChatPrivate, + toggleUserChatLock, + hasBreakoutRoom, + getEmojiList: () => EMOJI_STATUSES, + getEmoji, + hasPrivateChatBetweenUsers, + toggleUserLock, + requestUserInformation, + focusFirstDropDownItem, + isUserPresenter, + getUsersProp, + getUserCount, + sortUsersByCurrent, + ejectUserCameras, + UserJoinedMeetingAlert, + UserLeftMeetingAlert, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/styles.js b/src/2.7.12/imports/ui/components/user-list/styles.js new file mode 100644 index 00000000..9844368a --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/styles.js @@ -0,0 +1,80 @@ +import styled from 'styled-components'; + +import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders'; +import { + userListBg, + userListText, + colorGray, + listItemBgHover, + itemFocusBorder, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { smPaddingX, borderSize } from '/imports/ui/stylesheets/styled-components/general'; + +const UserList = styled(FlexColumn)` + justify-content: flex-start; + background-color: ${userListBg}; + color: ${userListText}; + height: 100%; +`; + +const SmallTitle = styled.h2` + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + padding: 0 ${smPaddingX}; + color: ${colorGray}; + flex: 1; + margin: 0; +`; + +const Messages = styled.div` + flex-grow: 0; + display: flex; + flex-flow: column; + flex-shrink: 0; + max-height: 30vh; +`; + +const ListItem = styled.div` + display: flex; + flex-flow: row; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + cursor: pointer; + + [dir="rtl"] & { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + + &:first-child { + margin-top: 0; + } + + &:hover { + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + background-color: ${listItemBgHover}; + } + + &:active, + &:focus { + outline: transparent; + outline-width: ${borderSize}; + outline-style: solid; + background-color: ${listItemBgHover}; + box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder}; + } +`; + +export default { + UserList, + SmallTitle, + ListItem, + Messages, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx new file mode 100644 index 00000000..f1f6f089 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from '/imports/ui/components/common/icon/component'; +import Styled from './styles'; +import { ACTIONS, PANELS } from '../../../layout/enums'; +import MeetingRemainingTime from '../../../notifications-bar/meeting-remaining-time/container'; + +const intlMessages = defineMessages({ + breakoutTitle: { + id: 'app.createBreakoutRoom.title', + description: 'breakout title', + }, + breakoutTimeRemaining: { + id: 'app.createBreakoutRoom.duration', + description: 'Message that tells how much time is remaining for the breakout room', + }, +}); + +const BreakoutRoomItem = ({ + hasBreakoutRoom, + breakoutRoom, + sidebarContentPanel, + layoutContextDispatch, + intl, +}) => { + const toggleBreakoutPanel = () => { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: sidebarContentPanel !== PANELS.BREAKOUT, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: sidebarContentPanel === PANELS.BREAKOUT + ? PANELS.NONE + : PANELS.BREAKOUT, + }); + }; + + if (hasBreakoutRoom) { + return ( + + + + {intl.formatMessage(intlMessages.breakoutTitle)} + + + + + { + if (e.key === 'Enter') { + e.preventDefault(); + toggleBreakoutPanel(); + } + }} + > + +
+ + {intl.formatMessage(intlMessages.breakoutTitle)} + + + + +
+
+
+
+
+ ); + } + return ; +}; + +export default injectIntl(BreakoutRoomItem); + +BreakoutRoomItem.propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + hasBreakoutRoom: PropTypes.bool.isRequired, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx new file mode 100644 index 00000000..f00b5038 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import BreakoutRoomItem from './component'; +import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; +import Breakouts from '/imports/api/breakouts'; +import Auth from '/imports/ui/services/auth'; + +const BreakoutRoomContainer = ({ breakoutRoom }) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + + const hasBreakoutRoom = !!breakoutRoom; + + return ( + + ); +}; + +export default withTracker(() => { + const breakoutRoom = Breakouts.findOne( + { parentMeetingId: Auth.meetingID }, + { fields: { timeRemaining: 1 } }, + ); + + return { + breakoutRoom, + }; +})(BreakoutRoomContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js new file mode 100644 index 00000000..ddf72185 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; +import { colorGray } from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography'; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +const ListItem = styled(StyledContent.ListItem)``; + +const BreakoutTitle = styled.div` + font-size: ${fontSizeSmall}; +`; + +const BreakoutDuration = styled.p` + margin: 0; + font-size: 0.75rem; + font-weight: 200; + color: ${colorGray}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-style: italic; +`; + +export default { + Messages, + Container, + SmallTitle, + ScrollableList, + List, + ListItem, + BreakoutTitle, + BreakoutDuration, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx new file mode 100644 index 00000000..e6fbb707 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx @@ -0,0 +1,60 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import Styled from './styles'; +import UserParticipantsContainer from './user-participants/container'; +import UserMessagesContainer from './user-messages/container'; +import UserNotesContainer from './user-notes/container'; +import TimerContainer from './timer/container'; +import UserCaptionsContainer from './user-captions/container'; +import WaitingUsersContainer from './waiting-users/container'; +import UserPollsContainer from './user-polls/container'; +import BreakoutRoomContainer from './breakout-room/container'; +import { isChatEnabled } from '/imports/ui/services/features'; + +const propTypes = { + currentUser: PropTypes.shape({}).isRequired, + isTimerActive: PropTypes.bool.isRequired, +}; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; +const ALWAYS_SHOW_WAITING_ROOM = Meteor.settings.public.app.alwaysShowWaitingRoomUI; + +class UserContent extends PureComponent { + render() { + const { + currentUser, + isTimerActive, + pendingUsers, + isWaitingRoomEnabled, + isGuestLobbyMessageEnabled, + compact, + } = this.props; + + const showWaitingRoom = (ALWAYS_SHOW_WAITING_ROOM && isWaitingRoomEnabled) + || pendingUsers.length > 0; + + return ( + + {isChatEnabled() ? : null} + + + { isTimerActive && ( + + ) } + {showWaitingRoom && currentUser.role === ROLE_MODERATOR + ? ( + + ) : null} + + + + + ); + } +} + +UserContent.propTypes = propTypes; + +export default UserContent; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx new file mode 100644 index 00000000..89441369 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx @@ -0,0 +1,40 @@ +import React, { useContext } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Auth from '/imports/ui/services/auth'; +import UserContent from './component'; +import GuestUsers from '/imports/api/guest-users'; +import TimerService from '/imports/ui/components/timer/service'; +import { UsersContext } from '/imports/ui/components/components-data/users-context/context'; +import WaitingUsersService from '/imports/ui/components/waiting-users/service'; + +const UserContentContainer = (props) => { + const usingUsersContext = useContext(UsersContext); + const { users } = usingUsersContext; + const currentUser = { + userId: Auth.userID, + presenter: users[Auth.meetingID][Auth.userID].presenter, + locked: users[Auth.meetingID][Auth.userID].locked, + role: users[Auth.meetingID][Auth.userID].role, + }; + const { isGuestLobbyMessageEnabled } = WaitingUsersService; + + return ( + + ); +}; + +export default withTracker(() => ({ + isTimerActive: TimerService.isActive(), + pendingUsers: GuestUsers.find({ + meetingId: Auth.meetingID, + approved: false, + denied: false, + }).fetch(), + isWaitingRoomEnabled: WaitingUsersService.isWaitingRoomEnabled(), +}))(UserContentContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js new file mode 100644 index 00000000..8deb290d --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js @@ -0,0 +1,175 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders'; +import { + smPaddingX, + lgPaddingY, + borderSize, + mdPaddingY, + mdPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + colorPrimary, + userListBg, + colorWhite, + colorOffWhite, + colorGrayDark, + colorGrayLight, + colorGrayLighter, + listItemBgHover, + itemFocusBorder, + unreadMessagesBg, +} from '/imports/ui/stylesheets/styled-components/palette'; +import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography'; +import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable'; + +const Content = styled(FlexColumn)` + flex-grow: 1; + overflow: hidden; +`; + +const Container = styled.div` + display: flex; + align-items: center; + margin-bottom: ${lgPaddingY}; + margin-top: ${smPaddingX}; +`; + +const ScrollableList = styled(ScrollboxVertical)` + background: linear-gradient(${userListBg} 30%, rgba(255,255,255,0)), + linear-gradient(rgba(255,255,255,0), ${userListBg} 70%) 0 100%, + /* Shadows */ + radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; + + outline: none; + + &:hover { + /* Visible in Windows high-contrast themes */ + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + } + + &:focus, + &:active { + border-radius: none; + box-shadow: inset 0 0 1px ${colorPrimary}; + outline-style: transparent; + } + + overflow-x: hidden; + padding-top: 1px; + padding-right: 1px; +`; + +const List = styled.div` + margin: 0 0 1px ${mdPaddingY}; + + [dir="rtl"] & { + margin: 0 ${mdPaddingY} 1px 0; + } +`; + +const ListItem = styled(Styled.ListItem)` + align-items: center; + cursor: pointer; + display: flex; + flex-flow: row; + flex-grow: 0; + flex-shrink: 0; + padding-top: ${lgPaddingY}; + padding-bottom: ${lgPaddingY}; + padding-left: ${lgPaddingY}; + text-decoration: none; + width: 100%; + color: ${colorGrayDark}; + background-color: ${colorOffWhite}; + + [dir="rtl"] & { + padding-right: ${lgPaddingY}; + padding-left: 0; + } + + > i { + display: flex; + font-size: 175%; + color: ${colorGrayLight}; + flex: 0 0 2.2rem; + margin-right: ${smPaddingX}; + [dir="rtl"] & { + margin-right: 0; + margin-left: ${smPaddingX}; + } + } + + > span { + font-weight: 400; + font-size: ${fontSizeSmall}; + color: ${colorGrayDark}; + position: relative; + flex-grow: 1; + line-height: 2; + text-align: left; + padding-left: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + [dir="rtl"] & { + text-align: right; + padding-right: ${mdPaddingX}; + } + } + + div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &:active { + background-color: ${listItemBgHover}; + box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder}; + } +`; + +const UnreadMessages = styled(FlexColumn)` + justify-content: center; + margin-left: auto; + [dir="rtl"] & { + margin-right: auto; + margin-left: 0; + } +`; + +const UnreadMessagesText = styled(FlexColumn)` + margin: 0; + justify-content: center; + color: ${colorWhite}; + line-height: calc(1rem + 1px); + padding: 0 0.5rem; + text-align: center; + border-radius: 0.5rem/50%; + font-size: 0.8rem; + background-color: ${unreadMessagesBg}; +`; + +const Separator = styled.hr` + margin: 1rem auto; + width: 2.2rem; + border: 0; + border-top: 1px solid ${colorGrayLighter}; +`; + +export default { + Content, + Container, + ScrollableList, + List, + ListItem, + UnreadMessages, + UnreadMessagesText, + Separator, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx new file mode 100644 index 00000000..aaf4cb03 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx @@ -0,0 +1,75 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from '/imports/ui/components/common/icon/component'; +import TimerService from '/imports/ui/components/timer/service'; +import Styled from './styles'; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + stopwatch: PropTypes.bool.isRequired, + sidebarContentPanel: PropTypes.shape().isRequired, + layoutContextDispatch: PropTypes.shape().isRequired, + isModerator: PropTypes.bool.isRequired, +}; + +const intlMessages = defineMessages({ + title: { + id: 'app.userList.timerTitle', + description: 'Title for the time', + }, + timer: { + id: 'app.timer.timer.title', + description: 'Title for the timer', + }, + stopwatch: { + id: 'app.timer.stopwatch.title', + description: 'Title for the stopwatch', + }, +}); + +class Timer extends PureComponent { + render() { + const { + intl, + isModerator, + stopwatch, + sidebarContentPanel, + layoutContextDispatch, + } = this.props; + + if (!isModerator) return null; + + const message = stopwatch ? intlMessages.stopwatch : intlMessages.timer; + + return ( + + + + {intl.formatMessage(intlMessages.title)} + + + + + TimerService.togglePanel(sidebarContentPanel, layoutContextDispatch)} + > + + + {intl.formatMessage(message)} + + + + + + ); + } +} + +Timer.propTypes = propTypes; + +export default injectIntl(Timer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx new file mode 100644 index 00000000..ed864e0f --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import TimerService from '/imports/ui/components/timer/service'; +import Timer from './component'; +import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; + +const TimerContainer = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + return ; +}; + +export default withTracker(() => ({ + isModerator: TimerService.isModerator(), + stopwatch: TimerService.getStopwatch(), +}))(TimerContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js new file mode 100644 index 00000000..d3341890 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; + +const ListItem = styled(StyledContent.ListItem)` + i{ left: 4px; } +`; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +export default { + ListItem, + Messages, + Container, + SmallTitle, + ScrollableList, + List, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx new file mode 100644 index 00000000..ca517720 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx @@ -0,0 +1,139 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import CaptionsListItem from '/imports/ui/components/user-list/captions-list-item/component'; +import { defineMessages, injectIntl } from 'react-intl'; +import KEY_CODES from '/imports/utils/keyCodes'; +import Styled from './styles'; +import { findDOMNode } from 'react-dom'; + +const propTypes = { + ownedLocales: PropTypes.arrayOf(PropTypes.object).isRequired, + sidebarContentPanel: PropTypes.string.isRequired, + layoutContextDispatch: PropTypes.func.isRequired, + roving: PropTypes.func.isRequired, +}; + +const intlMessages = defineMessages({ + title: { + id: 'app.userList.captionsTitle', + description: 'Title for the captions list', + }, +}); + +class UserCaptions extends Component { + constructor() { + super(); + + this.state = { + selectedCaption: null, + }; + + this.activeCaptionRefs = []; + + this.changeState = this.changeState.bind(this); + this.rove = this.rove.bind(this); + } + + componentDidMount() { + if (this._captionsList) { + this._captionsList.addEventListener( + 'keydown', + this.rove, + true, + ); + } + } + + componentDidUpdate(prevProps, prevState) { + const { selectedCaption } = this.state; + if (selectedCaption && selectedCaption !== prevState.selectedCaption) { + const { firstChild } = selectedCaption; + if (firstChild) firstChild.focus(); + } + } + + changeState(ref) { + this.setState({ selectedCaption: ref }); + } + + rove(event) { + const { roving } = this.props; + const { selectedCaption } = this.state; + const captionItemsRef = findDOMNode(this._captionItems); + if ([KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event?.which)) { + selectedCaption?.firstChild?.click(); + } else { + roving(event, this.changeState, captionItemsRef, selectedCaption); + } + event.stopPropagation(); + } + + renderCaptions() { + const { + ownedLocales, + sidebarContentPanel, + layoutContextDispatch, + } = this.props; + + let index = -1; + + return ownedLocales.map((ownedLocale) => ( + + { this.activeCaptionRefs[index += 1] = node; }}> + + + + )); + } + + render() { + const { + intl, + ownedLocales, + } = this.props; + + if (ownedLocales.length < 1) return null; + + return ( + + + + {intl.formatMessage(intlMessages.title)} + + + { this._captionsList = ref; }} + > + + { this._captionItems = ref; }}> + {this.renderCaptions()} + + + + + ); + } +} + +UserCaptions.propTypes = propTypes; + +export default injectIntl(UserCaptions); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx new file mode 100644 index 00000000..a9d79e49 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import UserCaptionsItem from './component'; +import Service from '/imports/ui/components/user-list/service'; +import CaptionsService from '/imports/ui/components/captions/service'; +import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; + +const Container = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + const { roving } = Service; + return ; +}; + +export default withTracker(() => ({ + ownedLocales: CaptionsService.getOwnedLocales(), +}))(Container); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js new file mode 100644 index 00000000..02fcb050 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js @@ -0,0 +1,61 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; +import { borderSize } from '/imports/ui/stylesheets/styled-components/general'; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +const ListTransition = styled.div` + display: flex; + flex-flow: column; + margin: 0; + padding: 0; + padding-top: ${borderSize}; + outline: none; + overflow: hidden; + flex-shrink: 1; + + &.transition-enter, + &.transition-appear { + opacity: 0.01; + } + + &.transition-enter-active, + &.transition-appear-active { + opacity: 1; + + &.animationsEnabled { + transition: all 600ms; + } + } + + &.transition-leave { + opacity: 1; + } + + &.transition-leave-active { + opacity: 0; + + &.animationsEnabled { + transition: all 600ms; + } + } +`; + +export default { + Messages, + Container, + SmallTitle, + ScrollableList, + List, + ListTransition, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx new file mode 100644 index 00000000..c722e43f --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx @@ -0,0 +1,141 @@ +import React, { PureComponent } from 'react'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import PropTypes from 'prop-types'; +import { defineMessages } from 'react-intl'; +import Styled from './styles'; +import { findDOMNode } from 'react-dom'; +import ChatListItemContainer from '../../chat-list-item/container'; +import { injectIntl } from 'react-intl'; + +const propTypes = { + activeChats: PropTypes.arrayOf(String).isRequired, + compact: PropTypes.bool, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + roving: PropTypes.func.isRequired, +}; + +const defaultProps = { + compact: false, +}; + +const intlMessages = defineMessages({ + messagesTitle: { + id: 'app.userList.messagesTitle', + description: 'Title for the messages list', + }, +}); + +class UserMessages extends PureComponent { + constructor() { + super(); + + this.state = { + selectedChat: null, + }; + + this.activeChatRefs = []; + + this.changeState = this.changeState.bind(this); + this.rove = this.rove.bind(this); + } + + componentDidMount() { + const { compact } = this.props; + if (!compact) { + this._msgsList.addEventListener( + 'keydown', + this.rove, + true, + ); + } + } + + componentDidUpdate(prevProps, prevState) { + const { selectedChat } = this.state; + if (selectedChat && selectedChat !== prevState.selectedChat) { + const { firstChild } = selectedChat; + if (firstChild) firstChild.focus(); + } + } + + getActiveChats() { + const { + activeChats, + compact, + } = this.props; + + let index = -1; + + return activeChats.map(chat => ( + + { this.activeChatRefs[index += 1] = node; }}> + + + + )); + } + + changeState(ref) { + this.setState({ selectedChat: ref }); + } + + rove(event) { + const { roving } = this.props; + const { selectedChat } = this.state; + const msgItemsRef = findDOMNode(this._msgItems); + roving(event, this.changeState, msgItemsRef, selectedChat); + event.stopPropagation(); + } + + render() { + const { + intl, + compact, + } = this.props; + + return ( + + + { + !compact ? ( + + {intl.formatMessage(intlMessages.messagesTitle)} + + ) : ( + + ) + } + + { this._msgsList = ref; }} + > + + { this._msgItems = ref; }}> + {this.getActiveChats()} + + + + + ); + } +} + +UserMessages.propTypes = propTypes; +UserMessages.defaultProps = defaultProps; + +export default injectIntl(UserMessages); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx new file mode 100644 index 00000000..8af97a5e --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx @@ -0,0 +1,35 @@ +import React, { useContext } from 'react'; +import UserMessages from './component'; +import { ChatContext } from '/imports/ui/components/components-data/chat-context/context'; +import { GroupChatContext } from '/imports/ui/components/components-data/group-chat-context/context'; +import { UsersContext } from '/imports/ui/components/components-data/users-context/context'; +import Service from '/imports/ui/components/user-list/service'; +import Auth from '/imports/ui/services/auth'; +import { withTracker } from 'meteor/react-meteor-data'; +import Storage from '/imports/ui/services/storage/session'; +import { Session } from 'meteor/session'; + +const CLOSED_CHAT_LIST_KEY = 'closedChatList'; +const STARTED_CHAT_LIST_KEY = 'startedChatList'; + +const UserMessagesContainer = () => { + const usingChatContext = useContext(ChatContext); + const usingUsersContext = useContext(UsersContext); + const usingGroupChatContext = useContext(GroupChatContext); + const { chats: groupChatsMessages } = usingChatContext; + const { users } = usingUsersContext; + const { groupChat: groupChats } = usingGroupChatContext; + const activeChats = Service.getActiveChats({ groupChatsMessages, groupChats, users:users[Auth.meetingID] }); + const { roving } = Service; + + return ; +}; + +export default withTracker(() => { + // Here just to add reactivity to this component. + // We need to rerender this component whenever this + // Storage variable changes. + Storage.getItem(CLOSED_CHAT_LIST_KEY); + Session.get(STARTED_CHAT_LIST_KEY); + return {}; +})(UserMessagesContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js new file mode 100644 index 00000000..42e89674 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js @@ -0,0 +1,69 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; +import { borderSize } from '/imports/ui/stylesheets/styled-components/general'; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const Separator = styled(StyledContent.Separator)``; + +const MessagesTitle = styled(Styled.SmallTitle)` + flex: 1; + margin: 0; +`; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)` + background-color: var(--color-off-white,#F3F6F9); +`; + +const ListTransition = styled.div` + display: flex; + flex-flow: column; + margin: 0; + padding: 0; + padding-top: ${borderSize}; + outline: none; + overflow: hidden; + flex-shrink: 1; + + &.transition-enter, + &.transition-appear { + opacity: 0.01; + } + + &.transition-enter-active, + &.transition-appear-active { + opacity: 1; + + &.animationsEnabled { + transition: all 600ms; + } + } + + &.transition-leave { + opacity: 1; + } + + &.transition-leave-active { + opacity: 0; + + &.animationsEnabled { + transition: all 600ms; + } + } +`; + +export default { + Messages, + Container, + Separator, + MessagesTitle, + ScrollableList, + List, + ListTransition, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx new file mode 100644 index 00000000..c6362a57 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx @@ -0,0 +1,199 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from '/imports/ui/components/common/icon/component'; +import NotesService from '/imports/ui/components/notes/service'; +import Styled from './styles'; +import { PANELS } from '/imports/ui/components/layout/enums'; +import { notify } from '/imports/ui/services/notification'; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + isPinned: PropTypes.bool.isRequired, + sidebarContentPanel: PropTypes.string.isRequired, +}; + +const intlMessages = defineMessages({ + title: { + id: 'app.userList.notesTitle', + description: 'Title for the notes list', + }, + pinnedNotification: { + id: 'app.notes.pinnedNotification', + description: 'Notification text for pinned shared notes', + }, + sharedNotes: { + id: 'app.notes.title', + description: 'Title for the shared notes', + }, + sharedNotesPinned: { + id: 'app.notes.titlePinned', + description: 'Title for the shared notes pinned', + }, + unreadContent: { + id: 'app.userList.notesListItem.unreadContent', + description: 'Aria label for notes unread content', + }, + locked: { + id: 'app.notes.locked', + description: '', + }, + byModerator: { + id: 'app.userList.byModerator', + description: '', + }, + disabled: { + id: 'app.notes.disabled', + description: 'Aria description for disabled notes button', + }, +}); + +class UserNotes extends Component { + constructor(props) { + super(props); + + this.state = { + unread: false, + pinWasNotified: false, + }; + this.setUnread = this.setUnread.bind(this); + this.showTitleAlert = this.showTitleAlert.bind(this); + } + + componentDidMount() { + this.setUnread(NotesService.hasUnreadNotes()); + } + + componentDidUpdate(prevProps) { + const { sidebarContentPanel, isPinned } = this.props; + const { unread } = this.state; + + this.showTitleAlert(); + + const notesOpen = sidebarContentPanel === PANELS.SHARED_NOTES && !isPinned; + const notesClosed = (prevProps.sidebarContentPanel === PANELS.SHARED_NOTES + && sidebarContentPanel !== PANELS.SHARED_NOTES) + || (prevProps.isPinned && !isPinned); + + if (notesOpen && unread) { + NotesService.markNotesAsRead(); + this.setUnread(false); + } else if (!unread && NotesService.hasUnreadNotes()) { + this.setUnread(true); + } + + if (notesClosed) { + NotesService.markNotesAsRead(); + this.setUnread(false); + } + if (prevProps.isPinned && !isPinned) { + this.setState({ pinWasNotified: false }); + } + } + + setUnread(unread) { + this.setState({ unread }); + } + + showTitleAlert() { + const { + intl, + isPinned, + } = this.props; + const { pinWasNotified } = this.state; + if (isPinned && !pinWasNotified) { + notify(intl.formatMessage(intlMessages.pinnedNotification), 'info', 'copy', { pauseOnFocusLoss: false }); + this.setState({ + pinWasNotified: true, + }); + } + } + + renderNotes() { + const { + intl, + disableNotes, + sidebarContentPanel, + layoutContextDispatch, + isPinned, + } = this.props; + const { unread } = this.state; + + let notification = null; + if (unread && !isPinned) { + notification = ( + + + + ); + } + + const showTitle = isPinned ? intl.formatMessage(intlMessages.sharedNotesPinned) + : intl.formatMessage(intlMessages.sharedNotes); + return ( + NotesService.toggleNotesPanel(sidebarContentPanel, layoutContextDispatch)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + NotesService.toggleNotesPanel(sidebarContentPanel, layoutContextDispatch); + } + }} + as={isPinned ? 'button' : 'div'} + disabled={isPinned} + $disabled={isPinned} + > + +
+ + { showTitle } + + {disableNotes + ? ( + + + {`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`} + + ) : null} + {isPinned + ? ( + {`${intl.formatMessage(intlMessages.disabled)}`} + ) : null} +
+ {notification} +
+ ); + } + + render() { + const { intl } = this.props; + + if (!NotesService.isEnabled()) return null; + + return ( + + + + {intl.formatMessage(intlMessages.title)} + + + + + {this.renderNotes()} + + + + ); + } +} + +UserNotes.propTypes = propTypes; + +export default injectIntl(UserNotes); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx new file mode 100644 index 00000000..670ad193 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import NotesService from '/imports/ui/components/notes/service'; +import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; +import UserNotes from './component'; +import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; + +const UserNotesContainer = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + return ; +}; + +export default lockContextContainer(withTracker(({ userLocks }) => { + const shouldDisableNotes = userLocks.userNotes; + return { + unread: NotesService.hasUnreadNotes(), + disableNotes: shouldDisableNotes, + isPinned: NotesService.isSharedNotesPinned(), + }; +})(UserNotesContainer)); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js new file mode 100644 index 00000000..7ac236ae --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js @@ -0,0 +1,56 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; +import { colorGray } from '/imports/ui/stylesheets/styled-components/palette'; +import { + fontSizeSmall, + fontSizeSmaller, + fontSizeXS, +} from '/imports/ui/stylesheets/styled-components/typography'; + +const UnreadMessages = styled(StyledContent.UnreadMessages)``; + +const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``; + +const ListItem = styled(StyledContent.ListItem)` + i{ left: 4px; } +`; + +const NotesTitle = styled.div` + font-weight: 400; + font-size: ${fontSizeSmall}; +`; + +const NotesLock = styled.div` + font-weight: 200; + font-size: ${fontSizeSmaller}; + color: ${colorGray}; + + > i { + font-size: ${fontSizeXS}; + } +`; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +export default { + UnreadMessages, + UnreadMessagesText, + ListItem, + NotesTitle, + NotesLock, + Messages, + Container, + SmallTitle, + ScrollableList, + List, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx new file mode 100644 index 00000000..4d529ed3 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx @@ -0,0 +1,279 @@ +import React, { Component } from 'react'; +import { defineMessages } from 'react-intl'; +import PropTypes from 'prop-types'; +import Styled from './styles'; +import { findDOMNode } from 'react-dom'; +import { + AutoSizer, + CellMeasurer, + CellMeasurerCache, +} from 'react-virtualized'; +import UserListItemContainer from './user-list-item/container'; +import UserOptionsContainer from './user-options/container'; +import Settings from '/imports/ui/services/settings'; +import { injectIntl } from 'react-intl'; + +const propTypes = { + compact: PropTypes.bool, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + currentUser: PropTypes.shape({}), + users: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + setEmojiStatus: PropTypes.func.isRequired, + clearAllEmojiStatus: PropTypes.func.isRequired, + clearAllReactions: PropTypes.func.isRequired, + roving: PropTypes.func.isRequired, + requestUserInformation: PropTypes.func.isRequired, +}; + +const defaultProps = { + compact: false, + currentUser: null, +}; + +const intlMessages = defineMessages({ + usersTitle: { + id: 'app.userList.usersTitle', + description: 'Title for the Header', + }, +}); + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; +const SKELETON_COUNT = 10; + +class UserParticipants extends Component { + constructor() { + super(); + + this.cache = new CellMeasurerCache({ + fixedWidth: true, + keyMapper: () => 1, + }); + + this.state = { + selectedUser: null, + isOpen: false, + scrollArea: null, + }; + + this.userRefs = []; + + this.getScrollContainerRef = this.getScrollContainerRef.bind(this); + this.rove = this.rove.bind(this); + this.changeState = this.changeState.bind(this); + this.rowRenderer = this.rowRenderer.bind(this); + this.handleClickSelectedUser = this.handleClickSelectedUser.bind(this); + this.selectEl = this.selectEl.bind(this); + } + + componentDidMount() { + document.getElementById('user-list-virtualized-scroll')?.getElementsByTagName('div')[0]?.firstElementChild?.setAttribute('aria-label', 'Users list'); + + const { compact } = this.props; + if (!compact) { + this.refScrollContainer.addEventListener( + 'keydown', + this.rove, + ); + + this.refScrollContainer.addEventListener( + 'click', + this.handleClickSelectedUser, + ); + } + + window.addEventListener('beforeunload', () => Session.set('dropdownOpenUserId', null)); + } + + shouldComponentUpdate(nextProps) { + return nextProps.isReady; + } + + selectEl(el) { + if (!el) return null; + if (typeof el.getAttribute === 'function' && el.getAttribute('tabindex')) return el?.focus(); + this.selectEl(el?.firstChild); + } + + componentDidUpdate(prevProps, prevState) { + const { selectedUser } = this.state; + + if (selectedUser) { + const { firstChild } = selectedUser; + if (!firstChild.isEqualNode(document.activeElement)) { + this.selectEl(selectedUser); + } + } + } + + componentWillUnmount() { + this.refScrollContainer.removeEventListener('keydown', this.rove); + this.refScrollContainer.removeEventListener('click', this.handleClickSelectedUser); + } + + getScrollContainerRef() { + return this.refScrollContainer; + } + + rowRenderer({ + index, + parent, + style, + key, + }) { + const { + compact, + setEmojiStatus, + setUserAway, + users, + requestUserInformation, + currentUser, + meetingIsBreakout, + lockSettingsProps, + isThisMeetingLocked, + } = this.props; + const { scrollArea } = this.state; + const user = users[index]; + const isRTL = Settings.application.isRTL; + + return ( + + + + + + ); + } + + handleClickSelectedUser(event) { + let selectedUser = null; + if (event.path) { + selectedUser = event.path.find(p => p.id && p.id.includes('user-')); + } + this.setState({ selectedUser }); + } + + rove(event) { + const { roving } = this.props; + const { selectedUser, scrollArea } = this.state; + const usersItemsRef = findDOMNode(scrollArea.firstChild); + event.stopPropagation(); + roving(event, this.changeState, usersItemsRef, selectedUser); + } + + changeState(ref) { + this.setState({ selectedUser: ref }); + } + + render() { + const { + intl, + users, + compact, + clearAllEmojiStatus, + clearAllReactions, + currentUser, + meetingIsBreakout, + isMeetingMuteOnStart, + } = this.props; + const { isOpen, scrollArea } = this.state; + + return ( + + { + !compact + ? ( + + + {intl.formatMessage(intlMessages.usersTitle)} + {users.length > 0 ? ` (${users.length})` : null} + + {currentUser?.role === ROLE_MODERATOR + ? ( + + ) : null + } + + + ) + : + } + { + this.refScrollContainer = ref; + }} + > + + + {({ height, width }) => ( + { + if (ref !== null) { + this.listRef = ref; + } + + if (ref !== null && !scrollArea) { + this.setState({ scrollArea: findDOMNode(ref) }); + } + }} + rowHeight={this.cache.rowHeight} + rowRenderer={this.rowRenderer} + rowCount={users.length || SKELETON_COUNT} + height={height - 1} + width={width - 1} + overscanRowCount={30} + deferredMeasurementCache={this.cache} + tabIndex={-1} + /> + )} + + + + ); + } +} + +UserParticipants.propTypes = propTypes; +UserParticipants.defaultProps = defaultProps; + +export default injectIntl(UserParticipants); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx new file mode 100644 index 00000000..c9f11cc6 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import UserListService from '/imports/ui/components/user-list/service'; +import UserParticipants from './component'; +import { meetingIsBreakout } from '/imports/ui/components/app/service'; +import ChatService from '/imports/ui/components/chat/service'; +import Auth from '/imports/ui/services/auth'; +import useContextUsers from '/imports/ui/components/components-data/users-context/service'; +import VideoService from '/imports/ui/components/video-provider/service'; +import UserReactionService from '/imports/ui/components/user-reaction/service'; +import WhiteboardService from '/imports/ui/components/whiteboard/service'; +import Meetings from '/imports/api/meetings'; + +const UserParticipantsContainer = (props) => { + const { + formatUsers, + setEmojiStatus, + setUserAway, + clearAllEmojiStatus, + clearAllReactions, + roving, + requestUserInformation, + } = UserListService; + + const { videoUsers, whiteboardUsers, reactionUsers } = props; + const { users: contextUsers, isReady } = useContextUsers(); + + const currentUser = contextUsers && isReady ? contextUsers[Auth.meetingID][Auth.userID] : null; + const usersArray = contextUsers && isReady ? Object.values(contextUsers[Auth.meetingID]) : null; + const users = contextUsers && isReady ? formatUsers(usersArray, videoUsers, whiteboardUsers, reactionUsers) : []; + + return ( + + ); +}; + +export default withTracker(() => { + ChatService.removePackagedClassAttribute( + ['ReactVirtualized__Grid', 'ReactVirtualized__Grid__innerScrollContainer'], + 'role', + ); + + const whiteboardId = WhiteboardService.getCurrentWhiteboardId(); + const whiteboardUsers = whiteboardId ? WhiteboardService.getMultiUser(whiteboardId) : null; + const currentMeeting = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { lockSettingsProps: 1 } }); + + const isMeetingMuteOnStart = () => { + const { voiceProp } = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'voiceProp.muteOnStart': 1 } }); + const { muteOnStart } = voiceProp; + return muteOnStart; + }; + + return ({ + isMeetingMuteOnStart: isMeetingMuteOnStart(), + meetingIsBreakout: meetingIsBreakout(), + videoUsers: VideoService.getUsersIdFromVideoStreams(), + whiteboardUsers, + reactionUsers: UserReactionService.getUsersIdFromUserReaction(), + isThisMeetingLocked: UserListService.isMeetingLocked(Auth.meetingID), + lockSettingsProps: currentMeeting && currentMeeting.lockSettingsProps, + }); +})(UserParticipantsContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js new file mode 100644 index 00000000..40a358a8 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js @@ -0,0 +1,82 @@ +import styled from 'styled-components'; + +import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders'; +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; +import { + ScrollboxVertical, + VirtualizedScrollboxVertical, +} from '/imports/ui/stylesheets/styled-components/scrollable'; +import { borderSize, mdPaddingY } from '/imports/ui/stylesheets/styled-components/general'; +import { colorPrimary, userListBg } from '/imports/ui/stylesheets/styled-components/palette'; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const Separator = styled(StyledContent.Separator)``; + +const UserListColumn = styled(FlexColumn)` + min-height: 0; + flex-grow: 1; +`; + +const VirtualizedScrollableList = styled(ScrollboxVertical)` + background: linear-gradient(${userListBg} 30%, rgba(255,255,255,0)), + linear-gradient(rgba(255,255,255,0), ${userListBg} 70%) 0 100%, + /* Shadows */ + radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; + + > div { + outline: none; + } + + &:hover { + /* Visible in Windows high-contrast themes */ + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + } + + &:focus { + outline: none; + } + + &:focus, + &:active { + box-shadow: inset 0 0 1px ${colorPrimary}; + border-radius: none; + outline-style: transparent; + } + + flex-grow: 1; + flex-shrink: 1; + + margin: 0 0 1px ${mdPaddingY}; + + [dir="rtl"] & { + margin: 0 ${mdPaddingY} 1px 0; + } + margin-left: 0; + padding-top: 1px; +`; + +const VirtualizedList = styled(VirtualizedScrollboxVertical)` + background: linear-gradient(#f3f6f9 30%, rgba(255,255,255,0)), + linear-gradient(rgba(255,255,255,0), #f3f6f9 70%) 0 100%, + /* Shadows */ + radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; + + outline: none; +`; + +export default { + Container, + SmallTitle, + Separator, + UserListColumn, + VirtualizedScrollableList, + VirtualizedList, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx new file mode 100644 index 00000000..91fe487d --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx @@ -0,0 +1,964 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import TooltipContainer from '/imports/ui/components/common/tooltip/container'; +import { Session } from 'meteor/session'; +import { findDOMNode } from 'react-dom'; +import UserAvatar from '/imports/ui/components/user-avatar/component'; +import Icon from '/imports/ui/components/common/icon/component'; +import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; +import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component'; +import VideoService from '/imports/ui/components/video-provider/service'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import Styled from './styles'; +import { PANELS, ACTIONS } from '../../../../layout/enums'; +import WhiteboardService from '/imports/ui/components/whiteboard/service'; +import { isChatEnabled } from '/imports/ui/services/features'; +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; +import 'react-loading-skeleton/dist/skeleton.css'; +import { uniqueId } from '/imports/utils/string-utils'; + +const messages = defineMessages({ + presenter: { + id: 'app.userList.presenter', + description: 'Text for identifying presenter user', + }, + you: { + id: 'app.userList.you', + description: 'Text for identifying your user', + }, + locked: { + id: 'app.userList.locked', + description: 'Text for identifying locked user', + }, + menuTitleContext: { + id: 'app.userList.menuTitleContext', + description: 'adds context to userListItem menu title', + }, + userAriaLabel: { + id: 'app.userList.userAriaLabel', + description: 'aria label for each user in the userlist', + }, + statusTriggerLabel: { + id: 'app.actionsBar.emojiMenu.statusTriggerLabel', + description: 'label for option to show emoji menu', + }, + backTriggerLabel: { + id: 'app.audio.backLabel', + description: 'label for option to hide emoji menu', + }, + lockPublicChat: { + id: 'app.userList.menu.lockPublicChat.label', + description: 'label for option to lock user\'s public chat', + }, + unlockPublicChat: { + id: 'app.userList.menu.unlockPublicChat.label', + description: 'label for option to lock user\'s public chat', + }, + StartPrivateChat: { + id: 'app.userList.menu.chat.label', + description: 'label for option to start a new private chat', + }, + PinUserWebcam: { + id: 'app.userList.menu.webcamPin.label', + description: 'label for pin user webcam', + }, + UnpinUserWebcam: { + id: 'app.userList.menu.webcamUnpin.label', + description: 'label for pin user webcam', + }, + ClearStatusLabel: { + id: 'app.userList.menu.clearStatus.label', + description: 'Clear the emoji status of this user', + }, + takePresenterLabel: { + id: 'app.actionsBar.actionsDropdown.takePresenter', + description: 'Set this user to be the presenter in this meeting', + }, + makePresenterLabel: { + id: 'app.userList.menu.makePresenter.label', + description: 'label to make another user presenter', + }, + giveWhiteboardAccess: { + id: 'app.userList.menu.giveWhiteboardAccess.label', + description: 'label to give user whiteboard access', + }, + removeWhiteboardAccess: { + id: 'app.userList.menu.removeWhiteboardAccess.label', + description: 'label to remove user whiteboard access', + }, + ejectUserCamerasLabel: { + id: 'app.userList.menu.ejectUserCameras.label', + description: 'label to eject user cameras', + }, + RemoveUserLabel: { + id: 'app.userList.menu.removeUser.label', + description: 'Forcefully remove this user from the meeting', + }, + MuteUserAudioLabel: { + id: 'app.userList.menu.muteUserAudio.label', + description: 'Forcefully mute this user', + }, + UnmuteUserAudioLabel: { + id: 'app.userList.menu.unmuteUserAudio.label', + description: 'Forcefully unmute this user', + }, + PromoteUserLabel: { + id: 'app.userList.menu.promoteUser.label', + description: 'Forcefully promote this viewer to a moderator', + }, + DemoteUserLabel: { + id: 'app.userList.menu.demoteUser.label', + description: 'Forcefully demote this moderator to a viewer', + }, + UnlockUserLabel: { + id: 'app.userList.menu.unlockUser.label', + description: 'Unlock individual user', + }, + LockUserLabel: { + id: 'app.userList.menu.lockUser.label', + description: 'Lock a unlocked user', + }, + DirectoryLookupLabel: { + id: 'app.userList.menu.directoryLookup.label', + description: 'Directory lookup', + }, + yesLabel: { + id: 'app.confirmationModal.yesLabel', + description: 'confirm button label', + }, + noLabel: { + id: 'app.endMeeting.noLabel', + description: 'cancel confirm button label', + }, + removeConfirmTitle: { + id: 'app.userList.menu.removeConfirmation.label', + description: 'title for remove user confirmation modal', + }, + removeConfirmDesc: { + id: 'app.userlist.menu.removeConfirmation.desc', + description: 'description for remove user confirmation', + }, + moderator: { + id: 'app.userList.moderator', + description: 'Text for identifying moderator user', + }, + mobile: { + id: 'app.userList.mobile', + description: 'Text for identifying mobile user', + }, + guest: { + id: 'app.userList.guest', + description: 'Text for identifying guest user', + }, + sharingWebcam: { + id: 'app.userList.sharingWebcam', + description: 'Text for identifying who is sharing webcam', + }, + breakoutRoom: { + id: 'app.createBreakoutRoom.room', + description: 'breakout room', + }, + awayLabel: { + id: 'app.userList.menu.away', + description: 'Text for identifying away user', + }, + notAwayLabel: { + id: 'app.userList.menu.notAway', + description: 'Text for identifying not away user', + }, +}); + +const propTypes = { + compact: PropTypes.bool.isRequired, + user: PropTypes.shape({ + name: PropTypes.string.isRequired, + pin: PropTypes.bool.isRequired, + }), + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + getAvailableActions: PropTypes.func.isRequired, + isThisMeetingLocked: PropTypes.bool.isRequired, + normalizeEmojiName: PropTypes.func.isRequired, + getScrollContainerRef: PropTypes.func.isRequired, + toggleUserLock: PropTypes.func.isRequired, + toggleUserChatLock: PropTypes.func.isRequired, + isMeteorConnected: PropTypes.bool.isRequired, + isMe: PropTypes.func.isRequired, +}; + +const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; +const LABEL = Meteor.settings.public.user.label; + +class UserListItem extends PureComponent { + /** + * Return true if the content fit on the screen, false otherwise. + * + * @param {number} contentOffSetTop + * @param {number} contentOffsetHeight + * @return True if the content fit on the screen, false otherwise. + */ + static checkIfDropdownIsVisible(contentOffSetTop, contentOffsetHeight) { + return (contentOffSetTop + contentOffsetHeight) < window.innerHeight; + } + + constructor(props) { + super(props); + + this.state = { + isActionsOpen: false, + dropdownVisible: false, + showNestedOptions: false, + selected: false, + isConfirmationModalOpen: false, + }; + + this.handleScroll = this.handleScroll.bind(this); + this.onActionsShow = this.onActionsShow.bind(this); + this.onActionsHide = this.onActionsHide.bind(this); + this.getDropdownMenuParent = this.getDropdownMenuParent.bind(this); + this.renderUserAvatar = this.renderUserAvatar.bind(this); + this.resetMenuState = this.resetMenuState.bind(this); + this.setConfirmationModalIsOpen = this.setConfirmationModalIsOpen.bind(this); + + this.title = uniqueId('dropdown-title-'); + this.seperator = uniqueId('action-separator-'); + } + + componentDidUpdate() { + const { user, selectedUserId } = this.props; + const { selected } = this.state; + if (selectedUserId === user?.userId && !selected) { + this.setState({ selected: true }); + } else if (selectedUserId !== user?.userId && selected) { + this.setState({ selected: false }); + } + } + + handleScroll() { + this.setState({ + isActionsOpen: false, + showNestedOptions: false, + }); + } + + handleClose() { + this.setState({ selected: null }); + } + + onActionsShow() { + Session.set('dropdownOpen', true); + const { getScrollContainerRef } = this.props; + const dropdown = this.getDropdownMenuParent(); + const scrollContainer = getScrollContainerRef(); + + if (dropdown && scrollContainer) { + // eslint-disable-next-line react/no-find-dom-node + const list = findDOMNode(this.list); + const children = [].slice.call(list.children); + children.find((child) => child.getAttribute('role') === 'menuitem').focus(); + + this.setState({ + isActionsOpen: true, + dropdownVisible: false, + }); + + scrollContainer.addEventListener('scroll', this.handleScroll, false); + } + } + + onActionsHide(callback) { + const { getScrollContainerRef } = this.props; + + this.setState({ + isActionsOpen: false, + dropdownVisible: false, + showNestedOptions: false, + }); + + const scrollContainer = getScrollContainerRef(); + scrollContainer.removeEventListener('scroll', this.handleScroll, false); + + if (callback) { + return callback; + } + + return Session.set('dropdownOpen', false); + } + + getUsersActions() { + const { + intl, + currentUser, + user, + voiceUser, + getAvailableActions, + getGroupChatPrivate, + toggleUserChatLock, + getEmojiList, + setEmojiStatus, + setUserAway, + assignPresenter, + removeUser, + toggleVoice, + changeRole, + ejectUserCameras, + lockSettingsProps, + hasPrivateChatBetweenUsers, + toggleUserLock, + requestUserInformation, + isMeteorConnected, + userLocks, + isMe, + meetingIsBreakout, + usersProp, + layoutContextDispatch, + } = this.props; + const { showNestedOptions } = this.state; + + if (!user) return []; + + const { clientType, isSharingWebcam, pin: userIsPinned } = user; + const isDialInUser = clientType === 'dial-in-user'; + + const amIPresenter = currentUser.presenter; + const amIModerator = currentUser.role === ROLE_MODERATOR; + const actionPermissions = getAvailableActions( + amIModerator, meetingIsBreakout, user, voiceUser, usersProp, amIPresenter, + ); + + const { + allowedToChatPrivately, + allowedToMuteAudio, + allowedToUnmuteAudio, + allowedToResetStatus, + allowedToRemove, + allowedToSetPresenter, + allowedToPromote, + allowedToDemote, + allowedToChangeStatus, + allowedToChangeUserLockStatus, + allowedToChangeWhiteboardAccess, + allowedToEjectCameras, + allowedToSetAway, + } = actionPermissions; + + const { disablePrivateChat } = lockSettingsProps; + + const enablePrivateChat = currentUser.role === ROLE_MODERATOR + ? allowedToChatPrivately + : allowedToChatPrivately + && (!(currentUser.locked && disablePrivateChat) + || hasPrivateChatBetweenUsers(currentUser.userId, user.userId) + || user.role === ROLE_MODERATOR) && isMeteorConnected; + + const { allowUserLookup } = Meteor.settings.public.app; + const userLocked = user.locked && user.role !== ROLE_MODERATOR; + const userChatLocked = user.chatLocked; + + const availableActions = [ + { + allowed: allowedToChangeStatus && !showNestedOptions && isMeteorConnected, + key: 'setstatus', + label: intl.formatMessage(messages.statusTriggerLabel), + onClick: () => this.setState({ showNestedOptions: true }), + icon: 'user', + iconRight: 'right_arrow', + dataTest: 'setStatus', + }, + { + allowed: showNestedOptions && isMeteorConnected && allowedToChangeStatus, + key: 'back', + label: intl.formatMessage(messages.backTriggerLabel), + onClick: () => this.setState({ showNestedOptions: false }), + icon: 'left_arrow', + divider: true, + }, + { + allowed: isSharingWebcam + && isMeteorConnected + && VideoService.isVideoPinEnabledForCurrentUser() + && !showNestedOptions, + key: 'pinVideo', + label: userIsPinned + ? intl.formatMessage(messages.UnpinUserWebcam) + : intl.formatMessage(messages.PinUserWebcam), + onClick: () => { + VideoService.toggleVideoPin(user.userId, userIsPinned); + }, + icon: userIsPinned ? 'pin-video_off' : 'pin-video_on', + }, + { + allowed: isChatEnabled() + && enablePrivateChat + && !isDialInUser + && !meetingIsBreakout + && isMeteorConnected + && !showNestedOptions, + key: 'activeChat', + label: intl.formatMessage(messages.StartPrivateChat), + onClick: () => { + this.handleClose(); + getGroupChatPrivate(currentUser.userId, user); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.CHAT, + }); + layoutContextDispatch({ + type: ACTIONS.SET_ID_CHAT_OPEN, + value: user.userId, + }); + }, + icon: 'chat', + dataTest: 'startPrivateChat', + }, + { + allowed: isChatEnabled() + && user.role !== ROLE_MODERATOR + && currentUser.role === ROLE_MODERATOR + && !isDialInUser + && isMeteorConnected, + key: 'lockChat', + label: userChatLocked ? intl.formatMessage(messages.unlockPublicChat) : intl.formatMessage(messages.lockPublicChat), + onClick: () => { + this.handleClose(); + toggleUserChatLock(user.userId, !userChatLocked); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: true, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: PANELS.CHAT, + }); + }, + icon: userChatLocked ? 'unlock' : 'lock', + dataTest: 'togglePublicChat', + }, + { + allowed: allowedToResetStatus + && user.emoji !== 'none' + && isMeteorConnected + && !showNestedOptions, + key: 'clearStatus', + label: intl.formatMessage(messages.ClearStatusLabel), + onClick: () => { + this.onActionsHide(setEmojiStatus(user.userId, 'none')); + this.handleClose(); + }, + icon: 'clear_status', + }, + { + allowed: allowedToMuteAudio + && isMeteorConnected + && !meetingIsBreakout + && !showNestedOptions, + key: 'mute', + label: intl.formatMessage(messages.MuteUserAudioLabel), + onClick: () => { + this.onActionsHide(toggleVoice(user.userId)); + this.handleClose(); + }, + icon: 'mute', + }, + { + allowed: allowedToUnmuteAudio + && !userLocks.userMic + && isMeteorConnected + && !meetingIsBreakout + && !showNestedOptions, + key: 'unmute', + label: intl.formatMessage(messages.UnmuteUserAudioLabel), + onClick: () => { + this.onActionsHide(toggleVoice(user.userId)); + this.handleClose(); + }, + icon: 'unmute', + dataTest: 'unmuteUser' + }, + { + allowed: allowedToChangeWhiteboardAccess + && !user.presenter + && isMeteorConnected + && !isDialInUser + && !showNestedOptions, + key: 'changeWhiteboardAccess', + label: user.whiteboardAccess + ? intl.formatMessage(messages.removeWhiteboardAccess) + : intl.formatMessage(messages.giveWhiteboardAccess), + onClick: () => { + WhiteboardService.changeWhiteboardAccess(user.userId, !user.whiteboardAccess); + this.handleClose(); + }, + icon: 'pen_tool', + dataTest: 'changeWhiteboardAccess', + }, + { + allowed: allowedToSetPresenter && isMeteorConnected && !isDialInUser && !showNestedOptions, + key: 'setPresenter', + label: isMe(user.userId) + ? intl.formatMessage(messages.takePresenterLabel) + : intl.formatMessage(messages.makePresenterLabel), + onClick: () => { + this.onActionsHide(assignPresenter(user.userId)); + this.handleClose(); + }, + icon: 'presentation', + dataTest: isMe(user.userId) ? 'takePresenter' : 'makePresenter', + }, + { + allowed: allowedToPromote && isMeteorConnected && !showNestedOptions, + key: 'promote', + label: intl.formatMessage(messages.PromoteUserLabel), + onClick: () => { + this.onActionsHide(changeRole(user.userId, 'MODERATOR')); + this.handleClose(); + }, + icon: 'promote', + dataTest: 'promoteToModerator', + }, + { + allowed: allowedToDemote && isMeteorConnected && !showNestedOptions, + key: 'demote', + label: intl.formatMessage(messages.DemoteUserLabel), + onClick: () => { + this.onActionsHide(changeRole(user.userId, 'VIEWER')); + this.handleClose(); + }, + icon: 'user', + dataTest: 'demoteToViewer', + }, + { + allowed: allowedToChangeUserLockStatus && isMeteorConnected && !showNestedOptions, + key: 'unlockUser', + label: userLocked ? intl.formatMessage(messages.UnlockUserLabel, { 0: user.name }) + : intl.formatMessage(messages.LockUserLabel, { 0: user.name }), + onClick: () => { + this.onActionsHide(toggleUserLock(user.userId, !userLocked)); + this.handleClose(); + }, + icon: userLocked ? 'unlock' : 'lock', + dataTest: 'unlockUserButton', + }, + { + allowed: allowUserLookup && isMeteorConnected && !showNestedOptions, + key: 'directoryLookup', + label: intl.formatMessage(messages.DirectoryLookupLabel), + onClick: () => { + this.onActionsHide(requestUserInformation(user.extId)); + this.handleClose(); + }, + icon: 'user', + }, + { + allowed: allowedToRemove && isMeteorConnected && !showNestedOptions, + key: 'remove', + label: intl.formatMessage(messages.RemoveUserLabel, { 0: user.name }), + onClick: () => { + this.onActionsHide( + this.setConfirmationModalIsOpen(true), + ); + + this.handleClose(); + }, + icon: 'circle_close', + dataTest: 'removeUser' + }, + { + allowed: allowedToEjectCameras + && user.isSharingWebcam + && isMeteorConnected + && !meetingIsBreakout + && !showNestedOptions, + key: 'ejectUserCameras', + label: intl.formatMessage(messages.ejectUserCamerasLabel), + onClick: () => { + this.onActionsHide(ejectUserCameras(user.userId)); + this.handleClose(); + }, + icon: 'video_off', + dataTest: 'ejectCamera' + }, + { + allowed: allowedToSetAway + && isMeteorConnected, + key: 'setAway', + label: intl.formatMessage(user.away ? messages.notAwayLabel : messages.awayLabel), + onClick: () => { + this.onActionsHide(setUserAway(user.userId, !user.away)); + this.handleClose(); + }, + icon: 'time', + }, + ]; + + const statuses = Object.keys(getEmojiList); + + statuses.forEach((s) => { + availableActions.push({ + allowed: showNestedOptions && isMeteorConnected, + key: s, + label: intl.formatMessage({ id: `app.actionsBar.emojiMenu.${s}Label` }), + onClick: () => { + setEmojiStatus(user.userId, s); + this.resetMenuState(); + this.handleClose(); + }, + icon: getEmojiList[s], + dataTest: s, + }); + }); + + return availableActions.filter((action) => action.allowed); + } + + getDropdownMenuParent() { + // eslint-disable-next-line react/no-find-dom-node + return findDOMNode(this.dropdown); + } + + resetMenuState() { + return this.setState({ + isActionsOpen: false, + dropdownVisible: false, + showNestedOptions: false, + selected: false, + }); + } + + /** + * Check if the dropdown is visible, if so, check if should be draw on top or bottom direction. + */ + checkDropdownDirection() { + if (this.isDropdownActivedByUser()) { + const nextState = { + dropdownVisible: true, + }; + + this.setState(nextState); + } + } + + /** + * Check if the dropdown is visible and is opened by the user + * + * @return True if is visible and opened by the user + */ + isDropdownActivedByUser() { + const { isActionsOpen, dropdownVisible } = this.state; + + return isActionsOpen && !dropdownVisible; + } + + renderUserAvatar() { + const { + normalizeEmojiName, + user, + userInBreakout, + breakoutSequence, + meetingIsBreakout, + voiceUser, + isReactionsEnabled, + } = this.props; + + const emojiProps = { + size: '1.3rem', + }; + + const userAvatarFiltered = (user.raiseHand === true || user.away === true || user.reaction !== 'none') + ? '' + : user.avatar; + + const emojiIcons = [ + { + id: 'hand', + native: '✋', + }, + { + id: 'clock7', + native: '⏰', + }, + ]; + + const getIconUser = () => { + if (user.raiseHand === true) { + return isReactionsEnabled + ? + : ; + } if (user.away === true) { + return isReactionsEnabled + ? + : ; + } if (user.emoji !== 'none' && user.emoji !== 'notAway') { + return ; + } if (user.reaction !== 'none') { + return user.reaction; + } if (user.name) { + return user.name.toLowerCase().slice(0, 2); + } return '??'; + }; + + const { clientType } = user; + const isVoiceOnly = clientType === 'dial-in-user'; + + const iconUser = getIconUser(); + const iconVoiceOnlyUser = (); + const userIcon = isVoiceOnly ? iconVoiceOnlyUser : iconUser; + + return ( + + { + userInBreakout + && !meetingIsBreakout + ? breakoutSequence : userIcon + } + + ); + } + + setConfirmationModalIsOpen(value) { + this.setState({ + isConfirmationModalOpen: value, + }); + } + + render() { + const { + compact, + user, + intl, + isThisMeetingLocked, + userInBreakout, + userLastBreakout, + isMe, + isRTL, + selectedUserId, + removeUser, + } = this.props; + + const { + isActionsOpen, + selected, + isConfirmationModalOpen + } = this.state; + + if (!user) return ( + + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ ); + + const actions = this.getUsersActions(); + + const you = isMe(user.userId) ? intl.formatMessage(messages.you) : ''; + + const presenter = (user.presenter) + ? intl.formatMessage(messages.presenter) + : ''; + + const userAriaLabel = intl.formatMessage( + messages.userAriaLabel, + { + 0: user.name, + 1: presenter, + 2: you, + 3: user.emoji, + }, + ); + + const userNameSub = []; + + if (user.isSharingWebcam && LABEL.sharingWebcam) { + userNameSub.push( + + { user.pin === true + ? + : } +   + {intl.formatMessage(messages.sharingWebcam)} + , + ); + } + + if (((isThisMeetingLocked && user.locked) || user.chatLocked) && user.role !== ROLE_MODERATOR) { + userNameSub.push( + + +   + {intl.formatMessage(messages.locked)} + , + ); + } + + if (user.role === ROLE_MODERATOR) { + if (LABEL.moderator) userNameSub.push(intl.formatMessage(messages.moderator)); + } + + if (user.mobile) { + if (LABEL.mobile) userNameSub.push(intl.formatMessage(messages.mobile)); + } + + if (user.guest) { + if (LABEL.guest) userNameSub.push(intl.formatMessage(messages.guest)); + } + + if (userInBreakout && userLastBreakout) { + userNameSub.push( + + +   + {userLastBreakout.isDefaultName + ? intl.formatMessage(messages.breakoutRoom, { 0: userLastBreakout.sequence }) + : userLastBreakout.shortName} + , + ); + } + + const innerContents = ( + + + {this.renderUserAvatar()} + + {!compact + ? ( + + + + + {user.name} +   + + + {(isMe(user.userId)) ? `(${intl.formatMessage(messages.you)})` : ''} + + { + userNameSub.length + ? ( + + {userNameSub.reduce((prev, curr) => [prev, ' | ', curr])} + + ) + : null + } + + ) + : null} + + ); + + const contents = !actions.length + ? ( + + {innerContents} + + ) + : ( +
+ {innerContents} +
+ ); + + if (!actions.length) return contents; + + return ( + <> + this.setState({ selected: true }, () => Session.set('dropdownOpenUserId', user.userId))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + this.setState({ selected: true }, () => Session.set('dropdownOpenUserId', user.userId)); + } + }} + role="button" + > + {contents} + + ) + } + actions={actions} + selectedEmoji={user.emoji} + onCloseCallback={() => this.setState({ selected: false }, () => Session.set('dropdownOpenUserId', null))} + open={selectedUserId === user.userId} + /> + {isConfirmationModalOpen ? this.setConfirmationModalIsOpen(false), + priority: "low", + setIsOpen: this.setConfirmationModalIsOpen, + isOpen: isConfirmationModalOpen + }} + /> : null} + + ); + } +} + +UserListItem.propTypes = propTypes; + +export default lockContextContainer(injectIntl(UserListItem)); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx new file mode 100644 index 00000000..63850c30 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import BreakoutService from '/imports/ui/components/breakout-room/service'; +import Auth from '/imports/ui/services/auth'; +import UserListItem from './component'; +import UserListService from '/imports/ui/components/user-list/service'; +import UserReactionService from '/imports/ui/components/user-reaction/service'; +import { layoutDispatch } from '../../../../layout/context'; + +const UserListItemContainer = (props) => { + const layoutContextDispatch = layoutDispatch(); + + const { + toggleVoice, + removeUser, + toggleUserLock, + toggleUserChatLock, + changeRole, + ejectUserCameras, + assignPresenter, + getAvailableActions, + normalizeEmojiName, + getGroupChatPrivate, + hasPrivateChatBetweenUsers, + } = UserListService; + + return ; +}; +const isMe = (intId) => intId === Auth.userID; + +export default withTracker(({ user }) => { + const findUserInBreakout = user ? BreakoutService.getBreakoutUserIsIn(user.userId) : false; + const findUserLastBreakout = user ? BreakoutService.getBreakoutUserWasIn(user.userId, null) : null; + const breakoutSequence = (findUserInBreakout || {}).sequence; + + return { + isMe, + userInBreakout: !!findUserInBreakout, + userLastBreakout: findUserLastBreakout, + breakoutSequence, + isMeteorConnected: Meteor.status().connected, + voiceUser: user ? UserListService.curatedVoiceUser(user.userId) : null, + getEmojiList: UserListService.getEmojiList(), + getEmoji: UserListService.getEmoji(), + usersProp: UserListService.getUsersProp(), + selectedUserId: Session.get('dropdownOpenUserId'), + isReactionsEnabled: UserReactionService.isEnabled(), + }; +})(UserListItemContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js new file mode 100644 index 00000000..aa5fe79e --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js @@ -0,0 +1,189 @@ +import styled from 'styled-components'; +import userAvatar from '/imports/ui/components/user-avatar/component'; + +import { + lgPaddingY, + smPaddingY, + borderSize, + smPaddingX, +} from '/imports/ui/stylesheets/styled-components/general'; +import { + listItemBgHover, + itemFocusBorder, + colorGray, + colorGrayDark, +} from '/imports/ui/stylesheets/styled-components/palette'; + +const UserItemContents = styled.div` + position: static; + padding: .45rem; + width: 100%; + margin-left: .5rem; + + ${({ selected }) => selected && ` + background-color: ${listItemBgHover}; + border-top-left-radius: ${smPaddingY}; + border-bottom-left-radius: ${smPaddingY}; + + &:focus { + box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder}; + } + `} + + ${({ isActionsOpen }) => !isActionsOpen && ` + display: flex; + flex-flow: row; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + cursor: pointer; + + [dir="rtl"] & { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + + &:first-child { + margin-top: 0; + } + + &:hover { + outline: transparent; + outline-style: dotted; + outline-width: ${borderSize}; + background-color: ${listItemBgHover}; + } + + &:active, + &:focus { + outline: transparent; + outline-width: ${borderSize}; + outline-style: solid; + background-color: ${listItemBgHover}; + box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder}; + } + flex-flow: column; + flex-shrink: 0; + `} + + ${({ isActionsOpen }) => isActionsOpen && ` + outline: transparent; + outline-width: ${borderSize}; + outline-style: solid; + background-color: ${listItemBgHover}; + box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder}; + border-top-left-radius: ${smPaddingY}; + border-bottom-left-radius: ${smPaddingY}; + + &:focus { + outline-style: solid; + outline-color: transparent !important; + } + `} +`; + +const SkeletonUserItemContents = styled.div` + position: static; + padding: .45rem; + margin-left: .5rem; + margin-right: .5rem; + width: auto; +`; + +const UserItemInnerContents = styled.div` + flex-grow: 0; + display: flex; + flex-flow: row; + padding: 3px; + + [dir="rtl"] & { + padding: ${lgPaddingY} ${lgPaddingY} ${lgPaddingY} 0; + } +`; + +const UserAvatar = styled.div` + flex: 0 0 2.25rem; +`; + +const UserAvatarComponent = styled(userAvatar)` +${({ hasReaction, emoji }) => hasReaction && !emoji && ` + transition: all .5s ease; + font-size: 1.2rem; +`} +`; + +const NoActionsListItem = styled.div` + margin-left: 0.5rem; + padding: .45rem; + width: 100%; +`; + +const UserName = styled.div` + display: flex; + flex-flow: column; + min-width: 0; + flex-grow: 1; + margin: 0 0 0 ${smPaddingX}; + justify-content: center; + font-size: 90%; + + [dir="rtl"] & { + margin: 0 ${smPaddingX} 0 0; + } +`; + +const UserNameMain = styled.span` + margin: 0; + font-size: 90%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 400; + color: ${colorGrayDark}; + display: flex; + flex-direction: row; + + > span { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + &.animationsEnabled { + transition: all .3s; + }`; + +const UserNameSub = styled.span` + margin: 0; + font-size: 0.75rem; + font-weight: 200; + color: ${colorGray}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + i { + line-height: 0; + font-size: 75%; + } +`; + +const SkeletonWrapper = styled.span` + width: 100%; +`; + +export default { + UserItemContents, + SkeletonUserItemContents, + UserItemInnerContents, + UserAvatar, + NoActionsListItem, + UserName, + UserNameMain, + UserNameSub, + SkeletonWrapper, + UserAvatarComponent, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx new file mode 100644 index 00000000..2ba5eb10 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx @@ -0,0 +1,446 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import LockViewersContainer from '/imports/ui/components/lock-viewers/container'; +import GuestPolicyContainer from '/imports/ui/components/waiting-users/guest-policy/container'; +import CreateBreakoutRoomContainer from '/imports/ui/components/actions-bar/create-breakout-room/container'; +import CaptionsService from '/imports/ui/components/captions/service'; +import WriterMenuContainer from '/imports/ui/components/captions/writer-menu/container'; +import BBBMenu from '/imports/ui/components/common/menu/component'; +import Styled from './styles'; +import { getUserNamesLink } from '/imports/ui/components/user-list/service'; +import Settings from '/imports/ui/services/settings'; +import { isBreakoutRoomsEnabled, isLearningDashboardEnabled } from '/imports/ui/services/features'; +import { uniqueId } from '/imports/utils/string-utils'; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + isMeetingMuted: PropTypes.bool.isRequired, + toggleMuteAllUsers: PropTypes.func.isRequired, + toggleMuteAllUsersExceptPresenter: PropTypes.func.isRequired, + toggleStatus: PropTypes.func.isRequired, + toggleReactions: PropTypes.func.isRequired, + guestPolicy: PropTypes.string.isRequired, + meetingIsBreakout: PropTypes.bool.isRequired, + hasBreakoutRoom: PropTypes.bool.isRequired, + isBreakoutRecordable: PropTypes.bool.isRequired, + dynamicGuestPolicy: PropTypes.bool.isRequired, +}; + +const intlMessages = defineMessages({ + optionsLabel: { + id: 'app.userList.userOptions.manageUsersLabel', + description: 'Manage user label', + }, + clearAllLabel: { + id: 'app.userList.userOptions.clearAllLabel', + description: 'Clear all label', + }, + clearAllDesc: { + id: 'app.userList.userOptions.clearAllDesc', + description: 'Clear all description', + }, + clearAllReactionsLabel: { + id: 'app.userList.userOptions.clearAllReactionsLabel', + description: 'Clear all reactions label', + }, + clearAllReactionsDesc: { + id: 'app.userList.userOptions.clearAllReactionsDesc', + description: 'Clear all reactions description', + }, + muteAllLabel: { + id: 'app.userList.userOptions.muteAllLabel', + description: 'Mute all label', + }, + muteAllDesc: { + id: 'app.userList.userOptions.muteAllDesc', + description: 'Mute all description', + }, + unmuteAllLabel: { + id: 'app.userList.userOptions.unmuteAllLabel', + description: 'Unmute all label', + }, + unmuteAllDesc: { + id: 'app.userList.userOptions.unmuteAllDesc', + description: 'Unmute all desc', + }, + lockViewersLabel: { + id: 'app.userList.userOptions.lockViewersLabel', + description: 'Lock viewers label', + }, + lockViewersDesc: { + id: 'app.userList.userOptions.lockViewersDesc', + description: 'Lock viewers description', + }, + guestPolicyLabel: { + id: 'app.userList.userOptions.guestPolicyLabel', + description: 'Guest policy label', + }, + guestPolicyDesc: { + id: 'app.userList.userOptions.guestPolicyDesc', + description: 'Guest policy description', + }, + muteAllExceptPresenterLabel: { + id: 'app.userList.userOptions.muteAllExceptPresenterLabel', + description: 'Mute all except presenter label', + }, + muteAllExceptPresenterDesc: { + id: 'app.userList.userOptions.muteAllExceptPresenterDesc', + description: 'Mute all except presenter description', + }, + createBreakoutRoom: { + id: 'app.actionsBar.actionsDropdown.createBreakoutRoom', + description: 'Create breakout room option', + }, + createBreakoutRoomDesc: { + id: 'app.actionsBar.actionsDropdown.createBreakoutRoomDesc', + description: 'Description of create breakout room option', + }, + learningDashboardLabel: { + id: 'app.learning-dashboard.label', + description: 'Activity Report label', + }, + learningDashboardDesc: { + id: 'app.learning-dashboard.description', + description: 'Activity Report description', + }, + saveUserNames: { + id: 'app.actionsBar.actionsDropdown.saveUserNames', + description: 'Save user name feature description', + }, + captionsLabel: { + id: 'app.actionsBar.actionsDropdown.captionsLabel', + description: 'Captions menu toggle label', + }, + captionsDesc: { + id: 'app.actionsBar.actionsDropdown.captionsDesc', + description: 'Captions menu toggle description', + }, + savedNamesListTitle: { + id: 'app.userList.userOptions.savedNames.title', + description: '', + }, + sortedFirstNameHeading: { + id: 'app.userList.userOptions.sortedFirstName.heading', + description: '', + }, + sortedLastNameHeading: { + id: 'app.userList.userOptions.sortedLastName.heading', + description: '', + }, + newTab: { + id: 'app.modal.newTab', + description: 'label used in aria description', + }, + invitationLabel: { + id: 'app.userList.userOptions.breakoutRoomInvitationLabel', + description: 'Invitation item', + }, +}); + +const USER_STATUS_ENABLED = Meteor.settings.public.userStatus.enabled; +const USER_REACTION_ENABLED = Meteor.settings.public.userReaction.enabled; + +class UserOptions extends PureComponent { + constructor(props) { + super(props); + + this.clearStatusId = uniqueId('list-item-'); + this.clearReactionId = uniqueId('list-item-'); + this.muteId = uniqueId('list-item-'); + this.muteAllId = uniqueId('list-item-'); + this.lockId = uniqueId('list-item-'); + this.guestPolicyId = uniqueId('list-item-'); + this.createBreakoutId = uniqueId('list-item-'); + this.learningDashboardId = uniqueId('list-item-'); + this.saveUsersNameId = uniqueId('list-item-'); + this.captionsId = uniqueId('list-item-'); + this.state = { + isCreateBreakoutRoomModalOpen: false, + isGuestPolicyModalOpen: false, + isInvitation: false, + isWriterMenuModalOpen: false, + isLockViewersModalOpen: false, + } + + this.handleCreateBreakoutRoomClick = this.handleCreateBreakoutRoomClick.bind(this); + this.onCreateBreakouts = this.onCreateBreakouts.bind(this); + this.onInvitationUsers = this.onInvitationUsers.bind(this); + this.renderMenuItems = this.renderMenuItems.bind(this); + this.onSaveUserNames = this.onSaveUserNames.bind(this); + this.setCreateBreakoutRoomModalIsOpen = this.setCreateBreakoutRoomModalIsOpen.bind(this); + this.setGuestPolicyModalIsOpen = this.setGuestPolicyModalIsOpen.bind(this); + this.setWriterMenuModalIsOpen = this.setWriterMenuModalIsOpen.bind(this); + this.setLockViewersModalIsOpen = this.setLockViewersModalIsOpen.bind(this); + } + + onSaveUserNames() { + const { intl, meetingName } = this.props; + const lang = Settings.application.locale; + const date = new Date(); + + const dateString = lang ? date.toLocaleDateString(lang) : date.toLocaleDateString(); + const timeString = lang ? date.toLocaleTimeString(lang) : date.toLocaleTimeString(); + + getUserNamesLink( + intl.formatMessage(intlMessages.savedNamesListTitle, + { + 0: meetingName, + 1: `${dateString}:${timeString}`, + }), + intl.formatMessage(intlMessages.sortedFirstNameHeading), + intl.formatMessage(intlMessages.sortedLastNameHeading), + ).dispatchEvent(new MouseEvent('click', + { bubbles: true, cancelable: true, view: window })); + } + + onCreateBreakouts() { + return this.handleCreateBreakoutRoomClick(false); + } + + onInvitationUsers() { + return this.handleCreateBreakoutRoomClick(true); + } + + handleCreateBreakoutRoomClick(isInvitation) { + this.setState({isInvitation}) + return this.setCreateBreakoutRoomModalIsOpen(true); + } + + renderMenuItems() { + const { + intl, + isMeetingMuted, + toggleStatus, + toggleReactions, + toggleMuteAllUsers, + toggleMuteAllUsersExceptPresenter, + meetingIsBreakout, + hasBreakoutRoom, + openLearningDashboardUrl, + amIModerator, + isMeteorConnected, + dynamicGuestPolicy, + } = this.props; + + const canCreateBreakout = amIModerator + && !meetingIsBreakout + && !hasBreakoutRoom + && isBreakoutRoomsEnabled(); + + const canInviteUsers = amIModerator + && !meetingIsBreakout + && hasBreakoutRoom + + const { locale } = intl; + + this.menuItems = []; + + if (isMeteorConnected) { + if (!meetingIsBreakout) { + this.menuItems.push({ + key: this.muteAllId, + label: intl.formatMessage(intlMessages[isMeetingMuted ? 'unmuteAllLabel' : 'muteAllLabel']), + description: intl.formatMessage(intlMessages[isMeetingMuted ? 'unmuteAllDesc' : 'muteAllDesc']), + onClick: toggleMuteAllUsers, + icon: isMeetingMuted ? 'unmute' : 'mute', + dataTest: 'muteAll', + }); + + if (!isMeetingMuted) { + this.menuItems.push({ + key: this.muteId, + label: intl.formatMessage(intlMessages.muteAllExceptPresenterLabel), + description: intl.formatMessage(intlMessages.muteAllExceptPresenterDesc), + onClick: toggleMuteAllUsersExceptPresenter, + icon: 'mute', + dataTest: 'muteAllExceptPresenter', + }); + } + + this.menuItems.push({ + key: this.lockId, + label: intl.formatMessage(intlMessages.lockViewersLabel), + description: intl.formatMessage(intlMessages.lockViewersDesc), + onClick: () => this.setLockViewersModalIsOpen(true), + icon: 'lock', + dataTest: 'lockViewersButton', + }); + + if (dynamicGuestPolicy) { + this.menuItems.push({ + key: this.guestPolicyId, + icon: 'user', + label: intl.formatMessage(intlMessages.guestPolicyLabel), + description: intl.formatMessage(intlMessages.guestPolicyDesc), + onClick: () => this.setGuestPolicyModalIsOpen(true), + dataTest: 'guestPolicyLabel', + }); + } + } + + if (amIModerator) { + this.menuItems.push({ + key: this.saveUsersNameId, + label: intl.formatMessage(intlMessages.saveUserNames), + // description: , + onClick: this.onSaveUserNames, + icon: 'download', + dataTest: 'downloadUserNamesList', + divider: !USER_STATUS_ENABLED, + }); + } + + if (USER_STATUS_ENABLED) { + this.menuItems.push({ + key: this.clearStatusId, + label: intl.formatMessage(intlMessages.clearAllLabel), + description: intl.formatMessage(intlMessages.clearAllDesc), + onClick: toggleStatus, + icon: 'clear_status', + divider: true, + }); + } + + if (USER_REACTION_ENABLED) { + this.menuItems.push({ + key: this.clearReactionId, + label: intl.formatMessage(intlMessages.clearAllReactionsLabel), + description: intl.formatMessage(intlMessages.clearAllReactionsDesc), + onClick: toggleReactions, + icon: 'clear_status', + divider: true, + }); + } + + if (canCreateBreakout) { + this.menuItems.push({ + key: this.createBreakoutId, + icon: 'rooms', + label: intl.formatMessage(intlMessages.createBreakoutRoom), + description: intl.formatMessage(intlMessages.createBreakoutRoomDesc), + onClick: this.onCreateBreakouts, + dataTest: 'createBreakoutRooms', + }); + } + + if (canInviteUsers && isMeteorConnected) { + this.menuItems.push({ + key: this.createBreakoutId, + icon: 'rooms', + label: intl.formatMessage(intlMessages.invitationLabel), + onClick: this.onInvitationUsers, + dataTest: 'inviteBreakoutRooms', + }) +} + if (amIModerator && CaptionsService.isCaptionsEnabled()) { + this.menuItems.push({ + icon: 'closed_caption', + label: intl.formatMessage(intlMessages.captionsLabel), + description: intl.formatMessage(intlMessages.captionsDesc), + key: this.captionsId, + onClick: () => { this.setWriterMenuModalIsOpen(true); }, + dataTest: 'writeClosedCaptions', + }); + } + if (amIModerator) { + if (isLearningDashboardEnabled()) { + this.menuItems.push({ + icon: 'multi_whiteboard', + iconRight: 'popout_window', + label: intl.formatMessage(intlMessages.learningDashboardLabel), + description: `${intl.formatMessage(intlMessages.learningDashboardDesc)} ${intl.formatMessage(intlMessages.newTab)}`, + key: this.learningDashboardId, + onClick: () => { openLearningDashboardUrl(locale); }, + dividerTop: true, + dataTest: 'learningDashboard' + }); + } + } + } + + return this.menuItems; + } + + renderModal(isOpen, setIsOpen, priority, Component, otherOptions) { + return isOpen ? setIsOpen(false), + priority, + setIsOpen, + isOpen + }} + /> : null + } + + setCreateBreakoutRoomModalIsOpen(value) { + this.setState({ + isCreateBreakoutRoomModalOpen: value, + }) + } + + setGuestPolicyModalIsOpen(value) { + this.setState({ + isGuestPolicyModalOpen: value, + }) + } + + setWriterMenuModalIsOpen(value) { + this.setState({isWriterMenuModalOpen: value}); + } + + setLockViewersModalIsOpen(value) { + this.setState({isLockViewersModalOpen: value}); + } + + render() { + const { intl, isRTL, isBreakoutRecordable } = this.props; + const { isCreateBreakoutRoomModalOpen, isInvitation, + isGuestPolicyModalOpen, isWriterMenuModalOpen, + isLockViewersModalOpen } = this.state; + + return ( + <> + null} + /> + )} + actions={this.renderMenuItems()} + opts={{ + id: "user-options-dropdown-menu", + keepMounted: true, + transitionDuration: 0, + elevation: 3, + getcontentanchorel: null, + fullwidth: "true", + anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' }, + transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' }, + }} + /> + {this.renderModal(isCreateBreakoutRoomModalOpen, this.setCreateBreakoutRoomModalIsOpen, "medium", + CreateBreakoutRoomContainer, {isBreakoutRecordable, isInvitation, isUpdate: isInvitation})} + {this.renderModal(isGuestPolicyModalOpen, this.setGuestPolicyModalIsOpen, "low", + GuestPolicyContainer)} + {this.renderModal(isWriterMenuModalOpen, this.setWriterMenuModalIsOpen, "low", + WriterMenuContainer)} + {this.renderModal(isLockViewersModalOpen, this.setLockViewersModalIsOpen, "low", + LockViewersContainer)} + + ); + } +} + +UserOptions.propTypes = propTypes; +export default injectIntl(UserOptions); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx new file mode 100644 index 00000000..ec7823e2 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import Auth from '/imports/ui/services/auth'; +import Meetings from '/imports/api/meetings'; +import ActionsBarService from '/imports/ui/components/actions-bar/service'; +import LearningDashboardService from '/imports/ui/components/learning-dashboard/service'; +import UserListService from '/imports/ui/components/user-list/service'; +import WaitingUsersService from '/imports/ui/components/waiting-users/service'; +import logger from '/imports/startup/client/logger'; +import { defineMessages, injectIntl } from 'react-intl'; +import { notify } from '/imports/ui/services/notification'; +import UserOptions from './component'; +import { layoutSelect } from '/imports/ui/components/layout/context'; + +const intlMessages = defineMessages({ + clearStatusMessage: { + id: 'app.userList.content.participants.options.clearedStatus', + description: 'Used in toast notification when emojis have been cleared', + }, + clearReactionsMessage: { + id: 'app.userList.content.participants.options.clearedReactions', + description: 'Used in toast notification when reactions have been cleared', + }, +}); + +const { dynamicGuestPolicy } = Meteor.settings.public.app; + +const meetingMuteDisabledLog = () => logger.info({ + logCode: 'useroptions_unmute_all', + extraInfo: { logType: 'moderator_action' }, +}, 'moderator disabled meeting mute'); + +const UserOptionsContainer = (props) => { + const isRTL = layoutSelect((i) => i.isRTL); + return ( + + ); +}; + +export default injectIntl(withTracker((props) => { + const { + clearAllEmojiStatus, + clearAllReactions, + intl, + isMeetingMuteOnStart, + } = props; + + const toggleReactions = () => { + clearAllReactions(); + + notify( + intl.formatMessage(intlMessages.clearReactionsMessage), 'info', 'clear_status', + ); + }; + + const toggleStatus = () => { + clearAllEmojiStatus(); + + notify( + intl.formatMessage(intlMessages.clearStatusMessage), 'info', 'clear_status', + ); + }; + + const getMeetingName = () => { + const { meetingProp } = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'meetingProp.name': 1 } }); + const { name } = meetingProp; + return name; + }; + return { + toggleMuteAllUsers: () => { + UserListService.muteAllUsers(Auth.userID); + if (isMeetingMuteOnStart) { + return meetingMuteDisabledLog(); + } + return logger.info({ + logCode: 'useroptions_mute_all', + extraInfo: { logType: 'moderator_action' }, + }, 'moderator enabled meeting mute, all users muted'); + }, + toggleMuteAllUsersExceptPresenter: () => { + UserListService.muteAllExceptPresenter(Auth.userID); + if (isMeetingMuteOnStart) { + return meetingMuteDisabledLog(); + } + return logger.info({ + logCode: 'useroptions_mute_all_except_presenter', + extraInfo: { logType: 'moderator_action' }, + }, 'moderator enabled meeting mute, all users muted except presenter'); + }, + toggleStatus, + toggleReactions, + isMeetingMuted: isMeetingMuteOnStart, + amIModerator: ActionsBarService.amIModerator(), + hasBreakoutRoom: UserListService.hasBreakoutRoom(), + isBreakoutRecordable: ActionsBarService.isBreakoutRecordable(), + guestPolicy: WaitingUsersService.getGuestPolicy(), + isMeteorConnected: Meteor.status().connected, + meetingName: getMeetingName(), + openLearningDashboardUrl: LearningDashboardService.openLearningDashboardUrl, + dynamicGuestPolicy, + }; +})(UserOptionsContainer)); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js new file mode 100644 index 00000000..228cefda --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js @@ -0,0 +1,23 @@ +import styled from 'styled-components'; + +import Button from '/imports/ui/components/common/button/component'; +import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography'; + +const OptionsButton = styled(Button)` + border-radius: 50%; + display: block; + padding: 0; + margin: 0 0.25rem; + + span { + padding: inherit; + } + + i { + font-size: ${fontSizeBase} !important; + } +`; + +export default { + OptionsButton, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx new file mode 100644 index 00000000..3b65c43a --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from '/imports/ui/components/common/icon/component'; +import Styled from './styles'; +import { ACTIONS, PANELS } from '../../../layout/enums'; + +const intlMessages = defineMessages({ + pollLabel: { + id: 'app.poll.pollPaneTitle', + description: 'label for user-list poll button', + }, +}); + +const UserPolls = ({ + intl, + isPresenter, + pollIsOpen, + forcePollOpen, + sidebarContentPanel, + layoutContextDispatch, +}) => { + if (!isPresenter) return null; + if (!pollIsOpen && !forcePollOpen) return null; + + const handleClickTogglePoll = () => { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: sidebarContentPanel !== PANELS.POLL, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: sidebarContentPanel === PANELS.POLL + ? PANELS.NONE + : PANELS.POLL, + }); + }; + + return ( + + + + {intl.formatMessage(intlMessages.pollLabel)} + + + + + { + if (e.key === 'Enter') { + handleClickTogglePoll(); + } + }} + > + + {intl.formatMessage(intlMessages.pollLabel)} + + + + + ); +}; + +export default injectIntl(UserPolls); + +UserPolls.propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + isPresenter: PropTypes.bool.isRequired, + pollIsOpen: PropTypes.bool.isRequired, + forcePollOpen: PropTypes.bool.isRequired, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx new file mode 100644 index 00000000..aed0e8fa --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import UserPolls from './component'; +import { layoutSelectInput, layoutDispatch } from '../../../layout/context'; + +const UserPollsContainer = (props) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const { sidebarContentPanel } = sidebarContent; + const layoutContextDispatch = layoutDispatch(); + + return ( + + ); +}; + +export default withTracker(({ isPresenter }) => ({ + pollIsOpen: Session.equals('isPollOpen', true), + forcePollOpen: Session.equals('forcePollOpen', true), +}))(UserPollsContainer); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js new file mode 100644 index 00000000..32685895 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +const ListItem = styled(StyledContent.ListItem)``; + +export default { + Messages, + Container, + SmallTitle, + ScrollableList, + List, + ListItem, +}; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx new file mode 100644 index 00000000..788ad63e --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from '/imports/ui/components/common/icon/component'; +import Styled from './styles'; +import { ACTIONS, PANELS } from '../../../layout/enums'; + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, +}; + +const intlMessages = defineMessages({ + waitingUsersTitle: { + id: 'app.userList.guest.waitingUsersTitle', + description: 'Title for the notes list', + }, + title: { + id: 'app.userList.guest.waitingUsers', + description: 'Title for the waiting users', + }, +}); + +const WaitingUsers = ({ + intl, + pendingUsers, + sidebarContentPanel, + layoutContextDispatch, +}) => { + const toggleWaitingPanel = () => { + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, + value: sidebarContentPanel !== PANELS.WAITING_USERS, + }); + layoutContextDispatch({ + type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, + value: sidebarContentPanel === PANELS.WAITING_USERS + ? PANELS.NONE + : PANELS.WAITING_USERS, + }); + }; + + return ( + + + + {intl.formatMessage(intlMessages.waitingUsersTitle)} + + + + + { + if (e.key === 'Enter') { + toggleWaitingPanel(); + } + }} + > + + {intl.formatMessage(intlMessages.title)} + {pendingUsers.length > 0 && ( + + + {pendingUsers.length} + + + )} + + + + + ); +}; + +WaitingUsers.propTypes = propTypes; + +export default injectIntl(WaitingUsers); diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx new file mode 100644 index 00000000..9c94a9a8 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import WaitingUsers from './component'; +import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context'; + +const WaitingUsersContainer = ({ pendingUsers }) => { + const sidebarContent = layoutSelectInput((i) => i.sidebarContent); + const layoutContextDispatch = layoutDispatch(); + const { sidebarContentPanel } = sidebarContent; + + return ( + + ); +}; + +export default WaitingUsersContainer; diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js new file mode 100644 index 00000000..7bf12a35 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +import Styled from '/imports/ui/components/user-list/styles'; +import StyledContent from '/imports/ui/components/user-list/user-list-content/styles'; + +const Messages = styled(Styled.Messages)``; + +const Container = styled(StyledContent.Container)``; + +const SmallTitle = styled(Styled.SmallTitle)``; + +const ScrollableList = styled(StyledContent.ScrollableList)``; + +const List = styled(StyledContent.List)``; + +const ListItem = styled(StyledContent.ListItem)``; + +const UnreadMessages = styled(StyledContent.UnreadMessages)``; + +const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``; + +export default { + Messages, + Container, + SmallTitle, + ScrollableList, + List, + ListItem, + UnreadMessages, + UnreadMessagesText, +}; diff --git a/src/2.7.12/imports/ui/components/user-reaction/service.js b/src/2.7.12/imports/ui/components/user-reaction/service.js new file mode 100644 index 00000000..f690a5c7 --- /dev/null +++ b/src/2.7.12/imports/ui/components/user-reaction/service.js @@ -0,0 +1,52 @@ +import UserReaction from '/imports/api/user-reaction'; +import Auth from '/imports/ui/services/auth'; +import { makeCall } from '/imports/ui/services/api'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import { isReactionsEnabled } from '/imports/ui/services/features/index'; + +const ENABLED = Meteor.settings.public.userReaction.enabled; + +const isEnabled = () => isReactionsEnabled() && getFromUserSettings('enable-user-reaction', ENABLED); + +const setUserReaction = (reaction) => { + if (isEnabled()) { + makeCall('setUserReaction', reaction); + } +}; + +const getUsersIdFromUserReaction = () => UserReaction.find( + { meetingId: Auth.meetingID }, + { fields: { userId: 1 } }, +).fetch().map((user) => user.userId); + +const getUserReaction = (userId) => { + const reaction = UserReaction.findOne( + { + meetingId: Auth.meetingID, + userId, + }, + { + fields: + { + reaction: 1, + creationDate: 1, + }, + }, + ); + + if (!reaction) { + return { + reaction: 'none', + reactionTime: 0, + }; + } + + return { reaction: reaction.reaction, reactionTime: reaction.creationDate.getTime() }; +}; + +export default { + getUserReaction, + setUserReaction, + getUsersIdFromUserReaction, + isEnabled, +}; diff --git a/src/2.7.12/imports/ui/components/utils/hooks/index.js b/src/2.7.12/imports/ui/components/utils/hooks/index.js new file mode 100644 index 00000000..4e3b0b29 --- /dev/null +++ b/src/2.7.12/imports/ui/components/utils/hooks/index.js @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react'; + +/** + * Custom hook to get previous value. It can be used, + * for example, to get previous props or state. + * @param {*} value + * @returns The previous value. + */ +export const usePreviousValue = (value) => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; + +export default { + usePreviousValue, +}; diff --git a/src/2.7.12/imports/ui/components/video-preview/component.jsx b/src/2.7.12/imports/ui/components/video-preview/component.jsx new file mode 100644 index 00000000..bab7f8e2 --- /dev/null +++ b/src/2.7.12/imports/ui/components/video-preview/component.jsx @@ -0,0 +1,1282 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { + defineMessages, injectIntl, FormattedMessage, +} from 'react-intl'; +import Button from '/imports/ui/components/common/button/component'; +import VirtualBgSelector from '/imports/ui/components/video-preview/virtual-background/component'; +import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service'; +import logger from '/imports/startup/client/logger'; +import browserInfo from '/imports/utils/browserInfo'; +import PreviewService from './service'; +import VideoService from '../video-provider/service'; +import Styled from './styles'; +import deviceInfo from '/imports/utils/deviceInfo'; +import MediaStreamUtils from '/imports/utils/media-stream-utils'; +import { notify } from '/imports/ui/services/notification'; +import { + EFFECT_TYPES, + SHOW_THUMBNAILS, + setSessionVirtualBackgroundInfo, + getSessionVirtualBackgroundInfo, + isVirtualBackgroundSupported, + clearSessionVirtualBackgroundInfo, + getSessionVirtualBackgroundInfoWithDefault, +} from '/imports/ui/services/virtual-background/service'; +import Settings from '/imports/ui/services/settings'; +import { isVirtualBackgroundsEnabled } from '/imports/ui/services/features'; +import Checkbox from '/imports/ui/components/common/checkbox/component'; +import { CustomVirtualBackgroundsContext } from '/imports/ui/components/video-preview/virtual-background/context'; +import Auth from '/imports/ui/services/auth'; +import Users from '/imports/api/users'; + +const VIEW_STATES = { + finding: 'finding', + found: 'found', + error: 'error', +}; + +const ENABLE_CAMERA_BRIGHTNESS = Meteor.settings.public.app.enableCameraBrightness; +const CAMERA_BRIGHTNESS_AVAILABLE = ENABLE_CAMERA_BRIGHTNESS && isVirtualBackgroundSupported(); + +const propTypes = { + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, + closeModal: PropTypes.func.isRequired, + startSharing: PropTypes.func.isRequired, + stopSharing: PropTypes.func.isRequired, + resolve: PropTypes.func, + camCapReached: PropTypes.bool, + hasVideoStream: PropTypes.bool.isRequired, + webcamDeviceId: PropTypes.string, + sharedDevices: PropTypes.arrayOf(PropTypes.string), + cameraAsContent: PropTypes.bool, +}; + +const defaultProps = { + resolve: null, + camCapReached: true, + webcamDeviceId: null, + sharedDevices: [], + cameraAsContent: false, +}; + +const intlMessages = defineMessages({ + webcamEffectsTitle: { + id: 'app.videoPreview.webcamEffectsTitle', + description: 'Title for the video effects modal', + }, + webcamSettingsTitle: { + id: 'app.videoPreview.webcamSettingsTitle', + description: 'Title for the video preview modal', + }, + closeLabel: { + id: 'app.videoPreview.closeLabel', + description: 'Close button label', + }, + webcamPreviewLabel: { + id: 'app.videoPreview.webcamPreviewLabel', + description: 'Webcam preview label', + }, + cameraLabel: { + id: 'app.videoPreview.cameraLabel', + description: 'Camera dropdown label', + }, + qualityLabel: { + id: 'app.videoPreview.profileLabel', + description: 'Quality dropdown label', + }, + low: { + id: 'app.videoPreview.quality.low', + description: 'Low quality option label', + }, + medium: { + id: 'app.videoPreview.quality.medium', + description: 'Medium quality option label', + }, + high: { + id: 'app.videoPreview.quality.high', + description: 'High quality option label', + }, + hd: { + id: 'app.videoPreview.quality.hd', + description: 'High definition option label', + }, + startSharingLabel: { + id: 'app.videoPreview.startSharingLabel', + description: 'Start sharing button label', + }, + stopSharingLabel: { + id: 'app.videoPreview.stopSharingLabel', + description: 'Stop sharing button label', + }, + stopSharingAllLabel: { + id: 'app.videoPreview.stopSharingAllLabel', + description: 'Stop sharing all button label', + }, + sharedCameraLabel: { + id: 'app.videoPreview.sharedCameraLabel', + description: 'Already Shared camera label', + }, + findingWebcamsLabel: { + id: 'app.videoPreview.findingWebcamsLabel', + description: 'Finding webcams label', + }, + webcamOptionLabel: { + id: 'app.videoPreview.webcamOptionLabel', + description: 'Default webcam option label', + }, + webcamNotFoundLabel: { + id: 'app.videoPreview.webcamNotFoundLabel', + description: 'Webcam not found label', + }, + profileNotFoundLabel: { + id: 'app.videoPreview.profileNotFoundLabel', + description: 'Profile not found label', + }, + permissionError: { + id: 'app.video.permissionError', + description: 'Error message for webcam permission', + }, + AbortError: { + id: 'app.video.abortError', + description: 'Some problem occurred which prevented the device from being used', + }, + OverconstrainedError: { + id: 'app.video.overconstrainedError', + description: 'No candidate devices which met the criteria requested', + }, + SecurityError: { + id: 'app.video.securityError', + description: 'Media support is disabled on the Document', + }, + TypeError: { + id: 'app.video.typeError', + description: 'List of constraints specified is empty, or has all constraints set to false', + }, + NotFoundError: { + id: 'app.video.notFoundError', + description: 'error message when can not get webcam video', + }, + NotAllowedError: { + id: 'app.video.notAllowed', + description: 'error message when webcam had permission denied', + }, + NotSupportedError: { + id: 'app.video.notSupportedError', + description: 'error message when origin do not have ssl valid', + }, + NotReadableError: { + id: 'app.video.notReadableError', + description: 'error message When the webcam is being used by other software', + }, + TimeoutError: { + id: 'app.video.timeoutError', + description: 'error message when promise did not return', + }, + iOSError: { + id: 'app.audioModal.iOSBrowser', + description: 'Audio/Video Not supported warning', + }, + iOSErrorDescription: { + id: 'app.audioModal.iOSErrorDescription', + description: 'Audio/Video not supported description', + }, + iOSErrorRecommendation: { + id: 'app.audioModal.iOSErrorRecommendation', + description: 'Audio/Video recommended action', + }, + genericError: { + id: 'app.video.genericError', + description: 'error message for when the webcam sharing fails with unknown error', + }, + camCapReached: { + id: 'app.video.camCapReached', + description: 'message for when the camera cap has been reached', + }, + virtualBgGenericError: { + id: 'app.video.virtualBackground.genericError', + description: 'Failed to apply camera effect', + }, + inactiveError: { + id: 'app.video.inactiveError', + description: 'Camera stopped unexpectedly', + }, + brightness: { + id: 'app.videoPreview.brightness', + description: 'Brightness label', + }, + wholeImageBrightnessLabel: { + id: 'app.videoPreview.wholeImageBrightnessLabel', + description: 'Whole image brightness label', + }, + wholeImageBrightnessDesc: { + id: 'app.videoPreview.wholeImageBrightnessDesc', + description: 'Whole image brightness aria description', + }, + cameraAsContentSettingsTitle: { + id: 'app.videoPreview.cameraAsContentSettingsTitle', + description: 'Title for the video preview modal when sharing camera as content', + }, + sliderDesc: { + id: 'app.videoPreview.sliderDesc', + description: 'Brightness slider aria description', + }, +}); + +class VideoPreview extends Component { + constructor(props) { + super(props); + + const { + webcamDeviceId, + } = props; + + this.handleProceed = this.handleProceed.bind(this); + this.handleStartSharing = this.handleStartSharing.bind(this); + this.handleStopSharing = this.handleStopSharing.bind(this); + this.handleStopSharingAll = this.handleStopSharingAll.bind(this); + this.handleSelectWebcam = this.handleSelectWebcam.bind(this); + this.handleSelectProfile = this.handleSelectProfile.bind(this); + this.handleVirtualBgSelected = this.handleVirtualBgSelected.bind(this); + this.handleLocalStreamInactive = this.handleLocalStreamInactive.bind(this); + this.handleBrightnessAreaChange = this.handleBrightnessAreaChange.bind(this); + this.updateVirtualBackgroundInfo = this.updateVirtualBackgroundInfo.bind(this); + + this._isMounted = false; + + this.state = { + webcamDeviceId, + availableWebcams: null, + selectedProfile: null, + isStartSharingDisabled: true, + viewState: VIEW_STATES.finding, + deviceError: null, + previewError: null, + brightness: 100, + wholeImageBrightness: false, + }; + } + + set currentVideoStream (bbbVideoStream) { + // Stream is being unset - remove gUM revocation handler to avoid false negatives + if (this._currentVideoStream) { + this._currentVideoStream.removeListener('inactive', this.handleLocalStreamInactive); + } + // Set up inactivation handler for the new stream (to, eg, detect gUM revocation) + if (bbbVideoStream) { + bbbVideoStream.once('inactive', this.handleLocalStreamInactive); + } + this._currentVideoStream = bbbVideoStream; + } + + get currentVideoStream () { + return this._currentVideoStream; + } + + componentDidMount() { + const { + webcamDeviceId, + forceOpen, + } = this.props; + const { dispatch, backgrounds } = this.context; + + this._isMounted = true; + + // Set the custom or default virtual background + const webcamBackground = Users.findOne({ + meetingId: Auth.meetingID, + userId: Auth.userID, + }, { + fields: { + webcamBackground: 1, + }, + }); + + const webcamBackgroundURL = webcamBackground?.webcamBackground; + if (webcamBackgroundURL !== '' && !backgrounds.webcamBackgroundURL) { + VirtualBgService.getFileFromUrl(webcamBackgroundURL).then((fetchedWebcamBackground) => { + if (fetchedWebcamBackground) { + const data = URL.createObjectURL(fetchedWebcamBackground); + const uniqueId = 'webcamBackgroundURL'; + const filename = webcamBackgroundURL; + dispatch({ + type: 'update', + background: { + filename, + uniqueId, + data, + lastActivityDate: Date.now(), + custom: true, + sessionOnly: true, + }, + }); + } else { + logger.error('Failed to fetch custom webcam background image. Using fallback image.'); + } + }); + } + + if (deviceInfo.hasMediaDevices) { + navigator.mediaDevices.enumerateDevices().then((devices) => { + VideoService.updateNumberOfDevices(devices); + // Video preview skip is activated, short circuit via a simpler procedure + if (PreviewService.getSkipVideoPreview() && !forceOpen) return this.skipVideoPreview(); + // Late enumerateDevices resolution, stop. + if (!this._isMounted) return; + + let { + webcams, + areLabelled, + areIdentified + } = PreviewService.digestVideoDevices(devices, webcamDeviceId); + + logger.debug({ + logCode: 'video_preview_enumerate_devices', + extraInfo: { + devices, + webcams, + }, + }, `Enumerate devices came back. There are ${devices.length} devices and ${webcams.length} are video inputs`); + + if (webcams.length > 0) { + this.getInitialCameraStream(webcams[0].deviceId) + .then(async () => { + // Late gUM resolve, stop. + if (!this._isMounted) return; + + if (!areLabelled || !areIdentified) { + // If they aren't labelled or have nullish deviceIds, run + // enumeration again and get their full versions + // Why: fingerprinting countermeasures obfuscate those when + // no permission was granted via gUM + try { + const newDevices = await navigator.mediaDevices.enumerateDevices(); + webcams = PreviewService.digestVideoDevices(newDevices, webcamDeviceId).webcams; + } catch (error) { + // Not a critical error beucase it should only affect UI; log it + // and go ahead + logger.error({ + logCode: 'video_preview_enumerate_relabel_failure', + extraInfo: { + errorName: error.name, errorMessage: error.message, + }, + }, 'enumerateDevices for relabelling failed'); + } + } + + this.setState({ + availableWebcams: webcams, + viewState: VIEW_STATES.found, + }); + this.displayPreview(); + }); + } else { + // There were no webcams coming from enumerateDevices. Throw an error. + const noWebcamsError = new Error('NotFoundError'); + this.handleDeviceError('enumerate', noWebcamsError, ': no webcams found'); + } + }).catch((error) => { + // enumerateDevices failed + this.handleDeviceError('enumerate', error, 'enumerating devices'); + }); + } else { + // Top-level navigator.mediaDevices is not supported. + // The session went through the version checking, but somehow ended here. + // Nothing we can do. + const error = new Error('NotSupportedError'); + this.handleDeviceError('mount', error, ': navigator.mediaDevices unavailable'); + } + } + + componentDidUpdate() { + if (this.brightnessMarker) { + const markerStyle = window.getComputedStyle(this.brightnessMarker); + const left = parseFloat(markerStyle.left); + const right = parseFloat(markerStyle.right); + + if (left < 0) { + this.brightnessMarker.style.left = '0px'; + this.brightnessMarker.style.right = 'auto'; + } else if (right < 0) { + this.brightnessMarker.style.right = '0px'; + this.brightnessMarker.style.left = 'auto'; + } + } + } + + componentWillUnmount() { + const { webcamDeviceId } = this.state; + this.terminateCameraStream(this.currentVideoStream, webcamDeviceId); + this.cleanupStreamAndVideo(); + this._isMounted = false; + } + + handleSelectWebcam(event) { + const webcamValue = event.target.value; + + this.getInitialCameraStream(webcamValue).then(() => { + this.displayPreview(); + }); + } + + handleLocalStreamInactive({ id }) { + // id === MediaStream.id + if (this.currentVideoStream + && typeof id === 'string' + && this.currentVideoStream?.mediaStream?.id === id) { + this.setState({ + isStartSharingDisabled: true, + }); + this.handlePreviewError( + 'stream_inactive', + new Error('inactiveError'), + '- preview camera stream inactive', + ); + } + } + + // Resolves into true if the background switch is successful, false otherwise + handleVirtualBgSelected(type, name, customParams) { + if (type !== EFFECT_TYPES.NONE_TYPE || CAMERA_BRIGHTNESS_AVAILABLE) { + return this.startVirtualBackground( + this.currentVideoStream, + type, + name, + customParams, + ).then((switched) => { + if (switched) this.updateVirtualBackgroundInfo(); + return switched; + }); + } + this.stopVirtualBackground(this.currentVideoStream); + this.updateVirtualBackgroundInfo(); + return Promise.resolve(true); + } + + stopVirtualBackground(bbbVideoStream) { + if (bbbVideoStream) { + bbbVideoStream.stopVirtualBackground(); + this.displayPreview(); + } + } + + startVirtualBackground(bbbVideoStream, type, name, customParams) { + this.setState({ isStartSharingDisabled: true }); + + if (bbbVideoStream == null) return Promise.resolve(false); + + return bbbVideoStream.startVirtualBackground(type, name, customParams).then(() => { + this.displayPreview(); + return true; + }).catch((error) => { + this.handleVirtualBgError(error, type, name); + return false; + }).finally(() => { + this.setState({ isStartSharingDisabled: false }); + }); + } + + handleSelectProfile(event) { + const profileValue = event.target.value; + const { webcamDeviceId } = this.state; + + const selectedProfile = PreviewService.getCameraProfile(profileValue); + this.getCameraStream(webcamDeviceId, selectedProfile).then(() => { + this.displayPreview(); + }); + } + + handleStartSharing() { + const { + resolve, + startSharing, + cameraAsContent, + startSharingCameraAsContent, + } = this.props; + const { + webcamDeviceId, + selectedProfile, + brightness, + } = this.state; + + // Only streams that will be shared should be stored in the service. + // If the store call returns false, we're duplicating stuff. So clean this one + // up because it's an impostor. + if (!PreviewService.storeStream(webcamDeviceId, this.currentVideoStream)) { + this.currentVideoStream.stop(); + } + + if ( + this.currentVideoStream.virtualBgService + && brightness === 100 + && this.currentVideoStream.virtualBgType === EFFECT_TYPES.NONE_TYPE + ) { + this.stopVirtualBackground(this.currentVideoStream); + } + + this.cleanupStreamAndVideo(); + + PreviewService.changeProfile(selectedProfile); + PreviewService.changeWebcam(webcamDeviceId); + if (cameraAsContent) { + startSharingCameraAsContent(webcamDeviceId); + } else { + startSharing(webcamDeviceId); + } + if (resolve) resolve(); + } + + handleStopSharing() { + const { resolve, stopSharing, stopSharingCameraAsContent } = this.props; + const { webcamDeviceId } = this.state; + + if (this.isCameraAsContentDevice(webcamDeviceId)) { + stopSharingCameraAsContent(); + } else { + PreviewService.deleteStream(webcamDeviceId); + stopSharing(webcamDeviceId); + this.cleanupStreamAndVideo(); + } + if (resolve) resolve(); + } + + handleStopSharingAll() { + const { resolve, stopSharing } = this.props; + stopSharing(); + if (resolve) resolve(); + } + + handleProceed() { + const { resolve, closeModal, sharedDevices, isVisualEffects } = this.props; + const { webcamDeviceId, brightness } = this.state; + const shared = sharedDevices.includes(webcamDeviceId); + + if ( + (shared || isVisualEffects) + && this.currentVideoStream.virtualBgService + && brightness === 100 + && this.currentVideoStream.virtualBgType === EFFECT_TYPES.NONE_TYPE + ) { + this.stopVirtualBackground(this.currentVideoStream); + } + + this.terminateCameraStream(this.currentVideoStream, webcamDeviceId); + closeModal(); + if (resolve) resolve(); + } + + handlePreviewError(logCode, error, description) { + logger.warn({ + logCode: `video_preview_${logCode}_error`, + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, `Error ${description}`); + this.setState({ + previewError: this.handleGUMError(error), + }); + } + + handleDeviceError(logCode, error, description) { + logger.warn({ + logCode: `video_preview_${logCode}_error`, + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, `Error ${description}`); + this.setState({ + viewState: VIEW_STATES.error, + deviceError: this.handleGUMError(error), + }); + } + + handleGUMError(error) { + const { intl } = this.props; + + logger.error({ + logCode: 'video_preview_gum_failure', + extraInfo: { + errorName: error.name, errorMessage: error.message, + }, + }, 'getUserMedia failed in video-preview'); + + const intlError = intlMessages[error.name] || intlMessages[error.message]; + if (intlError) { + return intl.formatMessage(intlError); + } + + return intl.formatMessage(intlMessages.genericError, + { 0: `${error.name}: ${error.message}` }); + } + + terminateCameraStream(stream, deviceId) { + if (stream) { + // Stream is being destroyed - remove gUM revocation handler to avoid false negatives + stream.removeListener('inactive', this.handleLocalStreamInactive); + PreviewService.terminateCameraStream(stream, deviceId); + } + } + + cleanupStreamAndVideo() { + this.currentVideoStream = null; + if (this.video) this.video.srcObject = null; + } + + handleVirtualBgError(error, type, name) { + const { intl } = this.props; + logger.error({ + logCode: `video_preview_virtualbg_error`, + extraInfo: { + errorName: error.name, + errorMessage: error.message, + virtualBgType: type, + virtualBgName: name, + }, + }, `Failed to toggle virtual background: ${error.message}`); + + notify(intl.formatMessage(intlMessages.virtualBgGenericError), 'error', 'video'); + } + + updateDeviceId (deviceId) { + let actualDeviceId = deviceId; + + if (!actualDeviceId && this.currentVideoStream) { + actualDeviceId = MediaStreamUtils.extractDeviceIdFromStream( + this.currentVideoStream.originalStream, + 'video', + ); + } + + this.setState({ webcamDeviceId: actualDeviceId, }); + } + + getInitialCameraStream(deviceId) { + const { cameraAsContent } = this.props; + const defaultProfile = !cameraAsContent + ? PreviewService.getDefaultProfile() + : PreviewService.getCameraAsContentProfile(); + + return this.getCameraStream(deviceId, defaultProfile); + } + + async startEffects(deviceId) { + // Brightness and backgrounds are independent of each other, + // handle each one separately. + try { + await this.startCameraBrightness(); + } catch (error) { + logger.warn({ + logCode: 'brightness_effect_error', + extraInfo: { + errorName: error.name, + errorMessage: error.message, + }, + }, 'Failed to start brightness effect'); + } + + let type; + let name; + let customParams; + + const { backgrounds } = this.context; + const { webcamBackgroundURL } = backgrounds; + const storedBackgroundInfo = getSessionVirtualBackgroundInfo(deviceId); + + if (storedBackgroundInfo) { + type = storedBackgroundInfo.type; + name = storedBackgroundInfo.name; + customParams = storedBackgroundInfo.customParams; + } else if (webcamBackgroundURL) { + const { data, filename } = webcamBackgroundURL; + type = EFFECT_TYPES.IMAGE_TYPE; + name = filename; + customParams = { file: data }; + } + + if (!type) return Promise.resolve(true); + + try { + return this.handleVirtualBgSelected(type, name, customParams); + } catch (error) { + this.handleVirtualBgError(error, type, name); + clearSessionVirtualBackgroundInfo(deviceId); + throw error; + } + } + + getCameraStream(deviceId, profile) { + const { webcamDeviceId } = this.state; + const { cameraAsContent } = this.props; + + this.setState({ + selectedProfile: profile.id, + isStartSharingDisabled: true, + previewError: undefined, + }); + + this.terminateCameraStream(this.currentVideoStream, webcamDeviceId); + this.cleanupStreamAndVideo(); + + // The return of doGUM is an instance of BBBVideoStream (a thin wrapper over a MediaStream) + return PreviewService.doGUM(deviceId, profile).then((bbbVideoStream) => { + // Late GUM resolve, clean up tracks, stop. + if (!this._isMounted) { + this.terminateCameraStream(bbbVideoStream, deviceId); + this.cleanupStreamAndVideo(); + return Promise.resolve(false); + } + + this.currentVideoStream = bbbVideoStream; + this.updateDeviceId(deviceId); + + if (cameraAsContent) { + this.setState({ + isStartSharingDisabled: false, + }); + + return Promise.resolve(true); + } + + return this.startEffects(deviceId) + .catch((error) => { + if (this.shouldSkipVideoPreview()) { + throw error; + } + }) + .finally(() => { + if (this._isMounted) { + this.setState({ + isStartSharingDisabled: false, + }); + } else { + this.terminateCameraStream(bbbVideoStream, deviceId); + this.cleanupStreamAndVideo(); + } + }); + }).catch((error) => { + // When video preview is set to skip, we need some way to bubble errors + // up to users; so re-throw the error + if (!PreviewService.getSkipVideoPreview()) { + this.handlePreviewError('do_gum_preview', error, 'displaying final selection'); + } else { + throw error; + } + }); + } + + displayPreview() { + if (this.currentVideoStream && this.video) { + this.video.srcObject = this.currentVideoStream.mediaStream; + } + } + + skipVideoPreview() { + this.getInitialCameraStream().then(() => { + this.handleStartSharing(); + }).catch(error => { + this.cleanupStreamAndVideo(); + notify(this.handleGUMError(error), 'error', 'video'); + }); + } + + supportWarning() { + const { intl } = this.props; + + return ( +
+ ! + {intl.formatMessage(intlMessages.iOSError)} + {intl.formatMessage(intlMessages.iOSErrorDescription)} + + {intl.formatMessage(intlMessages.iOSErrorRecommendation)} + +
+ ); + } + + getFallbackLabel(webcam, index) { + const { intl } = this.props; + return `${intl.formatMessage(intlMessages.cameraLabel)} ${index}` + } + + isAlreadyShared (webcamId) { + const { sharedDevices, cameraAsContentDeviceId } = this.props; + + return sharedDevices.includes(webcamId) || webcamId === cameraAsContentDeviceId; + } + + isCameraAsContentDevice (deviceId) { + const { cameraAsContentDeviceId } = this.props; + + return deviceId === cameraAsContentDeviceId; + } + + renderDeviceSelectors() { + const { + intl, + sharedDevices, + isVisualEffects, + cameraAsContent, + } = this.props; + + const { + webcamDeviceId, + availableWebcams, + selectedProfile, + } = this.state; + + const shared = sharedDevices.includes(webcamDeviceId); + const shouldShowVirtualBackgrounds = isVirtualBackgroundsEnabled() && !cameraAsContent; + + if (isVisualEffects) { + return ( + <> + {isVirtualBackgroundsEnabled() && this.renderVirtualBgSelector()} + + ); + } + + return ( + <> + + { cameraAsContent + ? ( + <> + + {intl.formatMessage(intlMessages.cameraLabel)} + + { availableWebcams && availableWebcams.length > 0 + ? ( + + {availableWebcams.map((webcam, index) => ( + + ))} + + ) + : ( + + {intl.formatMessage(intlMessages.webcamNotFoundLabel)} + + ) + } + + ) + : + <> + + {intl.formatMessage(intlMessages.cameraLabel)} + + { availableWebcams && availableWebcams.length > 0 + ? ( + + {availableWebcams.map((webcam, index) => ( + + ))} + + ) + : ( + + {intl.formatMessage(intlMessages.webcamNotFoundLabel)} + + ) + } + { shared + ? ( + + {intl.formatMessage(intlMessages.sharedCameraLabel)} + + ) + : ( + <> + + {intl.formatMessage(intlMessages.qualityLabel)} + + {PreviewService.PREVIEW_CAMERA_PROFILES.length > 0 + ? ( + + {PreviewService.PREVIEW_CAMERA_PROFILES.map((profile) => { + const label = intlMessages[`${profile.id}`] + ? intl.formatMessage(intlMessages[`${profile.id}`]) + : profile.name; + + return ( + + ); + })} + + ) + : ( + + {intl.formatMessage(intlMessages.profileNotFoundLabel)} + + ) + } + + ) + } + {shouldShowVirtualBackgrounds && this.renderVirtualBgSelector()} + + } + + ); + } + + handleBrightnessAreaChange() { + const { wholeImageBrightness } = this.state; + this.currentVideoStream.toggleCameraBrightnessArea(!wholeImageBrightness); + this.setState({ wholeImageBrightness: !wholeImageBrightness }); + } + + renderBrightnessInput() { + const { + cameraAsContent, + } = this.props; + const { + webcamDeviceId, + } = this.state; + if (!ENABLE_CAMERA_BRIGHTNESS) return null; + + const { intl } = this.props; + const { brightness, wholeImageBrightness, isStartSharingDisabled } = this.state; + const shared = this.isAlreadyShared(webcamDeviceId); + + const origin = brightness <= 100 ? 'left' : 'right'; + const offset = origin === 'left' + ? (brightness * 100) / 200 + : ((200 - brightness) * 100) / 200; + + if(cameraAsContent){ return null } + + return ( + <> + + {intl.formatMessage(intlMessages.brightness)} + +
+ + this.brightnessMarker = ref} + style={{ [origin]: `calc(${offset}% - 1rem)` }} + > + {brightness - 100} + + +
+ { + const brightness = e.target.valueAsNumber; + this.currentVideoStream.changeCameraBrightness(brightness); + this.setState({ brightness }); + }} + disabled={!isVirtualBackgroundSupported() || isStartSharingDisabled} + /> +
+ {intl.formatMessage(intlMessages.sliderDesc)} +
+ + {'-100'} + {'0'} + {'100'} + +
+ +
+ + ); + } + + renderVirtualBgSelector() { + const { isVisualEffects } = this.props; + const { isStartSharingDisabled, webcamDeviceId } = this.state; + const initialVirtualBgState = this.currentVideoStream ? { + type: this.currentVideoStream.virtualBgType, + name: this.currentVideoStream.virtualBgName + } : getSessionVirtualBackgroundInfoWithDefault(webcamDeviceId); + + return ( + + ); + } + + renderContent() { + const { + intl, + } = this.props; + + const { + viewState, + deviceError, + previewError, + } = this.state; + + const { animations } = Settings.application; + + switch (viewState) { + case VIEW_STATES.finding: + return ( + + +
+ {intl.formatMessage(intlMessages.findingWebcamsLabel)} + +
+
+
+ ); + case VIEW_STATES.error: + return ( + +
{deviceError}
+
+ ); + case VIEW_STATES.found: + default: + return ( + + + { + previewError + ? ( +
{previewError}
+ ) + : ( + { this.video = ref; }} + autoPlay + playsInline + muted + /> + ) + } +
+ + {this.renderDeviceSelectors()} + {this.renderBrightnessInput()} + +
+ ); + } + } + + getModalTitle() { + const { intl, cameraAsContent } = this.props; + if (cameraAsContent) return intl.formatMessage(intlMessages.cameraAsContentSettingsTitle); + return intl.formatMessage(intlMessages.webcamSettingsTitle); + } + + startCameraBrightness() { + if (CAMERA_BRIGHTNESS_AVAILABLE) { + const setBrightnessInfo = () => { + const stream = this.currentVideoStream || {}; + const service = stream.virtualBgService || {}; + const { brightness = 100, wholeImageBrightness = false } = service; + this.setState({ brightness, wholeImageBrightness }); + }; + + if (!this.currentVideoStream.virtualBgService) { + return this.startVirtualBackground( + this.currentVideoStream, + EFFECT_TYPES.NONE_TYPE, + ).then((switched) => { + if (switched) { + setBrightnessInfo(); + } + }); + } + + setBrightnessInfo(); + } + + return Promise.resolve(true); + } + + updateVirtualBackgroundInfo() { + const { webcamDeviceId } = this.state; + + // Update this session's virtual camera effect information if it's enabled + setSessionVirtualBackgroundInfo( + this.currentVideoStream.virtualBgType, + this.currentVideoStream.virtualBgName, + this.currentVideoStream.customParams, + webcamDeviceId, + ); + } + + renderModalContent() { + const { + intl, + hasVideoStream, + forceOpen, + camCapReached, + isVisualEffects, + } = this.props; + + const { + isStartSharingDisabled, + webcamDeviceId, + deviceError, + previewError, + } = this.state; + const shouldDisableButtons = PreviewService.getSkipVideoPreview() + && !forceOpen + && !(deviceError || previewError); + + const shared = this.isAlreadyShared(webcamDeviceId); + + const { isIe } = browserInfo; + + return ( + <> + {isIe ? ( + + Chrome, + 1: Firefox, + }} + /> + + ) : null} + + {this.renderContent()} + + {!isVisualEffects ? ( + + {hasVideoStream && VideoService.isMultipleCamerasEnabled() + ? ( + +