Merge branch 'develop' into analytics_actor_for_meeting_info

This commit is contained in:
Aron Engineer 2021-03-29 19:50:03 +00:00
commit 7c06f3b85e
68 changed files with 1038 additions and 259 deletions

View File

@ -20,9 +20,11 @@ HOW TO WRITE A GOOD PULL REQUEST?
<!-- A brief description of each change being made with this pull request. -->
### Closes Issue(s)
<!-- List here all the issues closed by this pull request. Use keyword `closes` before each issue number
Closes #123456
-->
Closes #
closes #...
<!-- List here all the issues closed by this pull request. Use keyword `closes` before each issue number -->
### Motivation

View File

@ -31,8 +31,8 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
userValidated(u, state)
} else {
if (u.banned) {
failReason = "Ejected user rejoining"
failReasonCode = EjectReasonCode.EJECTED_USER_REJOINING
failReason = "Banned user rejoining"
failReasonCode = EjectReasonCode.BANNED_USER_REJOINING
} else if (u.loggedOut) {
failReason = "User had logged out"
failReasonCode = EjectReasonCode.USER_LOGGED_OUT
@ -77,7 +77,8 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
reasonCode: String,
state: MeetingState2x
): MeetingState2x = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, 0, 0, Option.apply(reason))
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, 0,
0, reasonCode, reason)
outGW.send(event)
// send a system message to force disconnection
@ -88,8 +89,10 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
}
def sendValidateAuthTokenRespMsg(meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String] = None): Unit = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reason)
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long,
reasonCode: String = EjectReasonCode.NOT_EJECT, reason: String = "User not ejected"): Unit = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, registeredOn,
authTokenValidatedOn, reasonCode, reason)
outGW.send(event)
}

View File

@ -300,12 +300,13 @@ object SystemUser {
}
object EjectReasonCode {
val NOT_EJECT = "not_eject_reason"
val DUPLICATE_USER = "duplicate_user_in_meeting_eject_reason"
val PERMISSION_FAILED = "not_enough_permission_eject_reason"
val EJECT_USER = "user_requested_eject_reason"
val SYSTEM_EJECT_USER = "system_requested_eject_reason"
val VALIDATE_TOKEN = "validate_token_failed_eject_reason"
val USER_INACTIVITY = "user_inactivity_eject_reason"
val EJECTED_USER_REJOINING = "ejected_user_rejoining_reason"
val BANNED_USER_REJOINING = "banned_user_rejoining_reason"
val USER_LOGGED_OUT = "user_logged_out_reason"
}

View File

@ -85,11 +85,13 @@ object MsgBuilder {
}
def buildValidateAuthTokenRespMsg(meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String]): BbbCommonEnvCoreMsg = {
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long,
reasonCode: String, reason: String): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(ValidateAuthTokenRespMsg.NAME, routing)
val header = BbbClientMsgHeader(ValidateAuthTokenRespMsg.NAME, meetingId, userId)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reason)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn,
reasonCode, reason)
val event = ValidateAuthTokenRespMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}

View File

@ -6,11 +6,11 @@ import org.bigbluebutton.core.running.OutMsgRouter
object ValidateAuthTokenRespMsgSender {
def send(outGW: OutMsgRouter, meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String]): Unit = {
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long, reasonCode: String, reason: String): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(ValidateAuthTokenRespMsg.NAME, routing)
val header = BbbClientMsgHeader(ValidateAuthTokenRespMsg.NAME, meetingId, userId)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reason)
val body = ValidateAuthTokenRespMsgBody(userId, authToken, valid, waitForApproval, registeredOn, authTokenValidatedOn, reasonCode, reason)
val event = ValidateAuthTokenRespMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)

View File

@ -60,7 +60,7 @@ case class ValidateAuthTokenRespMsg(
body: ValidateAuthTokenRespMsgBody
) extends BbbCoreMsg
case class ValidateAuthTokenRespMsgBody(userId: String, authToken: String, valid: Boolean, waitForApproval: Boolean,
registeredOn: Long, authTokenValidatedOn: Long, reason: Option[String])
registeredOn: Long, authTokenValidatedOn: Long, reasonCode: String, reason: String)
object UserLeftMeetingEvtMsg {
val NAME = "UserLeftMeetingEvtMsg"

View File

@ -96,7 +96,7 @@ source /etc/bigbluebutton/bigbluebutton-release
# Figure out our environment (Debian vs. CentOS)
#
if [ -f /etc/centos-release ]; then
if [ -f /etc/centos-release ] || [ -f /etc/system-release ]; then
DISTRIB_ID=centos
TOMCAT_USER=tomcat
TOMCAT_DIR=/var/lib/$TOMCAT_USER
@ -822,7 +822,7 @@ check_configuration() {
echo
echo "# Warning: No firewall detected. Recommend using setting up a firewall for your server"
echo "#"
echo "# https://docs.bigbluebutton.org/2.2/troubleshooting.html#freeswitch-using-default-stun-server"
echo "# https://docs.bigbluebutton.org/2.2/customize.html#setup-a-firewall"
echo "#"
echo
fi

View File

@ -9,7 +9,7 @@ if [ ! -f /var/tmp/bbb-kms-last-restart.txt ]; then
exit
fi
users=$(mongo --quiet mongodb://127.0.1.1:27017/meteor --eval "db.users.count({connectionStatus: 'online'})")
users=$(mongo --quiet mongodb://127.0.1.1:27017/meteor --eval "db.users.count()")
if [ "$users" -eq 0 ]; then

View File

@ -1,6 +1,8 @@
import { Meteor } from 'meteor/meteor';
import addConnectionStatus from './methods/addConnectionStatus';
import voidConnection from './methods/voidConnection';
Meteor.methods({
addConnectionStatus,
voidConnection,
});

View File

@ -7,5 +7,8 @@ export default function addConnectionStatus(level) {
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
updateConnectionStatus(meetingId, requesterUserId, level);
}

View File

@ -0,0 +1,4 @@
// Round-trip time helper
export default function voidConnection() {
return 0;
}

View File

@ -2,13 +2,25 @@ import ConnectionStatus from '/imports/api/connection-status';
import Logger from '/imports/startup/server/logger';
export default function clearConnectionStatus(meetingId) {
const selector = {};
if (meetingId) {
return ConnectionStatus.remove({ meetingId }, () => {
Logger.info(`Cleared ConnectionStatus (${meetingId})`);
});
selector.meetingId = meetingId;
}
return ConnectionStatus.remove({}, () => {
Logger.info('Cleared ConnectionStatus (all)');
});
try {
const numberAffected = ConnectionStatus.remove(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}`);
}
}

View File

@ -20,18 +20,13 @@ export default function updateConnectionStatus(meetingId, userId, level) {
timestamp,
};
const cb = (err, numChanged) => {
if (err) {
return Logger.error(`Updating connection status: ${err}`);
try {
const { numberAffected } = ConnectionStatus.upsert(selector, modifier);
if (numberAffected) {
Logger.verbose(`Updated connection status userId=${userId} level=${level}`);
}
const { insertedId } = numChanged;
if (insertedId) {
return Logger.info(`Added connection status userId=${userId} level=${level}`);
}
return Logger.verbose(`Update connection status userId=${userId} level=${level}`);
};
return ConnectionStatus.upsert(selector, modifier, cb);
} catch (err) {
Logger.error(`Updating connection status: ${err}`);
}
}

View File

@ -7,14 +7,22 @@ export default async function addBulkGroupChatMsgs(msgs) {
if (!msgs.length) return;
const mappedMsgs = msgs
.map(({ chatId, meetingId, msg }) => ({
_id: new Mongo.ObjectID()._str,
...msg,
meetingId,
chatId,
message: parseMessage(msg.message),
sender: msg.sender.id,
}))
.map(({ chatId, meetingId, msg }) => {
const {
sender,
color,
...restMsg
} = msg;
return {
_id: new Mongo.ObjectID()._str,
...restMsg,
meetingId,
chatId,
message: parseMessage(msg.message),
sender: sender.id,
};
})
.map(el => flat(el, { safe: true }));
try {

View File

@ -28,8 +28,16 @@ export default function addGroupChatMsg(meetingId, chatId, msg) {
message: String,
correlationId: Match.Maybe(String),
});
const {
color,
sender,
...restMsg
} = msg;
const msgDocument = {
...msg,
...restMsg,
sender: sender.id,
meetingId,
chatId,
message: parseMessage(msg.message),

View File

@ -16,12 +16,18 @@ export default function syncMeetingChatMsgs(meetingId, chatId, msgs) {
msgs
.forEach((msg) => {
const {
sender,
color,
...restMsg
} = msg;
const msgToSync = {
...msg,
...restMsg,
meetingId,
chatId,
message: parseMessage(msg.message),
sender: msg.sender.id,
sender: sender.id,
};
const modifier = flat(msgToSync, { safe: true });

View File

@ -28,6 +28,8 @@ import clearRecordMeeting from './clearRecordMeeting';
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 clearWhiteboardMultiUser from '/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser';
import Metrics from '/imports/startup/server/metrics';
@ -62,6 +64,7 @@ export default function meetingHasEnded(meetingId) {
clearAuthTokenValidation(meetingId);
clearWhiteboardMultiUser(meetingId);
clearScreenshare(meetingId);
clearUsersPersistentData(meetingId);
BannedUsers.delete(meetingId);
Metrics.removeMeeting(meetingId);

View File

@ -0,0 +1,9 @@
import { Meteor } from 'meteor/meteor';
const UsersPersistentData = new Mongo.Collection('users-persistent-data');
if (Meteor.isServer) {
UsersPersistentData._ensureIndex({ meetingId: 1, userId: 1 });
}
export default UsersPersistentData;

View File

@ -0,0 +1 @@
import './publishers';

View File

@ -0,0 +1,76 @@
import { check } from 'meteor/check';
import UsersPersistentData from '/imports/api/users-persistent-data';
import Logger from '/imports/startup/server/logger';
export default function addUserPersistentData(user) {
check(user, {
meetingId: String,
sortName: String,
color: String,
mobile: Boolean,
breakoutProps: Object,
inactivityCheck: Boolean,
responseDelay: Number,
loggedOut: Boolean,
intId: String,
extId: String,
name: String,
role: String,
guest: Boolean,
authed: Boolean,
guestStatus: String,
emoji: String,
presenter: Boolean,
locked: Boolean,
avatar: String,
clientType: String,
effectiveConnectionType: null,
});
const {
intId,
extId,
meetingId,
name,
role,
token,
avatar,
guest,
color,
} = user;
const userData = {
userId: intId,
extId,
meetingId,
name,
role,
token,
avatar,
guest,
color,
loggedOut: false,
};
const selector = {
userId: intId,
meetingId,
};
const modifier = {
$set: userData,
};
try {
const { insertedId } = UsersPersistentData.upsert(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}`);
}
}

View File

@ -0,0 +1,26 @@
import Logger from '/imports/startup/server/logger';
import UsersPersistentData from '/imports/api/users-persistent-data/index';
export default function clearUsersPersistentData(meetingId) {
if (meetingId) {
try {
const numberAffected = UsersPersistentData.remove({ 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 = UsersPersistentData.remove({});
if (numberAffected) {
Logger.info('Cleared users persistent data (all)');
}
} catch (err) {
Logger.error(`Error clearing users persistent data (all). ${err}`);
}
}
}

View File

@ -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 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 {
UsersPersistentData.update(selector, modifier);
} catch (err) {
Logger.error(`Adding note to the collection: ${err}`);
}
}

View File

@ -0,0 +1,33 @@
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';
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 = {
fields: {
meetingId: false,
},
};
return UsersPersistentData.find(selector, options);
}
function publishUsersPersistentData(...args) {
const boundUsers = usersPersistentData.bind(this);
return boundUsers(...args);
}
Meteor.publish('users-persistent-data', publishUsersPersistentData);

View File

@ -5,7 +5,7 @@ import Meetings from '/imports/api/meetings';
import VoiceUsers from '/imports/api/voice-users/';
import _ from 'lodash';
import SanitizeHTML from 'sanitize-html';
import addUserPsersistentData from '/imports/api/users-persistent-data/server/modifiers/addUserPersistentData';
import stringHash from 'string-hash';
import flat from 'flat';
@ -58,26 +58,28 @@ export default function addUser(meetingId, userData) {
from a list based on the userId */
const color = COLOR_LIST[stringHash(user.intId) % COLOR_LIST.length];
const modifier = {
$set: Object.assign(
{
meetingId,
sortName: user.name.trim().toLowerCase(),
color,
mobile: false,
breakoutProps: {
isBreakoutUser: Meeting.meetingProp.isBreakout,
parentId: Meeting.breakoutProps.parentId,
},
effectiveConnectionType: null,
inactivityCheck: false,
responseDelay: 0,
loggedOut: false,
const userInfos = Object.assign(
{
meetingId,
sortName: user.name.trim().toLowerCase(),
color,
mobile: false,
breakoutProps: {
isBreakoutUser: Meeting.meetingProp.isBreakout,
parentId: Meeting.breakoutProps.parentId,
},
flat(user),
),
};
effectiveConnectionType: null,
inactivityCheck: false,
responseDelay: 0,
loggedOut: false,
},
flat(user),
);
const modifier = {
$set: userInfos,
};
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
if (user.clientType !== 'dial-in-user' && !VoiceUsers.findOne({ meetingId, intId: userId })) {

View File

@ -2,6 +2,7 @@ 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 stopWatchingExternalVideoSystemCall from '/imports/api/external-videos/server/methods/stopWatchingExternalVideoSystemCall';
import clearUserInfoForRequester from '/imports/api/users-infos/server/modifiers/clearUserInfoForRequester';
import ClientConnections from '/imports/startup/server/ClientConnections';
@ -32,6 +33,7 @@ export default function removeUser(meetingId, userId) {
};
try {
setloggedOutStatus(userId, meetingId, true);
VideoStreams.remove({ meetingId, userId });
const sessionUserId = `${meetingId}-${userId}`;

View File

@ -26,6 +26,7 @@ import RandomUserSelectContainer from '/imports/ui/components/modal/random-user/
import { withDraggableContext } from '../media/webcam-draggable-overlay/context';
import { styles } from './styles';
import { makeCall } from '/imports/ui/services/api';
import ConnectionStatusService from '/imports/ui/components/connection-status/service';
import { NAVBAR_HEIGHT } from '/imports/ui/components/layout/layout-manager';
const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
@ -161,6 +162,8 @@ class App extends Component {
if (isMobileBrowser) makeCall('setMobileUser');
ConnectionStatusService.startRoundTripTime();
logger.info({ logCode: 'app_component_componentdidmount' }, 'Client loaded successfully');
}
@ -226,6 +229,8 @@ class App extends Component {
if (navigator.connection) {
navigator.connection.addEventListener('change', handleNetworkConnection, false);
}
ConnectionStatusService.stopRoundTripTime();
}
handleWindowResize() {

View File

@ -109,6 +109,7 @@ class BreakoutJoinConfirmation extends Component {
}, 'joining breakout room closed audio in the main room');
}
VideoService.storeDeviceIds();
VideoService.exitVideo();
if (UserListService.amIPresenter()) screenshareHasEnded();
if (url === '') {

View File

@ -248,6 +248,7 @@ class BreakoutRoom extends PureComponent {
logCode: 'breakoutroom_join',
extraInfo: { logType: 'user_action' },
}, 'joining breakout room closed audio in the main room');
VideoService.storeDeviceIds();
VideoService.exitVideo();
if (UserListService.amIPresenter()) screenshareHasEnded();
}

View File

@ -11,7 +11,7 @@ const SIZES = [
];
const COLORS = [
'default', 'primary', 'danger', 'success', 'dark',
'default', 'primary', 'danger', 'warning', 'success', 'dark', 'offline',
];
const propTypes = {

View File

@ -18,6 +18,10 @@
--btn-success-bg: var(--color-success);
--btn-success-border: var(--color-success);
--btn-warning-color: var(--color-white);
--btn-warning-bg: var(--color-warning);
--btn-warning-border: var(--color-warning);
--btn-danger-color: var(--color-white);
--btn-danger-bg: var(--color-danger);
--btn-danger-border: var(--color-danger);
@ -26,6 +30,10 @@
--btn-dark-bg: var(--color-gray-dark);
--btn-dark-border: var(--color-danger);
--btn-offline-color: var(--color-white);
--btn-offline-bg: var(--color-offline);
--btn-offline-border: var(--color-offline);
--btn-border-size: var(--border-size);
--btn-border-radius: var(--border-radius);
--btn-font-weight: 600;
@ -258,6 +266,10 @@
@include button-variant(var(--btn-success-color), var(--btn-success-bg), var(--btn-success-border));
}
.warning {
@include button-variant(var(--btn-warning-color), var(--btn-warning-bg), var(--btn-warning-border));
}
.danger {
@include button-variant(var(--btn-danger-color), var(--btn-danger-bg), var(--btn-danger-border));
}
@ -266,6 +278,10 @@
@include button-variant(var(--btn-dark-color), var(--btn-dark-bg), var(--btn-dark-border));
}
.offline {
@include button-variant(var(--btn-offline-color), var(--btn-offline-bg), var(--btn-offline-border));
}
/* Styles
* ==========
*/
@ -288,6 +304,10 @@
@include button-ghost-variant(var(--btn-success-bg), var(--btn-success-color));
}
&.warning {
@include button-ghost-variant(var(--btn-warning-bg), var(--btn-warning-color));
}
&.danger {
@include button-ghost-variant(var(--btn-danger-bg), var(--btn-danger-color));
}
@ -295,6 +315,10 @@
&.dark {
@include button-ghost-variant(var(--btn-dark-bg), var(--btn-dark-color));
}
&.offline {
@include button-ghost-variant(var(--btn-offline-bg), var(--btn-offline-color));
}
}
.circle {

View File

@ -55,11 +55,11 @@ class ChatDropdown extends PureComponent {
}
componentDidUpdate(prevProps, prevState) {
const { timeWindowsValues } = this.props;
const { timeWindowsValues, users } = this.props;
const { isSettingOpen } = this.state;
if (prevState.isSettingOpen !== isSettingOpen) {
this.clipboard = new Clipboard('#clipboardButton', {
text: () => ChatService.exportChat(timeWindowsValues),
text: () => ChatService.exportChat(timeWindowsValues, users),
});
}
}
@ -82,7 +82,7 @@ class ChatDropdown extends PureComponent {
getAvailableActions() {
const {
intl, isMeteorConnected, amIModerator, meetingIsBreakout, meetingName, timeWindowsValues,
intl, isMeteorConnected, amIModerator, meetingIsBreakout, meetingName, timeWindowsValues, users,
} = this.props;
const clearIcon = 'delete';
@ -104,7 +104,7 @@ class ChatDropdown extends PureComponent {
link.setAttribute(
'href',
`data: ${mimeType} ;charset=utf-8,
${encodeURIComponent(ChatService.exportChat(timeWindowsValues))}`,
${encodeURIComponent(ChatService.exportChat(timeWindowsValues, users))}`,
);
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
}}

View File

@ -1,10 +1,16 @@
import React from 'react';
import React, { useContext } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Auth from '/imports/ui/services/auth';
import Meetings from '/imports/api/meetings';
import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
import ChatDropdown from './component';
const ChatDropdownContainer = ({ ...props }) => <ChatDropdown {...props} />;
const ChatDropdownContainer = ({ ...props }) => {
const usingUsersContext = useContext(UsersContext);
const { users } = usingUsersContext;
return <ChatDropdown {...props} users={users} />;
};
export default withTracker(() => {
const getMeetingName = () => {

View File

@ -167,6 +167,11 @@ const ChatContainer = (props) => {
globalAppplyStateToProps = applyPropsToState;
throttledFunc();
ChatService.removePackagedClassAttribute(
["ReactVirtualized__Grid", "ReactVirtualized__Grid__innerScrollContainer"],
"role"
);
return (
<Chat {...{
...props,

View File

@ -286,10 +286,8 @@ class MessageForm extends PureComponent {
id="message-input"
innerRef={(ref) => { this.textarea = ref; return this.textarea; }}
placeholder={intl.formatMessage(messages.inputPlaceholder, { 0: title })}
aria-controls={chatAreaId}
aria-label={intl.formatMessage(messages.inputLabel, { 0: chatTitle })}
aria-invalid={hasErrors ? 'true' : 'false'}
aria-describedby={hasErrors ? 'message-input-error' : null}
autoCorrect="off"
autoComplete="off"
spellCheck="true"
@ -297,7 +295,7 @@ class MessageForm extends PureComponent {
value={message}
onChange={this.handleMessageChange}
onKeyDown={this.handleMessageKeyDown}
async={true}
async
/>
<Button
hideLabel

View File

@ -312,21 +312,28 @@ const htmlDecode = (input) => {
};
// Export the chat as [Hour:Min] user: message
const exportChat = (timeWindowList) => {
const messageList = timeWindowList.reduce( (acc, timeWindow) => [...acc, ...timeWindow.content], []);
messageList.sort((a, b) => a.time - b.time);
const exportChat = (timeWindowList, users) => {
// const messageList = timeWindowList.reduce( (acc, timeWindow) => [...acc, ...timeWindow.content], []);
// messageList.sort((a, b) => a.time - b.time);
return messageList.map(message => {
const date = new Date(message.time);
const hour = date.getHours().toString().padStart(2, 0);
const min = date.getMinutes().toString().padStart(2, 0);
const hourMin = `[${hour}:${min}]`;
const messageList = timeWindowList.reduce((acc, timeWindow) => {
const userName = message.id.endsWith('welcome-msg')
? ''
: `${message.name} :`;
return `${hourMin} ${userName} ${htmlDecode(message.text)}`;
}).join('\n');
const msgs = timeWindow.content.map(message => {
const date = new Date(message.time);
const hour = date.getHours().toString().padStart(2, 0);
const min = date.getMinutes().toString().padStart(2, 0);
const hourMin = `[${hour}:${min}]`;
console.log('message', message);
const userName = message.id.endsWith('welcome-msg')
? ''
: `${users[timeWindow.sender].name} :`;
return `${hourMin} ${userName} ${htmlDecode(message.text)}`;
});
return [...acc, ...msgs];
}, [])
return messageList.join('\n');
}
@ -360,6 +367,17 @@ const getLastMessageTimestampFromChatList = activeChats => activeChats
.map(chatId => getAllMessages(chatId).reduce(maxTimestampReducer, 0))
.reduce(maxNumberReducer, 0);
const removePackagedClassAttribute = (classnames, attribute) => {
classnames.map(c => {
const elements = document.getElementsByClassName(c);
if (elements) {
for (const [k, v] of Object.entries(elements)) {
v.removeAttribute(attribute);
}
}
});
}
export default {
setUserSentMessage,
mapGroupMessage,
@ -385,4 +403,5 @@ export default {
maxTimestampReducer,
getLastMessageTimestampFromChatList,
UnsentMessagesCollection,
removePackagedClassAttribute,
};

View File

@ -40,21 +40,19 @@ export default function TimeWindowChatItemContainer(props) {
key,
timestamp,
content,
color,
} = message;
const messages = content;
const user = users[sender?.id];
const user = users[sender];
const messageKey = key;
return (
<TimeWindowChatItem
{
...{
color: user?.color || color,
color: user?.color,
isModerator: user?.role === ROLE_MODERATOR,
isOnline: !!user,
isOnline: !user?.loggedOut,
avatar: user?.avatar,
name: user?.name || sender?.name,
name: user?.name,
read: message.read,
messages,
isDefaultPoll,

View File

@ -46,11 +46,11 @@ export const ChatContext = createContext();
const generateStateWithNewMessage = (msg, state) => {
const timeWindow = generateTimeWindow(msg.timestamp);
const userId = msg.sender.id;
const userId = msg.sender;
const keyName = userId + '-' + timeWindow;
const msgBuilder = (msg, chat) => {
const msgTimewindow = generateTimeWindow(msg.timestamp);
const key = msg.sender.id + '-' + msgTimewindow;
const key = msg.sender + '-' + msgTimewindow;
const chatIndex = chat?.chatIndexes[key];
const {
_id,
@ -66,7 +66,7 @@ const generateStateWithNewMessage = (msg, state) => {
lastTimestamp: msg.timestamp,
read: msg.chatId === PUBLIC_CHAT_KEY && msg.timestamp <= getLoginTime() ? true : false,
content: [
{ id: msg.id, name: msg.sender.name, text: msg.message, time: msg.timestamp },
{ id: msg.id, text: msg.message, time: msg.timestamp },
],
}
};
@ -109,7 +109,7 @@ const generateStateWithNewMessage = (msg, state) => {
const timewindowIndex = stateMessages.chatIndexes[keyName];
const groupMessage = messageGroups[keyName + '-' + timewindowIndex];
if (!groupMessage || (groupMessage && groupMessage.sender.id !== stateMessages.lastSender.id)) {
if (!groupMessage || (groupMessage && groupMessage.sender !== stateMessages.lastSender)) {
const [tempGroupMessage, sender, newIndex] = msgBuilder(msg, stateMessages);
stateMessages.lastSender = sender;
@ -122,13 +122,13 @@ const generateStateWithNewMessage = (msg, state) => {
messageGroups[key] = tempGroupMessage[key];
const message = tempGroupMessage[key];
const previousMessage = message.timestamp <= getLoginTime();
if (!previousMessage && message.sender.id !== Auth.userID && !message.id.startsWith(SYSTEM_CHAT_TYPE)) {
if (!previousMessage && message.sender !== Auth.userID && !message.id.startsWith(SYSTEM_CHAT_TYPE)) {
stateMessages.unreadTimeWindows.add(key);
}
});
} else {
if (groupMessage) {
if (groupMessage.sender.id === stateMessages.lastSender.id) {
if (groupMessage.sender === stateMessages.lastSender) {
const previousMessage = msg.timestamp <= getLoginTime();
const timeWindowKey = keyName + '-' + stateMessages.chatIndexes[keyName];
messageGroups[timeWindowKey] = {
@ -137,10 +137,10 @@ const generateStateWithNewMessage = (msg, state) => {
read: previousMessage ? true : false,
content: [
...groupMessage.content,
{ id: msg.id, name: groupMessage.sender.name, text: msg.message, time: msg.timestamp }
{ id: msg.id, text: msg.message, time: msg.timestamp }
],
};
if (!previousMessage && groupMessage.sender.id !== Auth.userID) {
if (!previousMessage && groupMessage.sender !== Auth.userID) {
stateMessages.unreadTimeWindows.add(timeWindowKey);
}
}

View File

@ -1,5 +1,6 @@
import { useContext, useEffect } from 'react';
import Users from '/imports/api/users';
import UsersPersistentData from '/imports/api/users-persistent-data';
import { UsersContext, ACTIONS } from './context';
import { ChatContext, ACTIONS as CHAT_ACTIONS } from '../chat-context/context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
@ -8,8 +9,30 @@ const Adapter = () => {
const usingUsersContext = useContext(UsersContext);
const { dispatch } = usingUsersContext;
const usingChatContext = useContext(ChatContext);
const { dispatch: chatDispatch } = usingChatContext;
useEffect(()=> {
const usersPersistentDataCursor = UsersPersistentData.find({}, { sort: { timestamp: 1 } });
usersPersistentDataCursor.observe({
added: (obj) => {
ChatLogger.debug("usersAdapter::observe::added_persistent_user", obj);
dispatch({
type: ACTIONS.ADDED_USER_PERSISTENT_DATA,
value: {
user: obj,
},
});
},
changed: (obj) => {
ChatLogger.debug("usersAdapter::observe::changed_persistent_user", obj);
dispatch({
type: ACTIONS.CHANGED_USER_PERSISTENT_DATA,
value: {
user: obj,
},
});
},
removed: (obj) => {},
});
}, []);
useEffect(() => {
const usersCursor = Users.find({}, { sort: { timestamp: 1 } });
@ -31,14 +54,6 @@ const Adapter = () => {
},
});
},
removed: (obj) => {
dispatch({
type: ACTIONS.REMOVED,
value: {
user: obj,
},
});
},
});
}, []);

View File

@ -9,6 +9,8 @@ export const ACTIONS = {
ADDED: 'added',
CHANGED: 'changed',
REMOVED: 'removed',
ADDED_USER_PERSISTENT_DATA: 'added_user_persistent_data',
CHANGED_USER_PERSISTENT_DATA: 'changed_user_persistent_data',
};
export const UsersContext = createContext();
@ -47,6 +49,37 @@ const reducer = (state, action) => {
return state;
}
//USER PERSISTENT DATA
case ACTIONS.ADDED_USER_PERSISTENT_DATA: {
const { user } = action.value;
if (state[user.userId]) {
return state;
}
const newState = {
...state,
[user.userId]: {
...user,
},
};
return newState;
}
case ACTIONS.CHANGED_USER_PERSISTENT_DATA: {
const { user } = action.value;
const stateUser = state[user.userId];
if (stateUser) {
const newState = {
...state,
[user.userId]: {
...stateUser,
...user,
},
};
return newState;
}
return state;
}
default: {
throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
}

View File

@ -0,0 +1,92 @@
import React, { Component } from 'react';
import { withModalMounter } from '/imports/ui/components/modal/service';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import ConnectionStatusModalContainer from '/imports/ui/components/connection-status/modal/container';
import ConnectionStatusService from '/imports/ui/components/connection-status/service';
import { styles } from './styles';
const intlMessages = defineMessages({
label: {
id: 'app.connection-status.label',
description: 'Connection status button label',
},
description: {
id: 'app.connection-status.description',
description: 'Connection status button description',
},
});
class ConnectionStatusButton extends Component {
shouldComponentUpdate(nextProps) {
const {
connected,
stats,
} = this.props;
const {
connected: nextConnected,
stats: nextStats,
} = nextProps;
// Always re-render when the connection state change
if (connected !== nextConnected) return true;
// Avoid simple re-render case
if (stats === nextStats) return false;
// Avoid updating when component drifts between danger and critical
// since it's the same feedback
if (stats === 'danger' && nextStats === 'critical') return false;
if (stats === 'critical' && nextStats === 'danger') return false;
return true;
}
render() {
const {
intl,
connected,
stats,
mountModal,
} = this.props;
let color;
if (!connected) {
color = 'offline';
} else {
switch (stats) {
case 'warning':
case 'danger':
// We map warning and danger stats into the warning palette color and
// the error notification
color = 'warning';
ConnectionStatusService.notification('warning', intl);
break;
case 'critical':
color = 'danger';
ConnectionStatusService.notification('error', intl);
break;
default:
color = 'success';
}
}
return (
<Button
icon="network"
label={intl.formatMessage(intlMessages.label)}
hideLabel
aria-label={intl.formatMessage(intlMessages.description)}
size="sm"
color={color}
disabled={!connected}
circle
onClick={() => mountModal(<ConnectionStatusModalContainer />)}
data-test="connectionStatusButton"
/>
);
}
}
export default injectIntl(withModalMounter(ConnectionStatusButton));

View File

@ -0,0 +1,16 @@
import React from 'react';
import { Meteor } from 'meteor/meteor';
import { withTracker } from 'meteor/react-meteor-data';
import ConnectionStatusService from '../service';
import ConnectionStatusButtonComponent from './component';
const connectionStatusButtonContainer = props => <ConnectionStatusButtonComponent {...props} />;
export default withTracker(() => {
const { connected } = Meteor.status();
return {
connected,
stats: ConnectionStatusService.getStats(),
};
})(connectionStatusButtonContainer);

View File

@ -0,0 +1,17 @@
.btn {
margin: 0;
span {
border: none;
box-shadow: none;
}
&:hover,
&:focus {
span {
background-color: transparent !important;
color: var(--color-white) !important;
opacity: .75;
}
}
}

View File

@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
import cx from 'classnames';
import UserAvatar from '/imports/ui/components/user-avatar/component';
import SlowConnection from '/imports/ui/components/slow-connection/component';
import Switch from '/imports/ui/components/switch/component';
import Service from '../service';
import Modal from '/imports/ui/components/modal/simple/component';
import { styles } from './styles';
const STATS = Meteor.settings.public.stats;
const intlMessages = defineMessages({
ariaTitle: {
id: 'app.connection-status.ariaTitle',
@ -30,9 +30,17 @@ const intlMessages = defineMessages({
id: 'app.connection-status.more',
description: 'More about conectivity issues',
},
offline: {
id: 'app.connection-status.offline',
description: 'Offline user',
dataSaving: {
id: 'app.settings.dataSavingTab.description',
description: 'Description of data saving',
},
webcam: {
id: 'app.settings.dataSavingTab.webcam',
description: 'Webcam data saving switch',
},
screenshare: {
id: 'app.settings.dataSavingTab.screenShare',
description: 'Screenshare data saving switch',
},
});
@ -43,7 +51,33 @@ const propTypes = {
}).isRequired,
};
const isConnectionStatusEmpty = (connectionStatus) => {
// Check if it's defined
if (!connectionStatus) return true;
// Check if it's an array
if (!Array.isArray(connectionStatus)) return true;
// Check if is empty
if (connectionStatus.length === 0) return true;
return false;
};
class ConnectionStatusComponent extends PureComponent {
constructor(props) {
super(props);
this.help = Service.getHelp();
this.state = { dataSaving: props.dataSaving };
}
handleDataSavingChange(key) {
const { dataSaving } = this.state;
dataSaving[key] = !dataSaving[key];
this.setState(dataSaving);
}
renderEmpty() {
const { intl } = this.props;
@ -66,15 +100,12 @@ class ConnectionStatusComponent extends PureComponent {
intl,
} = this.props;
if (connectionStatus.length === 0) return this.renderEmpty();
if (isConnectionStatusEmpty(connectionStatus)) return this.renderEmpty();
return connectionStatus.map((conn, index) => {
const dateTime = new Date(conn.timestamp);
const itemStyle = {};
itemStyle[styles.even] = index % 2 === 0;
const textStyle = {};
textStyle[styles.offline] = conn.offline;
itemStyle[styles.even] = (index + 1) % 2 === 0;
return (
<div
@ -95,9 +126,8 @@ class ConnectionStatusComponent extends PureComponent {
</div>
<div className={styles.name}>
<div className={cx(styles.text, textStyle)}>
<div className={styles.text}>
{conn.name}
{conn.offline ? ` (${intl.formatMessage(intlMessages.offline)})` : null}
</div>
</div>
<div className={styles.status}>
@ -116,19 +146,63 @@ class ConnectionStatusComponent extends PureComponent {
});
}
renderDataSaving() {
const {
intl,
dataSaving,
} = this.props;
const {
viewParticipantsWebcams,
viewScreenshare,
} = dataSaving;
return (
<div className={styles.dataSaving}>
<div className={styles.description}>
{intl.formatMessage(intlMessages.dataSaving)}
</div>
<div className={styles.saving}>
<label className={styles.label}>
{intl.formatMessage(intlMessages.webcam)}
</label>
<Switch
icons={false}
defaultChecked={viewParticipantsWebcams}
onChange={() => this.handleDataSavingChange('viewParticipantsWebcams')}
ariaLabelledBy="webcam"
ariaLabel={intl.formatMessage(intlMessages.webcam)}
/>
</div>
<div className={styles.saving}>
<label className={styles.label}>
{intl.formatMessage(intlMessages.screenshare)}
</label>
<Switch
icons={false}
defaultChecked={viewScreenshare}
onChange={() => this.handleDataSavingChange('viewScreenshare')}
ariaLabelledBy="screenshare"
ariaLabel={intl.formatMessage(intlMessages.screenshare)}
/>
</div>
</div>
);
}
render() {
const {
closeModal,
intl,
} = this.props;
const isValidUrl = new RegExp(/^(http|https):\/\/[^ "]+$/).test(STATS.help);
const { dataSaving } = this.state;
return (
<Modal
overlayClassName={styles.overlay}
className={styles.modal}
onRequestClose={closeModal}
onRequestClose={() => closeModal(dataSaving, intl)}
hideBorder
contentLabel={intl.formatMessage(intlMessages.ariaTitle)}
>
@ -140,14 +214,15 @@ class ConnectionStatusComponent extends PureComponent {
</div>
<div className={styles.description}>
{intl.formatMessage(intlMessages.description)}{' '}
{isValidUrl
{this.help
&& (
<a href={STATS.help} target="_blank" rel="noopener noreferrer">
<a href={this.help} target="_blank" rel="noopener noreferrer">
{`(${intl.formatMessage(intlMessages.more)})`}
</a>
)
}
</div>
{this.renderDataSaving()}
<div className={styles.content}>
<div className={styles.wrapper}>
{this.renderConnections()}

View File

@ -1,12 +1,18 @@
import React from 'react';
import _ from 'lodash';
import { withTracker } from 'meteor/react-meteor-data';
import { withModalMounter } from '/imports/ui/components/modal/service';
import Settings from '/imports/ui/services/settings';
import ConnectionStatusService from '../service';
import ConnectionStatusComponent from './component';
const connectionStatusContainer = props => <ConnectionStatusComponent {...props} />;
export default withModalMounter(withTracker(({ mountModal }) => ({
closeModal: () => mountModal(null),
closeModal: (dataSaving, intl) => {
ConnectionStatusService.updateDataSavingSettings(dataSaving, intl);
mountModal(null);
},
connectionStatus: ConnectionStatusService.getConnectionStatus(),
dataSaving: _.clone(Settings.dataSaving),
}))(connectionStatusContainer));

View File

@ -1,5 +1,13 @@
@import '/imports/ui/stylesheets/mixins/focus';
@import '/imports/ui/stylesheets/variables/_all';
@import "/imports/ui/components/modal/simple/styles";
:root {
--modal-margin: 3rem;
--title-position-left: 2.2rem;
--closeBtn-position-left: 2.5rem;
}
.title {
left: var(--title-position-left);
right: auto;
@ -57,7 +65,7 @@
.wrapper {
display: block;
width: 100%;
max-height: 24rem;
max-height: 16rem;
}
.item {
@ -101,10 +109,6 @@
overflow: hidden;
text-overflow: ellipsis;
}
.offline {
font-style: italic;
}
}
.status {
@ -128,3 +132,18 @@
height: 100%;
}
}
.dataSaving {
background-color: var(--color-off-white);
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 1rem;
width: 100%;
}
.saving {
display: flex;
width: 100%;
justify-content: space-between;
}

View File

@ -1,33 +1,68 @@
import { defineMessages } from 'react-intl';
import ConnectionStatus from '/imports/api/connection-status';
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import Settings from '/imports/ui/services/settings';
import Logger from '/imports/startup/client/logger';
import _ from 'lodash';
import { Session } from 'meteor/session';
import { notify } from '/imports/ui/services/notification';
import { makeCall } from '/imports/ui/services/api';
const STATS = Meteor.settings.public.stats;
const NOTIFICATION = STATS.notification;
const STATS_LENGTH = STATS.length;
const STATS_INTERVAL = STATS.interval;
const STATS_LOG = STATS.log;
const RTT_INTERVAL = STATS_LENGTH * STATS_INTERVAL;
// Set a bottom threshold to avoid log flooding
const RTT_LOG_THRESHOLD = STATS.rtt[STATS.level.indexOf('danger')];
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
let audioStats = '';
const audioStatsDep = new Tracker.Dependency();
const intlMessages = defineMessages({
saved: {
id: 'app.settings.save-notification.label',
description: 'Label shown in toast when data savings are saved',
},
notification: {
id: 'app.connection-status.notification',
description: 'Label shown in toast when connection loss is detected',
},
});
let stats = -1;
const statsDep = new Tracker.Dependency();
let statsTimeout = null;
const getHelp = () => STATS.help;
const URL_REGEX = new RegExp(/^(http|https):\/\/[^ "]+$/);
const getHelp = () => {
if (URL_REGEX.test(STATS.help)) return STATS.help;
return null;
};
const getLevel = () => STATS.level;
const getAudioStats = () => {
audioStatsDep.depend();
return audioStats;
const getStats = () => {
statsDep.depend();
return STATS.level[stats];
};
const setAudioStats = (level = '') => {
if (audioStats !== level) {
audioStats = level;
audioStatsDep.changed();
const setStats = (level = -1) => {
if (stats !== level) {
stats = level;
statsDep.changed();
addConnectionStatus(level);
}
};
const handleStats = (level) => {
if (level > stats) {
setStats(level);
}
};
const handleAudioStatsEvent = (event) => {
const { detail } = event;
if (detail) {
@ -37,21 +72,64 @@ const handleAudioStatsEvent = (event) => {
for (let i = STATS.level.length - 1; i >= 0; i--) {
if (loss > STATS.loss[i] || jitter > STATS.jitter[i]) {
active = true;
setAudioStats(STATS.level[i]);
handleStats(i);
break;
}
}
if (active) {
if (statsTimeout !== null) clearTimeout(statsTimeout);
statsTimeout = setTimeout(() => {
setAudioStats();
}, STATS.length * STATS.interval);
}
if (active) startStatsTimeout();
}
};
const handleSocketStatsEvent = (event) => {
const { detail } = event;
if (detail) {
const { rtt } = detail;
let active = false;
// From higher to lower
for (let i = STATS.level.length - 1; i >= 0; i--) {
if (rtt > STATS.rtt[i]) {
active = true;
handleStats(i);
break;
}
}
if (active) startStatsTimeout();
}
};
const startStatsTimeout = () => {
if (statsTimeout !== null) clearTimeout(statsTimeout);
statsTimeout = setTimeout(() => {
setStats();
}, STATS.timeout);
};
const addConnectionStatus = (level) => {
if (level !== '') makeCall('addConnectionStatus', level);
if (level !== -1) makeCall('addConnectionStatus', STATS.level[level]);
};
const fetchRoundTripTime = () => {
const t0 = Date.now();
makeCall('voidConnection').then(() => {
const tf = Date.now();
const rtt = tf - t0;
if (STATS_LOG && rtt > RTT_LOG_THRESHOLD) {
Logger.info(
{
logCode: 'rtt',
extraInfo: { rtt },
},
'Calculated round-trip time in milliseconds',
);
}
const event = new CustomEvent('socketstats', { detail: { rtt } });
window.dispatchEvent(event);
});
};
const sortLevel = (a, b) => {
@ -63,7 +141,51 @@ const sortLevel = (a, b) => {
if (indexOfA > indexOfB) return -1;
};
const getMyConnectionStatus = () => {
const myConnectionStatus = ConnectionStatus.findOne(
{
meetingId: Auth.meetingID,
userId: Auth.userID,
},
{ fields:
{
level: 1,
timestamp: 1,
},
},
);
const me = Users.findOne(
{
meetingId: Auth.meetingID,
userId: Auth.userID,
},
{ fields:
{
avatar: 1,
color: 1,
}
},
);
if (myConnectionStatus) {
return [{
name: Auth.fullname,
avatar: me.avatar,
you: true,
moderator: false,
color: me.color,
level: myConnectionStatus.level,
timestamp: myConnectionStatus.timestamp,
}];
}
return [];
};
const getConnectionStatus = () => {
if (!isModerator()) return getMyConnectionStatus();
const connectionStatus = ConnectionStatus.find(
{ meetingId: Auth.meetingID },
).fetch().map(status => {
@ -89,7 +211,6 @@ const getConnectionStatus = () => {
role: 1,
avatar: 1,
color: 1,
connectionStatus: 1,
},
},
).fetch().reduce((result, user) => {
@ -99,7 +220,6 @@ const getConnectionStatus = () => {
role,
avatar,
color,
connectionStatus: userStatus,
} = user;
const status = connectionStatus.find(status => status.userId === userId);
@ -108,7 +228,6 @@ const getConnectionStatus = () => {
result.push({
name,
avatar,
offline: userStatus === 'offline',
you: Auth.userID === userId,
moderator: role === ROLE_MODERATOR,
color,
@ -123,15 +242,81 @@ const getConnectionStatus = () => {
const isEnabled = () => STATS.enabled;
let roundTripTimeInterval = null;
const startRoundTripTime = () => {
if (!isEnabled()) return;
stopRoundTripTime();
roundTripTimeInterval = setInterval(fetchRoundTripTime, RTT_INTERVAL);
};
const stopRoundTripTime = () => {
if (roundTripTimeInterval) {
clearInterval(roundTripTimeInterval);
}
}
const isModerator = () => {
const user = Users.findOne(
{
meetingId: Auth.meetingID,
userId: Auth.userID,
},
{ fields: { role: 1 }},
);
if (user && user.role === ROLE_MODERATOR) {
return true;
}
return false;
};
if (STATS.enabled) {
window.addEventListener('audiostats', handleAudioStatsEvent);
window.addEventListener('socketstats', handleSocketStatsEvent);
}
const updateDataSavingSettings = (dataSaving, intl) => {
if (!_.isEqual(Settings.dataSaving, dataSaving)) {
Settings.dataSaving = dataSaving;
Settings.save();
if (intl) notify(intl.formatMessage(intlMessages.saved), 'info', 'settings');
}
};
const getNotified = () => {
const notified = Session.get('connectionStatusNotified');
// Since notified can be undefined we need a boolean verification
return notified === true;
};
const notification = (level, intl) => {
if (!NOTIFICATION[level]) return null;
// Avoid toast spamming
const notified = getNotified();
if (notified) {
return null;
} else {
Session.set('connectionStatusNotified', true);
}
if (intl) notify(intl.formatMessage(intlMessages.notification), level, 'network');
};
export default {
addConnectionStatus,
getConnectionStatus,
getAudioStats,
getStats,
getHelp,
getLevel,
isEnabled,
notification,
startRoundTripTime,
stopRoundTripTime,
updateDataSavingSettings,
};

View File

@ -16,6 +16,8 @@ import DefaultContent from '../presentation/default-content/component';
import ExternalVideoContainer from '../external-video-player/container';
import Storage from '../../services/storage/session';
import { withLayoutConsumer } from '/imports/ui/components/layout/context';
import Auth from '/imports/ui/services/auth';
import breakoutService from '/imports/ui/components/breakout-room/service';
const LAYOUT_CONFIG = Meteor.settings.public.layout;
const KURENTO_CONFIG = Meteor.settings.public.kurento;
@ -106,6 +108,8 @@ class MediaContainer extends Component {
}
}
let userWasInBreakout = false;
export default withLayoutConsumer(withModalMounter(withTracker(() => {
const { dataSaving } = Settings;
const { viewParticipantsWebcams, viewScreenshare } = dataSaving;
@ -127,6 +131,31 @@ export default withLayoutConsumer(withModalMounter(withTracker(() => {
data.children = <ScreenshareContainer />;
}
const userIsInBreakout = breakoutService.getBreakoutUserIsIn(Auth.userID);
let deviceIds = Session.get('deviceIds');
if (!userIsInBreakout && userWasInBreakout && deviceIds && deviceIds !== '') {
/* used when re-sharing cameras after leaving a breakout room.
it is needed in cases where the user has more than one active camera
so we only share the second camera after the first
has finished loading (can't share more than one at the same time) */
const canConnect = Session.get('canConnect');
deviceIds = deviceIds.split(',');
if (canConnect) {
const deviceId = deviceIds.shift();
Session.set('canConnect', false);
Session.set('WebcamDeviceId', deviceId);
Session.set('deviceIds', deviceIds.join(','));
VideoService.joinVideo(deviceId);
}
} else {
userWasInBreakout = userIsInBreakout;
}
const { streams: usersVideo } = VideoService.getVideoStreams();
data.usersVideo = usersVideo;

View File

@ -11,6 +11,8 @@ import { styles } from './styles.scss';
import Button from '/imports/ui/components/button/component';
import RecordingIndicator from './recording-indicator/container';
import TalkingIndicatorContainer from '/imports/ui/components/nav-bar/talking-indicator/container';
import ConnectionStatusButton from '/imports/ui/components/connection-status/button/container';
import ConnectionStatusService from '/imports/ui/components/connection-status/service';
import SettingsDropdownContainer from './settings-dropdown/container';
const intlMessages = defineMessages({
@ -125,6 +127,7 @@ class NavBar extends Component {
/>
</div>
<div className={styles.right}>
{ConnectionStatusService.isEnabled() ? <ConnectionStatusButton /> : null}
<SettingsDropdownContainer amIModerator={amIModerator} />
</div>
</div>

View File

@ -8,7 +8,6 @@ import Meetings, { MeetingTimeRemaining } from '/imports/api/meetings';
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 ConnectionStatusService from '/imports/ui/components/connection-status/service';
import { styles } from './styles.scss';
import breakoutService from '/imports/ui/components/breakout-room/service';
@ -150,22 +149,6 @@ export default injectIntl(withTracker(({ intl }) => {
}
}
if (ConnectionStatusService.isEnabled()) {
const stats = ConnectionStatusService.getAudioStats();
if (stats) {
if (ConnectionStatusService.getLevel().includes(stats)) {
data.message = (
<SlowConnection effectiveConnectionType={stats}>
{intl.formatMessage(intlMessages.slowEffectiveConnectionDetected)}{' '}
<a href={ConnectionStatusService.getHelp()} target="_blank" rel="noopener noreferrer">
{intl.formatMessage(intlMessages.slowEffectiveConnectionHelpLink)}
</a>
</SlowConnection>
);
}
}
}
if (!connected) {
data.color = 'primary';
switch (status) {

View File

@ -12,15 +12,13 @@ const intlMessages = defineMessages({
const ClosePresentationComponent = ({ intl, toggleSwapLayout }) => (
<Button
role="button"
aria-labelledby="closeLabel"
aria-describedby="closeDesc"
color="primary"
icon="minus"
size="sm"
data-test="hidePresentationButton"
onClick={toggleSwapLayout}
label={intl.formatMessage(intlMessages.closePresentationLabel)}
aria-label={intl.formatMessage(intlMessages.closePresentationLabel)}
hideLabel
className={styles.button}
/>

View File

@ -36,6 +36,8 @@ class Subscriptions extends Component {
}
}
let usersPersistentDataHandler = null;
export default withTracker(() => {
const { credentials } = Auth;
const { meetingId, requesterUserId } = credentials;
@ -105,6 +107,9 @@ export default withTracker(() => {
const chatIds = chats.map(chat => chat.chatId);
groupChatMessageHandler = Meteor.subscribe('group-chat-msg', chatIds, subscriptionErrorHandler);
}
if (ready && !usersPersistentDataHandler) {
usersPersistentDataHandler = Meteor.subscribe('users-persistent-data');
}
return {
subscriptionsReady: ready,

View File

@ -3,10 +3,18 @@ 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';
const UserParticipantsContainer = props => <UserParticipants {...props} />;
export default withTracker(() => ({
users: UserListService.getUsers(),
meetingIsBreakout: meetingIsBreakout(),
}))(UserParticipantsContainer);
export default withTracker(() => {
ChatService.removePackagedClassAttribute(
['ReactVirtualized__Grid', 'ReactVirtualized__Grid__innerScrollContainer'],
'role',
);
return ({
users: UserListService.getUsers(),
meetingIsBreakout: meetingIsBreakout(),
});
})(UserParticipantsContainer);

View File

@ -10,11 +10,9 @@ import DropdownContent from '/imports/ui/components/dropdown/content/component';
import DropdownList from '/imports/ui/components/dropdown/list/component';
import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
import LockViewersContainer from '/imports/ui/components/lock-viewers/container';
import ConnectionStatusContainer from '/imports/ui/components/connection-status/modal/container';
import GuestPolicyContainer from '/imports/ui/components/waiting-users/guest-policy/container';
import BreakoutRoom from '/imports/ui/components/actions-bar/create-breakout-room/container';
import CaptionsService from '/imports/ui/components/captions/service';
import ConnectionStatusService from '/imports/ui/components/connection-status/service';
import CaptionsWriterMenu from '/imports/ui/components/captions/writer-menu/container';
import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component';
import { styles } from './styles';
@ -74,14 +72,6 @@ const intlMessages = defineMessages({
id: 'app.userList.userOptions.lockViewersDesc',
description: 'Lock viewers description',
},
connectionStatusLabel: {
id: 'app.userList.userOptions.connectionStatusLabel',
description: 'Connection status label',
},
connectionStatusDesc: {
id: 'app.userList.userOptions.connectionStatusDesc',
description: 'Connection status description',
},
guestPolicyLabel: {
id: 'app.userList.userOptions.guestPolicyLabel',
description: 'Guest policy label',
@ -148,7 +138,6 @@ class UserOptions extends PureComponent {
this.muteId = _.uniqueId('list-item-');
this.muteAllId = _.uniqueId('list-item-');
this.lockId = _.uniqueId('list-item-');
this.connectionStatusId = _.uniqueId('list-item-');
this.guestPolicyId = _.uniqueId('list-item-');
this.createBreakoutId = _.uniqueId('list-item-');
this.saveUsersNameId = _.uniqueId('list-item-');
@ -298,15 +287,6 @@ class UserOptions extends PureComponent {
onClick={() => mountModal(<LockViewersContainer />)}
/>) : null
),
(ConnectionStatusService.isEnabled() && isMeteorConnected ? (
<DropdownListItem
key={this.connectionStatusId}
icon="warning"
label={intl.formatMessage(intlMessages.connectionStatusLabel)}
description={intl.formatMessage(intlMessages.connectionStatusDesc)}
onClick={() => mountModal(<ConnectionStatusContainer />)}
/>) : null
),
(!meetingIsBreakout && isMeteorConnected ? (
<DropdownListItem
key={this.guestPolicyId}

View File

@ -142,6 +142,22 @@ class VideoService {
this.isConnected = true;
}
storeDeviceIds() {
const streams = VideoStreams.find(
{
meetingId: Auth.meetingID,
userId: Auth.userID,
}, { fields: { deviceId: 1 } },
).fetch();
let deviceIds = [];
streams.forEach(s => {
deviceIds.push(s.deviceId);
}
);
Session.set('deviceIds', deviceIds.join());
}
exitVideo() {
if (this.isConnected) {
logger.info({
@ -801,6 +817,7 @@ class VideoService {
const videoService = new VideoService();
export default {
storeDeviceIds: () => videoService.storeDeviceIds(),
exitVideo: () => videoService.exitVideo(),
joinVideo: deviceId => videoService.joinVideo(deviceId),
stopVideo: cameraId => videoService.stopVideo(cameraId),

View File

@ -122,6 +122,12 @@ class VideoListItem extends Component {
const { videoIsReady } = this.state;
if (!videoIsReady) this.setState({ videoIsReady: true });
window.dispatchEvent(new Event('resize'));
/* used when re-sharing cameras after leaving a breakout room.
it is needed in cases where the user has more than one active camera
so we only share the second camera after the first
has finished loading (can't share more than one at the same time) */
Session.set('canConnect', true);
}
getAvailableActions() {

View File

@ -470,26 +470,21 @@ class PollDrawComponent extends Component {
fill={backgroundColor}
strokeWidth={thickness}
/>
<text
x={innerRect.x}
y={innerRect.y}
fill="#333333"
fontFamily="Arial"
fontSize={calcFontSize}
textAnchor={isRTL ? 'end' : 'start'}
>
{extendedTextArray.map(line => (
<tspan
x={line.keyColumn.xLeft}
y={line.keyColumn.yLeft}
dy={maxLineHeight / 2}
key={`${line.key}_key`}
className={styles.outline}
>
{line.keyColumn.keyString}
</tspan>
))}
</text>
{extendedTextArray.map(line => (
<text
x={line.keyColumn.xLeft}
y={line.keyColumn.yLeft}
dy={maxLineHeight / 2}
key={`${line.key}_key`}
fill="#333333"
fontFamily="Arial"
fontSize={calcFontSize}
textAnchor={isRTL ? 'end' : 'start'}
className={styles.outline}
>
{line.keyColumn.keyString}
</text>
))}
{extendedTextArray.map(line => (
<rect
key={`${line.key}_bar`}

View File

@ -18,6 +18,7 @@
--color-success: #008081;
--color-danger: #DF2721;
--color-warning: purple;
--color-offline: var(--color-gray-light);
--color-background: var(--color-gray-dark);

View File

@ -159,7 +159,7 @@ const calculateInterval = (stats) => {
bytes: {
received: diff(single, first.bytes.received, last.bytes.received)
},
jitter: single ? first.jitter : last.jitter
jitter: Math.max.apply(Math, stats.map(s => s.jitter))
};
};
@ -190,7 +190,7 @@ const logResult = (id, result) => {
if (!iteration || iteration % STATS_LENGTH !== 0) return null;
const duration = STATS_LENGTH * STATS_INTERVAL / 1000;
logger.info(
logger.debug(
{
logCode: 'stats_monitor_result',
extraInfo: {

View File

@ -163,10 +163,10 @@ public:
mediaTimeouts:
maxConnectionAttempts: 2
# Base screen media timeout (send|recv)
baseTimeout: 15000
baseTimeout: 30000
# Max timeout: used as the max camera subscribe reconnection timeout. Each
# subscribe reattempt increases the reconnection timer up to this
maxTimeout: 35000
maxTimeout: 60000
timeoutIncreaseFactor: 1.5
constraints:
video:
@ -420,7 +420,11 @@ public:
enabled: true
interval: 2000
length: 5
timeout: 30000
log: false
notification:
warning: false
error: true
jitter:
- 10
- 20
@ -429,6 +433,10 @@ public:
- 0.05
- 0.1
- 0.2
rtt:
- 500
- 1000
- 2000
level:
- warning
- danger

View File

@ -277,17 +277,6 @@ function WebRtcPeer(mode, options, callback) {
callback(null, localDescription.sdp, self.processAnswer.bind(self));
}
const isSafari = ((userAgent.indexOf('iphone') > -1 || userAgent.indexOf('ipad') > -1) || browser.name.toLowerCase() == 'safari');
// Bind the SDP release to the gathering state on Safari-based envs
if (isSafari) {
pc.onicegatheringstatechange = function (event) {
if(event.target.iceGatheringState == "complete") {
descriptionCallback();
}
}
}
var offerAudio = true;
var offerVideo = true;
if (mediaConstraints) {
@ -305,10 +294,6 @@ function WebRtcPeer(mode, options, callback) {
offer = mangleSdpToAddSimulcast(offer);
return pc.setLocalDescription(offer);
}).then(() => {
// The Safari offer release was already binded to the gathering state
if (isSafari) {
return;
}
descriptionCallback();
}).catch(callback);
};

View File

@ -104,8 +104,6 @@
"app.userList.userOptions.lockViewersDesc": "Lock certain functionalities for attendees of the meeting",
"app.userList.userOptions.guestPolicyLabel": "Guest policy",
"app.userList.userOptions.guestPolicyDesc": "Change meeting guest policy setting",
"app.userList.userOptions.connectionStatusLabel": "Connection status",
"app.userList.userOptions.connectionStatusDesc": "View users' connection status",
"app.userList.userOptions.disableCam": "Viewers' webcams are disabled",
"app.userList.userOptions.disableMic": "Viewers' microphones are disabled",
"app.userList.userOptions.disablePrivChat": "Private chat is disabled",
@ -618,7 +616,8 @@
"app.connection-status.description": "View users' connection status",
"app.connection-status.empty": "There are currently no reported connection issues",
"app.connection-status.more": "more",
"app.connection-status.offline": "offline",
"app.connection-status.label": "Connection status",
"app.connection-status.notification": "Loss in your connection was detected",
"app.recording.startTitle": "Start recording",
"app.recording.stopTitle": "Pause recording",
"app.recording.resumeTitle": "Resume recording",

View File

@ -21,6 +21,7 @@ import '/imports/api/whiteboard-multi-user/server';
import '/imports/api/video-streams/server';
import '/imports/api/network-information/server';
import '/imports/api/users-infos/server';
import '/imports/api/users-persistent-data/server';
import '/imports/api/connection-status/server';
import '/imports/api/note/server';
import '/imports/api/external-videos/server';

View File

@ -27,6 +27,7 @@ module BigBlueButton
$bbb_props = YAML::load(File.open(File.expand_path('../../../scripts/bigbluebutton.yml', __FILE__)))
$recording_dir = $bbb_props['recording_dir']
$raw_recording_dir = "#{$recording_dir}/raw"
$store_recording_status = $bbb_props['store_recording_status']
# Class to wrap Redis so we can mock
# for testing
@ -135,6 +136,7 @@ module BigBlueButton
end
RECORDINGS_CHANNEL = "bigbluebutton:from-rap"
RAP_STATUS_LIST = "bigbluebutton:rap:status"
def put_message(message_type, meeting_id, additional_payload = {})
events_xml = "#{$raw_recording_dir}/#{meeting_id}/events.xml"
@ -149,6 +151,11 @@ module BigBlueButton
"meeting_id" => meeting_id
})
@redis.publish RECORDINGS_CHANNEL, msg.to_json
if $store_recording_status
@redis.lpush RAP_STATUS_LIST, msg.to_json
end
end
def put_message_workflow(message_type, workflow, meeting_id, additional_payload = {})
@ -157,6 +164,10 @@ module BigBlueButton
})
end
def put_archive_norecord(meeting_id, additional_payload = {})
put_message "archive_norecord", meeting_id, additional_payload
end
def put_archive_started(meeting_id, additional_payload = {})
put_message "archive_started", meeting_id, additional_payload
end

View File

@ -42,6 +42,10 @@ module BigBlueButton
!File.exist?(@archived_fail)
)
if File.exist?(@archived_norecord)
@publisher.put_archive_norecord(@meeting_id)
end
@publisher.put_archive_ended(@meeting_id, success: step_succeeded, step_time: step_time)
if step_succeeded

View File

@ -35,6 +35,7 @@ module BigBlueButton
# If the publish directory exists, the script does nothing
FileUtils.rm_rf("#{@recording_dir}/publish/#{@format_name}/#{@full_id}")
remove_status_files
# For legacy reasons, the meeting ID passed to the publish script contains

View File

@ -22,6 +22,12 @@ redis_port: 6379
# redis_workers_host: 127.0.0.1
# redis_workers_port: 6379
# Set to true to insert recording process status into
# redis list with key "store_recording_status: true".
# This is useful if you want to track progress status
# and have another script process it.
store_recording_status: false
# Sequence of recording steps. Keys are the current step, values
# are the next step(s). Examples:
# current_step: next_step