diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js index d3058051956a6ff1df1d73f18733cc101b8bd23d..5e5d1cfb9d515436a5ff76396518292817c2f04d 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js @@ -13,6 +13,7 @@ import clearPresentationPods from '/imports/api/presentation-pods/server/modifie import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoiceUsers'; import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo'; import clearNote from '/imports/api/note/server/modifiers/clearNote'; +import clearNetworkInformation from '/imports/api/network-information/server/modifiers/clearNetworkInformation'; export default function meetingHasEnded(meetingId) { return Meetings.remove({ meetingId }, () => { @@ -28,6 +29,7 @@ export default function meetingHasEnded(meetingId) { clearVoiceUsers(meetingId); clearUserInfo(meetingId); clearNote(meetingId); + clearNetworkInformation(meetingId); return Logger.info(`Cleared Meetings with id ${meetingId}`); }); diff --git a/bigbluebutton-html5/imports/api/network-information/index.js b/bigbluebutton-html5/imports/api/network-information/index.js new file mode 100644 index 0000000000000000000000000000000000000000..de033433a7584c7145c9778ac205ecc165c91b78 --- /dev/null +++ b/bigbluebutton-html5/imports/api/network-information/index.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +const NetworkInformation = new Mongo.Collection('network-information'); + +if (Meteor.isServer) { + NetworkInformation._ensureIndex({ meetingId: 1 }); +} + +export default NetworkInformation; diff --git a/bigbluebutton-html5/imports/api/network-information/server/index.js b/bigbluebutton-html5/imports/api/network-information/server/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c043da1bfd8f0bb2867c94b40f8dacbf5bf17bf7 --- /dev/null +++ b/bigbluebutton-html5/imports/api/network-information/server/index.js @@ -0,0 +1,2 @@ +import './methods'; +import './publisher'; diff --git a/bigbluebutton-html5/imports/api/network-information/server/methods.js b/bigbluebutton-html5/imports/api/network-information/server/methods.js new file mode 100644 index 0000000000000000000000000000000000000000..6eaf424c0d48693e695690f5330d48ed3cb74300 --- /dev/null +++ b/bigbluebutton-html5/imports/api/network-information/server/methods.js @@ -0,0 +1,6 @@ +import { Meteor } from 'meteor/meteor'; +import userInstabilityDetected from './methods/userInstabilityDetected'; + +Meteor.methods({ + userInstabilityDetected, +}); diff --git a/bigbluebutton-html5/imports/api/network-information/server/methods/userInstabilityDetected.js b/bigbluebutton-html5/imports/api/network-information/server/methods/userInstabilityDetected.js new file mode 100644 index 0000000000000000000000000000000000000000..790058e8e08ebdc9b4ea37c11ac6be2c138cdfd8 --- /dev/null +++ b/bigbluebutton-html5/imports/api/network-information/server/methods/userInstabilityDetected.js @@ -0,0 +1,22 @@ +import { check } from 'meteor/check'; +import NetworkInformation from '/imports/api/network-information'; +import Logger from '/imports/startup/server/logger'; + +export default function userInstabilityDetected(credentials, sender) { + const { meetingId, requesterUserId: receiver } = credentials; + + check(meetingId, String); + check(receiver, String); + check(sender, String); + + const payload = { + time: new Date().getTime(), + meetingId, + receiver, + sender, + }; + + Logger.debug(`Receiver ${receiver} reported a network instability in meeting ${meetingId}`); + + return NetworkInformation.insert(payload); +} diff --git a/bigbluebutton-html5/imports/api/network-information/server/modifiers/clearNetworkInformation.js b/bigbluebutton-html5/imports/api/network-information/server/modifiers/clearNetworkInformation.js new file mode 100644 index 0000000000000000000000000000000000000000..c272d64ab2a27a454d253e03758dd10d50458be0 --- /dev/null +++ b/bigbluebutton-html5/imports/api/network-information/server/modifiers/clearNetworkInformation.js @@ -0,0 +1,14 @@ +import NetworkInformation from '/imports/api/network-information'; +import Logger from '/imports/startup/server/logger'; + +export default function clearNetworkInformation(meetingId) { + if (meetingId) { + return NetworkInformation.remove({ meetingId }, () => { + Logger.info(`Cleared Network Information (${meetingId})`); + }); + } + + return NetworkInformation.remove({}, () => { + Logger.info('Cleared Network Information (all)'); + }); +} diff --git a/bigbluebutton-html5/imports/api/network-information/server/publisher.js b/bigbluebutton-html5/imports/api/network-information/server/publisher.js new file mode 100644 index 0000000000000000000000000000000000000000..0e83b5cb9e987e80eb03f395f64fbf57a6aa4062 --- /dev/null +++ b/bigbluebutton-html5/imports/api/network-information/server/publisher.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import NetworkInformation from '/imports/api/network-information'; + +function networkInformation(credentials) { + const { meetingId } = credentials; + + check(meetingId, String); + + return NetworkInformation.find({ + meetingId, + }); +} + +function publish(...args) { + const boundNetworkInformation = networkInformation.bind(this); + + return boundNetworkInformation(...args); +} + +Meteor.publish('network-information', publish); diff --git a/bigbluebutton-html5/imports/api/users/server/methods.js b/bigbluebutton-html5/imports/api/users/server/methods.js index 5e70dbd470eed2216fe9ce2cb81dec9fe8b3def2..d53ecc255853c48da5c3c9f86dfc92edd601c659 100644 --- a/bigbluebutton-html5/imports/api/users/server/methods.js +++ b/bigbluebutton-html5/imports/api/users/server/methods.js @@ -5,7 +5,9 @@ import assignPresenter from './methods/assignPresenter'; import changeRole from './methods/changeRole'; import removeUser from './methods/removeUser'; import toggleUserLock from './methods/toggleUserLock'; +import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType'; import userActivitySign from './methods/userActivitySign'; +import userChangedSettings from './methods/userChangedSettings'; import userLeftMeeting from './methods/userLeftMeeting'; Meteor.methods({ @@ -15,6 +17,8 @@ Meteor.methods({ removeUser, validateAuthToken, toggleUserLock, + setUserEffectiveConnectionType, userActivitySign, + userChangedSettings, userLeftMeeting, }); diff --git a/bigbluebutton-html5/imports/api/users/server/methods/setUserEffectiveConnectionType.js b/bigbluebutton-html5/imports/api/users/server/methods/setUserEffectiveConnectionType.js new file mode 100644 index 0000000000000000000000000000000000000000..fd6a632d90cfb6425b9d6d661558a94c1f92a79f --- /dev/null +++ b/bigbluebutton-html5/imports/api/users/server/methods/setUserEffectiveConnectionType.js @@ -0,0 +1,29 @@ +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 setEffectiveConnectionType from '../modifiers/setUserEffectiveConnectionType'; + +export default function setUserEffectiveConnectionType(credentials, effectiveConnectionType) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'ChangeUserEffectiveConnectionMsg'; + + const { meetingId, requesterUserId } = credentials; + + check(meetingId, String); + check(requesterUserId, String); + check(effectiveConnectionType, String); + + const payload = { + meetingId, + userId: requesterUserId, + effectiveConnectionType, + }; + + setEffectiveConnectionType(meetingId, requesterUserId, effectiveConnectionType); + + Logger.verbose(`User ${requesterUserId} effective connection updated to ${effectiveConnectionType}`); + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); +} diff --git a/bigbluebutton-html5/imports/api/users/server/methods/userChangedSettings.js b/bigbluebutton-html5/imports/api/users/server/methods/userChangedSettings.js new file mode 100644 index 0000000000000000000000000000000000000000..4eecd37d2aac5c76fd2d5b79ad89094e1f540ca6 --- /dev/null +++ b/bigbluebutton-html5/imports/api/users/server/methods/userChangedSettings.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import RedisPubSub from '/imports/startup/server/redis'; +import setChangedSettings from '../modifiers/setChangedSettings'; + +export default function userChangedSettings(credentials, setting, value) { + const REDIS_CONFIG = Meteor.settings.private.redis; + const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const EVENT_NAME = 'UserChangedSettingsEvtMsg'; + + const { meetingId, requesterUserId } = credentials; + + if (!meetingId || !requesterUserId) return; + + check(meetingId, String); + check(requesterUserId, String); + check(setting, String); + check(value, Match.Any); + + const payload = { + meetingId, + requesterUserId, + setting, + value, + }; + + setChangedSettings(requesterUserId, setting, value); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); +} diff --git a/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js b/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js index d10c706ace352b07f6cd5268b67faed9b512b495..9102145f6e82e6afe68c5cba6e3779b59e188b07 100755 --- a/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js +++ b/bigbluebutton-html5/imports/api/users/server/modifiers/addUser.js @@ -80,6 +80,7 @@ export default function addUser(meetingId, user) { isBreakoutUser: Meeting.meetingProp.isBreakout, parentId: Meeting.breakoutProps.parentId, }, + effectiveConnectionType: null, inactivityCheck: false, responseDelay: 0, loggedOut: false, diff --git a/bigbluebutton-html5/imports/api/users/server/modifiers/setChangedSettings.js b/bigbluebutton-html5/imports/api/users/server/modifiers/setChangedSettings.js new file mode 100644 index 0000000000000000000000000000000000000000..2278151048fd285b6c95125e2a8ae25fa3d359c5 --- /dev/null +++ b/bigbluebutton-html5/imports/api/users/server/modifiers/setChangedSettings.js @@ -0,0 +1,31 @@ +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default function setChangedSettings(userId, setting, value) { + check(userId, String); + check(setting, String); + check(value, Match.Any); + + const selector = { + userId, + }; + + const modifier = { + $set: {}, + }; + + modifier.$set[setting] = value; + + const cb = (err, numChanged) => { + if (err) { + Logger.error(`${err}`); + } + + if (numChanged) { + Logger.info(`Updated setting ${setting} to ${value} for user ${userId}`); + } + }; + + return Users.update(selector, modifier, cb); +} diff --git a/bigbluebutton-html5/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js b/bigbluebutton-html5/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js new file mode 100644 index 0000000000000000000000000000000000000000..94543f5a019bd448a50e357fe928505bcd86fee7 --- /dev/null +++ b/bigbluebutton-html5/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js @@ -0,0 +1,33 @@ +import { check } from 'meteor/check'; +import Users from '/imports/api/users'; +import Logger from '/imports/startup/server/logger'; + +export default 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, + }, + }; + + const cb = (err, numChanged) => { + if (err) { + Logger.error(`Updating user ${userId}: ${err}`); + } + + if (numChanged) { + Logger.info(`Updated user ${userId} effective connection to ${effectiveConnectionType} in meeting ${meetingId}`); + } + }; + + return Users.update(selector, modifier, cb); +} diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx index 9d57d5bc770118eb97bec012357c1563557c215b..66e3814742ad7654890a915fb010fa1eaf07d840 100755 --- a/bigbluebutton-html5/imports/ui/components/app/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx @@ -16,6 +16,7 @@ import AudioContainer from '../audio/container'; import ChatAlertContainer from '../chat/alert/container'; import BannerBarContainer from '/imports/ui/components/banner-bar/container'; import WaitingNotifierContainer from '/imports/ui/components/waiting-users/alert/container'; +import { startBandwidthMonitoring, updateNavigatorConnection } from '/imports/ui/services/network-information/index'; import LockNotifier from '/imports/ui/components/lock-viewers/notify/container'; import { styles } from './styles'; @@ -24,6 +25,7 @@ const MOBILE_MEDIA = 'only screen and (max-width: 40em)'; const APP_CONFIG = Meteor.settings.public.app; const DESKTOP_FONT_SIZE = APP_CONFIG.desktopFontSize; const MOBILE_FONT_SIZE = APP_CONFIG.mobileFontSize; +const ENABLE_NETWORK_INFORMATION = APP_CONFIG.enableNetworkInformation; const intlMessages = defineMessages({ userListLabel: { @@ -110,11 +112,22 @@ class App extends Component { this.handleWindowResize(); window.addEventListener('resize', this.handleWindowResize, false); + if (ENABLE_NETWORK_INFORMATION) { + if (navigator.connection) { + this.handleNetworkConnection(); + navigator.connection.addEventListener('change', this.handleNetworkConnection); + } + + startBandwidthMonitoring(); + } + + logger.info({ logCode: 'app_component_componentdidmount' }, 'Client loaded successfully'); } componentWillUnmount() { window.removeEventListener('resize', this.handleWindowResize, false); + navigator.connection.addEventListener('change', this.handleNetworkConnection, false); } handleWindowResize() { @@ -125,6 +138,10 @@ class App extends Component { this.setState({ enableResize: shouldEnableResize }); } + handleNetworkConnection() { + updateNavigatorConnection(navigator.connection); + } + renderPanel() { const { enableResize } = this.state; const { openPanel } = this.props; diff --git a/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx index 655b53243d91f7d5c858e69c938d82b62fcc1374..7210da50693e7cac87ef924d1fa5aa3c83c83f63 100644 --- a/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/notifications-bar/container.jsx @@ -5,8 +5,10 @@ import { defineMessages, injectIntl } from 'react-intl'; import _ from 'lodash'; import Auth from '/imports/ui/services/auth'; import Meetings from '/imports/api/meetings'; -import NavBarService from '../nav-bar/service'; +import Users from '/imports/api/users'; import BreakoutRemainingTime from '/imports/ui/components/breakout-room/breakout-remaining-time/container'; +import SlowConnection from '/imports/ui/components/slow-connection/component'; +import NavBarService from '../nav-bar/service'; import NotificationsBar from './component'; @@ -19,6 +21,14 @@ const STATUS_FAILED = 'failed'; // failed to connect and waiting to try to reconnect const STATUS_WAITING = 'waiting'; +const METEOR_SETTINGS_APP = Meteor.settings.public.app; + +// https://github.com/bigbluebutton/bigbluebutton/issues/5286#issuecomment-465342716 +const SLOW_CONNECTIONS_TYPES = METEOR_SETTINGS_APP.effectiveConnection; +const ENABLE_NETWORK_INFORMATION = METEOR_SETTINGS_APP.enableNetworkInformation; + +const HELP_LINK = METEOR_SETTINGS_APP.helpLink; + const intlMessages = defineMessages({ failedMessage: { id: 'app.failedMessage', @@ -60,6 +70,14 @@ const intlMessages = defineMessages({ id: 'app.meeting.alertBreakoutEndsUnderOneMinute', description: 'Alert that tells that the breakout end under a minute', }, + slowEffectiveConnectionDetected: { + id: 'app.network.connection.effective.slow', + description: 'Alert for detected slow connections', + }, + slowEffectiveConnectionHelpLink: { + id: 'app.network.connection.effective.slow.help', + description: 'Help link for slow connections', + }, }); const NotificationsBarContainer = (props) => { @@ -104,6 +122,22 @@ export default injectIntl(withTracker(({ intl }) => { const { status, connected, retryTime } = Meteor.status(); const data = {}; + const user = Users.findOne({ userId: Auth.userID }); + + if (user) { + const { effectiveConnectionType } = user; + if (ENABLE_NETWORK_INFORMATION && SLOW_CONNECTIONS_TYPES.includes(effectiveConnectionType)) { + data.message = ( + <SlowConnection effectiveConnectionType={effectiveConnectionType}> + {intl.formatMessage(intlMessages.slowEffectiveConnectionDetected)} + <a href={HELP_LINK} target="_blank" rel="noopener noreferrer"> + {intl.formatMessage(intlMessages.slowEffectiveConnectionHelpLink)} + </a> + </SlowConnection> + ); + } + } + if (!connected) { data.color = 'primary'; switch (status) { diff --git a/bigbluebutton-html5/imports/ui/components/slow-connection/component.jsx b/bigbluebutton-html5/imports/ui/components/slow-connection/component.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9d8c102a12689e5999912fe07d9a1ba63dbd7834 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/slow-connection/component.jsx @@ -0,0 +1,32 @@ +import React, { Fragment } from 'react'; +import cx from 'classnames'; +import { styles } from './styles'; + +const SLOW_CONNECTIONS_TYPES = { + critical: { + level: styles.bad, + bars: styles.oneBar, + }, + danger: { + level: styles.bad, + bars: styles.twoBars, + }, + warning: { + level: styles.warning, + bars: styles.threeBars, + }, +}; + +const SlowConnection = ({ children, effectiveConnectionType, iconOnly }) => ( + <Fragment> + <div className={cx(styles.signalBars, styles.sizingBox, SLOW_CONNECTIONS_TYPES[effectiveConnectionType].level, SLOW_CONNECTIONS_TYPES[effectiveConnectionType].bars)}> + <div className={cx(styles.firstBar, styles.bar)} /> + <div className={cx(styles.secondBar, styles.bar)} /> + <div className={cx(styles.thirdBar, styles.bar)} /> + <div className={cx(styles.fourthBar, styles.bar)} /> + </div> + {!iconOnly ? (<span>{children}</span>) : null} + </Fragment> +); + +export default SlowConnection; diff --git a/bigbluebutton-html5/imports/ui/components/slow-connection/styles.scss b/bigbluebutton-html5/imports/ui/components/slow-connection/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..7360ee18a1d91dad85b0439f684737486a3e29f1 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/slow-connection/styles.scss @@ -0,0 +1,34 @@ +.sizingBox { + width: 27px; + height: 15px; + box-sizing: border-box; +} + +.signalBars { + display: inline-block; +} + +.signalBars .bar { + width: 4px; + margin-left: 1px; + display: inline-block; +} +.signalBars .bar.firstBar { height: 25%; } +.signalBars .bar.secondBar { height: 50%; } +.signalBars .bar.thirdBar { height: 75%; } +.signalBars .bar.fourthBar { height: 100%; } + +.bad .bar { + background-color: #e74c3c; +} +.warning .bar { + background-color: #f1c40f; +} + +.fourBars .bar.fifthBar, +.threeBars .bar.fifthBar, +.threeBars .bar.fourthBar, +.oneBar .bar:not(.firstBar), +.twoBars .bar:not(.firstBar):not(.secondBar) { + background-color: #c3c3c3; +} \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx index 71cca0fdbabba51b9458698b054d007d3da12976..2168cba2ddaa1f5661138a1b3391d5423075f614 100644 --- a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx @@ -16,6 +16,7 @@ const SUBSCRIPTIONS = [ 'users', 'meetings', 'polls', 'presentations', 'slides', 'captions', 'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat', 'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'note', + 'network-information', ]; class Subscriptions extends Component { diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx index a5dd80287e34c1357baef3a51a604432cabb3fa5..3362870d2b3e04f1a85908f5c9e9e56128c959e0 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx @@ -490,6 +490,7 @@ class UserDropdown extends PureComponent { render() { const { compact, + currentUser, user, intl, isMeetingLocked, @@ -508,6 +509,8 @@ class UserDropdown extends PureComponent { const userItemContentsStyle = {}; + const { isModerator } = currentUser; + userItemContentsStyle[styles.dropdown] = true; userItemContentsStyle[styles.userListItem] = !isActionsOpen; userItemContentsStyle[styles.usertListItemWithMenu] = isActionsOpen; @@ -551,7 +554,7 @@ class UserDropdown extends PureComponent { {<UserIcons {...{ user, - compact, + isModerator, }} />} </div> diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/component.jsx index fd945bc2b79cce55c2164ec5bc5fb3ab387de788..ddccfe3773e0b0d33bd46051264857209a50b6fc 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/component.jsx @@ -1,9 +1,16 @@ -import React from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; -import Icon from '/imports/ui/components/icon/component'; +import SlowConnection from '/imports/ui/components/slow-connection/component'; +import Auth from '/imports/ui/services/auth'; import { styles } from './styles'; +const METEOR_SETTINGS_APP = Meteor.settings.public.app; + +const SLOW_CONNECTIONS_TYPES = METEOR_SETTINGS_APP.effectiveConnection; +const ENABLE_NETWORK_INFORMATION = METEOR_SETTINGS_APP.enableNetworkInformation; + const propTypes = { + isModerator: PropTypes.bool.isRequired, user: PropTypes.shape({ name: PropTypes.string.isRequired, isPresenter: PropTypes.bool.isRequired, @@ -11,31 +18,33 @@ const propTypes = { isModerator: PropTypes.bool.isRequired, image: PropTypes.string, }).isRequired, - compact: PropTypes.bool.isRequired, }; const UserIcons = (props) => { const { - user, - compact, + isModerator, + user: { + effectiveConnectionType, + id, + }, } = props; - if (compact || user.isSharingWebcam) { - return null; - } + const showNetworkInformation = ENABLE_NETWORK_INFORMATION + && SLOW_CONNECTIONS_TYPES.includes(effectiveConnectionType) + && (id === Auth.userID || isModerator); return ( <div className={styles.userIcons}> { - user.isSharingWebcam ? + showNetworkInformation ? ( <span className={styles.userIconsContainer}> - <Icon iconName="video" /> + <SlowConnection effectiveConnectionType={effectiveConnectionType} iconOnly /> </span> - : null + ) : null } </div> ); }; UserIcons.propTypes = propTypes; -export default UserIcons; +export default memo(UserIcons); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/styles.scss b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/styles.scss index 523bac770a748dce6ce7688bd36c8d902545f08b..129edb5739a8f686465a3eea8aefd5c1e640003b 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-icons/styles.scss @@ -22,7 +22,7 @@ justify-content: space-between; margin: 0 0 0 var(--sm-padding-x)/2; text-align: right; - font-size: 1rem; + font-size: 1.25rem; flex-shrink: 1; color: var(--user-icons-color); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index 97ed7bd488d7e31b43e8d817e95c7864495eab16..bdc35fa9a2c77cfb2cd4ce8384f54e5ec7a9a4f7 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -7,13 +7,21 @@ import { fetchWebRTCMappedStunTurnServers } from '/imports/utils/fetchStunTurnSe import ReconnectingWebSocket from 'reconnecting-websocket'; import logger from '/imports/startup/client/logger'; import browser from 'browser-detect'; +import { + updateCurrentWebcamsConnection, + getCurrentWebcams, + deleteWebcamConnection, + newWebcamConnection, + updateWebcamStats, +} from '/imports/ui/services/network-information/index'; import { tryGenerateIceCandidates } from '../../../utils/safari-webrtc'; import Auth from '/imports/ui/services/auth'; import VideoService from './service'; import VideoList from './video-list/component'; -// const VIDEO_CONSTRAINTS = Meteor.settings.public.kurento.cameraConstraints; +const APP_CONFIG = Meteor.settings.public.app; +const ENABLE_NETWORK_INFORMATION = APP_CONFIG.enableNetworkInformation; const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles; const intlClientErrors = defineMessages({ @@ -158,6 +166,30 @@ class VideoProvider extends Component { this.visibility.onVisible(this.unpauseViewers); this.visibility.onHidden(this.pauseViewers); + + if (ENABLE_NETWORK_INFORMATION) { + this.currentWebcamsStatsInterval = setInterval(() => { + const currentWebcams = getCurrentWebcams(); + if (!currentWebcams) return; + + const { payload } = currentWebcams; + + payload.forEach((id) => { + const peer = this.webRtcPeers[id]; + + const hasLocalStream = peer && peer.started === true + && peer.peerConnection.getLocalStreams().length > 0; + const hasRemoteStream = peer && peer.started === true + && peer.peerConnection.getRemoteStreams().length > 0; + + if (hasLocalStream) { + this.customGetStats(peer.peerConnection, peer.peerConnection.getLocalStreams()[0].getVideoTracks()[0], (stats => updateWebcamStats(id, stats)), true); + } else if (hasRemoteStream) { + this.customGetStats(peer.peerConnection, peer.peerConnection.getRemoteStreams()[0].getVideoTracks()[0], (stats => updateWebcamStats(id, stats)), true); + } + }); + }, 5000); + } } componentWillUpdate({ users, userId }) { @@ -196,6 +228,8 @@ class VideoProvider extends Component { this.stopWebRTCPeer(id); }); + clearInterval(this.currentWebcamsStatsInterval); + // Close websocket connection to prevent multiple reconnects from happening this.ws.close(); } @@ -441,6 +475,10 @@ class VideoProvider extends Component { webRtcPeer.dispose(); } delete this.webRtcPeers[id]; + if (ENABLE_NETWORK_INFORMATION) { + deleteWebcamConnection(id); + updateCurrentWebcamsConnection(this.webRtcPeers); + } } else { this.logger('warn', 'No WebRTC peer to stop (not an error)', 'video_provider_no_peer_to_destroy', { cameraId: id }); } @@ -527,6 +565,10 @@ class VideoProvider extends Component { if (this.webRtcPeers[id].peerConnection) { this.webRtcPeers[id].peerConnection.oniceconnectionstatechange = this._getOnIceConnectionStateChangeCallback(id); } + if (ENABLE_NETWORK_INFORMATION) { + newWebcamConnection(id); + updateCurrentWebcamsConnection(this.webRtcPeers); + } } } @@ -680,7 +722,7 @@ class VideoProvider extends Component { } } - customGetStats(peer, mediaStreamTrack, callback) { + customGetStats(peer, mediaStreamTrack, callback, monitoring = false) { const { stats } = this.state; const statsState = stats; let promise; @@ -735,6 +777,7 @@ class VideoProvider extends Component { encodeUsagePercent: videoInOrOutbound.encodeUsagePercent, rtt: videoInOrOutbound.rtt, currentDelay: videoInOrOutbound.currentDelay, + pliCount: videoInOrOutbound.pliCount, }; const videoStatsArray = statsState; @@ -742,7 +785,10 @@ class VideoProvider extends Component { while (videoStatsArray.length > 5) { // maximum interval to consider videoStatsArray.shift(); } - this.setState({ stats: videoStatsArray }); + + if (!monitoring) { + this.setState({ stats: videoStatsArray }); + } const firstVideoStats = videoStatsArray[0]; const lastVideoStats = videoStatsArray[videoStatsArray.length - 1]; @@ -767,9 +813,9 @@ class VideoProvider extends Component { let videoBitrate; if (videoStats.packetsReceived > 0) { // Remote video - videoLostPercentage = ((videoStats.packetsLost / ((videoStats.packetsLost + videoStats.packetsReceived) * 100)) || 0).toFixed(1); + videoLostPercentage = ((videoStats.packetsLost / ((videoStats.packetsLost + videoStats.packetsReceived)) * 100) || 0).toFixed(1); videoBitrate = Math.floor(videoKbitsReceivedPerSecond || 0); - videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived) * 100)) || 0).toFixed(1); + videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived)) * 100) || 0).toFixed(1); } else { videoLostPercentage = (((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsSent)) * 100) || 0).toFixed(1); videoBitrate = Math.floor(videoKbitsSentPerSecond || 0); @@ -793,6 +839,7 @@ class VideoProvider extends Component { encodeUsagePercent: videoStats.encodeUsagePercent, rtt: videoStats.rtt, currentDelay: videoStats.currentDelay, + pliCount: videoStats.pliCount, }, }; diff --git a/bigbluebutton-html5/imports/ui/services/network-information/index.js b/bigbluebutton-html5/imports/ui/services/network-information/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b95177c0ac1dca8402e50713abfb5e6e4f19fa8a --- /dev/null +++ b/bigbluebutton-html5/imports/ui/services/network-information/index.js @@ -0,0 +1,231 @@ +import NetworkInformation from '/imports/api/network-information'; +import { makeCall } from '/imports/ui/services/api'; +import Auth from '/imports/ui/services/auth'; +import Users from '/imports/api/users'; +import logger from '/imports/startup/client/logger'; +import _ from 'lodash'; + +const NetworkInformationLocal = new Mongo.Collection(null); + +const NAVIGATOR_CONNECTION = 'NAVIGATOR_CONNECTION'; +const NUMBER_OF_WEBCAMS_CHANGED = 'NUMBER_OF_WEBCAMS_CHANGED'; +const STARTED_WEBCAM_SHARING = 'STARTED_WEBCAM_SHARING'; +const STOPPED_WEBCAM_SHARING = 'STOPPED_WEBCAM_SHARING'; +const WEBCAMS_GET_STATUS = 'WEBCAMS_GET_STATUS'; + +const DANGER_BEGIN_TIME = 5000; +const DANGER_END_TIME = 30000; + +const WARNING_END_TIME = 60000; + +let monitoringIntervalRef; + +export const updateCurrentWebcamsConnection = (connections) => { + const doc = { + timestamp: new Date().getTime(), + event: NUMBER_OF_WEBCAMS_CHANGED, + payload: Object.keys(connections), + }; + + NetworkInformationLocal.insert(doc); +}; + +export const deleteWebcamConnection = (id) => { + const doc = { + timestamp: new Date().getTime(), + event: STOPPED_WEBCAM_SHARING, + payload: { id }, + }; + + NetworkInformationLocal.insert(doc); +}; + +export const getCurrentWebcams = () => NetworkInformationLocal + .findOne({ + event: NUMBER_OF_WEBCAMS_CHANGED, + }, { sort: { timestamp: -1 } }); + +export const newWebcamConnection = (id) => { + const doc = { + timestamp: new Date().getTime(), + event: STARTED_WEBCAM_SHARING, + payload: { id }, + }; + + NetworkInformationLocal.insert(doc); +}; + +export const startBandwidthMonitoring = () => { + monitoringIntervalRef = setInterval(() => { + const monitoringTime = new Date().getTime(); + + const dangerLowerBoundary = monitoringTime - DANGER_BEGIN_TIME; + + const warningLowerBoundary = monitoringTime - DANGER_END_TIME; + const warningUpperBoundary = monitoringTime - WARNING_END_TIME; + + // Remove old documents to reduce the size of the local collection. + NetworkInformationLocal.remove({ + event: WEBCAMS_GET_STATUS, + timestamp: { $lt: warningUpperBoundary }, + }); + + const usersWatchingWebcams = Users.find({ + userId: { $ne: Auth.userID }, + viewParticipantsWebcams: true, + connectionStatus: 'online', + }).map(user => user.userId); + + const warningZone = NetworkInformationLocal + .find({ + event: WEBCAMS_GET_STATUS, + timestamp: { $lte: warningLowerBoundary, $gt: warningUpperBoundary }, + $or: [ + { + 'payload.id': Auth.userID, + 'payload.stats.deltaPliCount': { $gt: 0 }, + }, + { + 'payload.id': { $ne: Auth.userID }, + 'payload.stats.deltaPacketsLost': { $gt: 0 }, + }, + ], + }).count(); + + const warningZoneReceivers = NetworkInformation + .find({ + receiver: { $in: usersWatchingWebcams }, + sender: Auth.userID, + time: { $lte: warningLowerBoundary, $gt: warningUpperBoundary }, + }).count(); + + const dangerZone = _.uniqBy(NetworkInformationLocal + .find({ + event: WEBCAMS_GET_STATUS, + timestamp: { $lt: dangerLowerBoundary, $gte: warningLowerBoundary }, + $or: [ + { + 'payload.id': Auth.userID, + 'payload.stats.deltaPliCount': { $gt: 0 }, + }, + { + 'payload.id': { $ne: Auth.userID }, + 'payload.stats.deltaPacketsLost': { $gt: 0 }, + }, + ], + }).fetch(), 'payload.id').length; + + const dangerZoneReceivers = _.uniqBy(NetworkInformation + .find({ + receiver: { $in: usersWatchingWebcams }, + sender: Auth.userID, + time: { $lt: dangerLowerBoundary, $gte: warningLowerBoundary }, + }).fetch(), 'receiver').length; + + let effectiveType = 'good'; + + if (dangerZone) { + if (!dangerZoneReceivers) { + effectiveType = 'danger'; + } + + if (dangerZoneReceivers === usersWatchingWebcams.length) { + effectiveType = 'danger'; + } + } else if (warningZone) { + if (!warningZoneReceivers) { + effectiveType = 'warning'; + } + + if (warningZoneReceivers === usersWatchingWebcams.length) { + effectiveType = 'warning'; + } + } + + const lastEffectiveConnectionType = Users.findOne({ userId: Auth.userID }); + + if (lastEffectiveConnectionType + && lastEffectiveConnectionType.effectiveConnectionType !== effectiveType) { + logger.info({ logCode: 'user_connection_instability' }, `User ${Auth.userID} effective connection is now ${effectiveType}`); + makeCall('setUserEffectiveConnectionType', effectiveType); + } + }, 5000); +}; + +export const stopBandwidthMonitoring = () => { + clearInterval(monitoringIntervalRef); +}; + +export const updateNavigatorConnection = ({ effectiveType, downlink, rtt }) => { + const doc = { + timestamp: new Date().getTime(), + event: NAVIGATOR_CONNECTION, + payload: { + effectiveType, + downlink, + rtt, + }, + }; + + NetworkInformationLocal.insert(doc); +}; + +export const updateWebcamStats = (id, stats) => { + if (!stats) return; + + const lastStatus = NetworkInformationLocal + .findOne( + { event: WEBCAMS_GET_STATUS, 'payload.id': id }, + { sort: { timestamp: -1 } }, + ); + + const { video } = stats; + + const doc = { + timestamp: new Date().getTime(), + event: WEBCAMS_GET_STATUS, + payload: { id, stats: video }, + }; + + if (lastStatus) { + const { + payload: { + stats: { + packetsLost, + packetsReceived, + packetsSent, + pliCount, + }, + }, + } = lastStatus; + const normalizedVideo = { ...video }; + + normalizedVideo.deltaPacketsLost = video.packetsLost - packetsLost; + normalizedVideo.deltaPacketsReceived = video.packetsReceived - packetsReceived; + normalizedVideo.deltaPacketsSent = video.packetsSent - packetsSent; + normalizedVideo.deltaPliCount = video.pliCount - pliCount; + + doc.payload = { + id, + stats: normalizedVideo, + }; + + if (normalizedVideo.deltaPacketsLost > 0) { + makeCall('userInstabilityDetected', id); + } + } + + NetworkInformationLocal.insert(doc); +}; + +export default { + NetworkInformationLocal, + updateCurrentWebcamsConnection, + deleteWebcamConnection, + getCurrentWebcams, + newWebcamConnection, + startBandwidthMonitoring, + stopBandwidthMonitoring, + updateNavigatorConnection, + updateWebcamStats, +}; diff --git a/bigbluebutton-html5/imports/ui/services/settings/index.js b/bigbluebutton-html5/imports/ui/services/settings/index.js index c6d76c674d8b14386abcee36f681cf26715f6b26..575de766e99d842abe079ce219bb13270621d159 100644 --- a/bigbluebutton-html5/imports/ui/services/settings/index.js +++ b/bigbluebutton-html5/imports/ui/services/settings/index.js @@ -1,5 +1,6 @@ import Storage from '/imports/ui/services/storage/session'; import _ from 'lodash'; +import { makeCall } from '/imports/ui/services/api'; const SETTINGS = [ 'application', @@ -33,9 +34,9 @@ class Settings { }); // Sets default locale to browser locale - defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false || - navigator.language || - defaultValues.application.locale; + defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false + || navigator.language + || defaultValues.application.locale; this.setDefault(defaultValues); } @@ -55,7 +56,15 @@ class Settings { } save() { - Object.keys(this).forEach(k => Storage.setItem(`settings${k}`, this[k].value)); + Object.keys(this).forEach((k) => { + if (k === '_dataSaving') { + const { value: { viewParticipantsWebcams } } = this[k]; + + makeCall('userChangedSettings', 'viewParticipantsWebcams', viewParticipantsWebcams); + } + + Storage.setItem(`settings${k}`, this[k].value); + }); } } diff --git a/bigbluebutton-html5/imports/ui/services/user/mapUser.js b/bigbluebutton-html5/imports/ui/services/user/mapUser.js index 14b554e43fc34f49a44b9a0df0328eeb37506a9a..435e907a41bcbf11ef84a372b840673ca75204e6 100755 --- a/bigbluebutton-html5/imports/ui/services/user/mapUser.js +++ b/bigbluebutton-html5/imports/ui/services/user/mapUser.js @@ -31,6 +31,7 @@ const mapUser = (user) => { isOnline: user.connectionStatus === 'online', clientType: user.clientType, loginTime: user.loginTime, + effectiveConnectionType: user.effectiveConnectionType, externalUserId: user.extId, }; diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 341c5696ce55ba541d5da9063eba95566c7e557a..eb6455e0b8637479a4fbaf6bfb133bafdc2c2cfe 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -19,6 +19,7 @@ public: basename: "/html5client" askForFeedbackOnLogout: false allowUserLookup: false + enableNetworkInformation: false defaultSettings: application: animations: true @@ -83,6 +84,10 @@ public: connectionTimeout: 60000 showHelpButton: true enableExternalVideo: true + effectiveConnection: + - critical + - danger + - warning kurento: wsUrl: HOST chromeDefaultExtensionKey: akgoaoikmbmhcopjgakkcepdgdgkjfbc diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index 235464ea8edcee6a5cc070b10da4c87341dffb9b..3d0281126a466ca9cf25ee4ac51ddd953252615c 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -610,6 +610,8 @@ "app.externalVideo.urlInput": "Add YouTube URL", "app.externalVideo.urlError": "This URL isn't a valid YouTube video", "app.externalVideo.close": "Close", + "app.network.connection.effective.slow": "We're noticing connectivity issues.", + "app.network.connection.effective.slow.help": "How to fix it?", "app.externalVideo.noteLabel": "Note: Shared YouTube videos will not appear in the recording", "app.actionsBar.actionsDropdown.shareExternalVideo": "Share YouTube video", "app.actionsBar.actionsDropdown.stopShareExternalVideo": "Stop sharing YouTube video", diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index ec9a43022fee20313fcff596985053d93539af2c..9906335729dec266c00b5c14b934943659e5407b 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -19,6 +19,7 @@ import '/imports/api/users-settings/server'; import '/imports/api/voice-users/server'; import '/imports/api/whiteboard-multi-user/server'; import '/imports/api/video/server'; +import '/imports/api/network-information/server'; import '/imports/api/users-infos/server'; import '/imports/api/note/server'; import '/imports/api/external-videos/server';