Merge branch 'develop' of https://github.com/bigbluebutton/bigbluebutton into 2.3-updated-polling

This commit is contained in:
KDSBrowne 2020-10-21 16:05:03 +00:00
commit 73f890c9d8
222 changed files with 25637 additions and 15346 deletions

View File

@ -37,6 +37,7 @@ trait SystemConfiguration {
lazy val fromAkkaAppsJsonChannel = Try(config.getString("eventBus.fromAkkaAppsChannel")).getOrElse("from-akka-apps-json-channel")
lazy val applyPermissionCheck = Try(config.getBoolean("apps.checkPermissions")).getOrElse(false)
lazy val ejectOnViolation = Try(config.getBoolean("apps.ejectOnViolation")).getOrElse(false)
lazy val voiceConfRecordPath = Try(config.getString("voiceConf.recordPath")).getOrElse("/var/freeswitch/meetings")
lazy val voiceConfRecordCodec = Try(config.getString("voiceConf.recordCodec")).getOrElse("wav")

View File

@ -37,7 +37,7 @@ trait RightsManagementTrait extends SystemConfiguration {
}
}
object PermissionCheck {
object PermissionCheck extends SystemConfiguration {
val MOD_LEVEL = 100
val AUTHED_LEVEL = 50
@ -83,12 +83,17 @@ object PermissionCheck {
def ejectUserForFailedPermission(meetingId: String, userId: String, reason: String,
outGW: OutMsgRouter, liveMeeting: LiveMeeting): Unit = {
val ejectedBy = SystemUser.ID
if (ejectOnViolation) {
val ejectedBy = SystemUser.ID
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.PERMISSION_FAILED, ban = false)
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.PERMISSION_FAILED, ban = false)
// send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW)
// send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW)
} else {
// TODO: get this object a context so it can use the akka logging system
println(s"Skipping violation ejection of ${userId} trying to ${reason} in ${meetingId}")
}
}
def addOldPresenter(users: Users2x, userId: String): OldPresenter = {

View File

@ -56,7 +56,7 @@ trait RegisterUserReqMsgHdlr {
val g = GuestApprovedVO(regUser.id, GuestStatus.ALLOW)
UsersApp.approveOrRejectGuest(liveMeeting, outGW, g, SystemUser.ID)
case GuestStatus.WAIT =>
val guest = GuestWaiting(regUser.id, regUser.name, regUser.role, regUser.guest, regUser.authed)
val guest = GuestWaiting(regUser.id, regUser.name, regUser.role, regUser.guest, regUser.avatarURL, regUser.authed)
addGuestToWaitingForApproval(guest, liveMeeting.guestsWaiting)
notifyModeratorsOfGuestWaiting(Vector(guest), liveMeeting.users2x, liveMeeting.props.meetingProp.intId)
case GuestStatus.DENY =>

View File

@ -51,7 +51,7 @@ class GuestsWaiting {
def setGuestPolicy(policy: GuestPolicy) = guestPolicy = policy
}
case class GuestWaiting(intId: String, name: String, role: String, guest: Boolean, authenticated: Boolean)
case class GuestWaiting(intId: String, name: String, role: String, guest: Boolean, avatar: String, authenticated: Boolean)
case class GuestPolicy(policy: String, setBy: String)
object GuestPolicyType {

View File

@ -27,7 +27,7 @@ import java.util.*;
* Source:<br>
* Phoenix: An Interactive Curve Design System Based on the Automatic Fitting
* of Hand-Sketched Curves.<br>
* © Copyright by Philip J. Schneider 1988.<br>
* Copyright (c) by Philip J. Schneider 1988.<br>
* A thesis submitted in partial fulfillment of the requirements for the degree
* of Master of Science, University of Washington.
* <p>
@ -238,7 +238,7 @@ public class Bezier {
* @param digitizedPoints Digitized points
* @param maxAngle maximal angle in radians between the current point and its
* predecessor and successor up to which the point does not break the
* digitized list into segments. Recommended value 44° = 44 * 180d / Math.PI
* digitized list into segments. Recommended value 44 deg = 44 * 180d / Math.PI
* @return Segments of digitized points, each segment having less than maximal
* angle between points.
*/

View File

@ -44,7 +44,7 @@ object MsgBuilder {
val envelope = BbbCoreEnvelope(GetGuestsWaitingApprovalRespMsg.NAME, routing)
val header = BbbClientMsgHeader(GetGuestsWaitingApprovalRespMsg.NAME, meetingId, userId)
val guestsWaiting = guests.map(g => GuestWaitingVO(g.intId, g.name, g.role, g.guest, g.authenticated))
val guestsWaiting = guests.map(g => GuestWaitingVO(g.intId, g.name, g.role, g.guest, g.avatar, g.authenticated))
val body = GetGuestsWaitingApprovalRespMsgBody(guestsWaiting)
val event = GetGuestsWaitingApprovalRespMsg(header, body)
@ -56,7 +56,7 @@ object MsgBuilder {
val envelope = BbbCoreEnvelope(GuestsWaitingForApprovalEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(GuestsWaitingForApprovalEvtMsg.NAME, meetingId, userId)
val guestsWaiting = guests.map(g => GuestWaitingVO(g.intId, g.name, g.role, g.guest, g.authenticated))
val guestsWaiting = guests.map(g => GuestWaitingVO(g.intId, g.name, g.role, g.guest, g.avatar, g.authenticated))
val body = GuestsWaitingForApprovalEvtMsgBody(guestsWaiting)
val event = GuestsWaitingForApprovalEvtMsg(header, body)

View File

@ -20,13 +20,13 @@ trait FakeTestData {
val guest1 = createUserVoiceAndCam(liveMeeting, Roles.VIEWER_ROLE, guest = true, authed = true, CallingWith.WEBRTC, muted = false,
talking = false, listenOnly = false)
Users2x.add(liveMeeting.users2x, guest1)
val guestWait1 = GuestWaiting(guest1.intId, guest1.name, guest1.role, guest1.guest, guest1.authed)
val guestWait1 = GuestWaiting(guest1.intId, guest1.name, guest1.role, guest1.guest, "", guest1.authed)
GuestsWaiting.add(liveMeeting.guestsWaiting, guestWait1)
val guest2 = createUserVoiceAndCam(liveMeeting, Roles.VIEWER_ROLE, guest = true, authed = true, CallingWith.FLASH, muted = false,
talking = false, listenOnly = false)
Users2x.add(liveMeeting.users2x, guest2)
val guestWait2 = GuestWaiting(guest2.intId, guest2.name, guest2.role, guest2.guest, guest2.authed)
val guestWait2 = GuestWaiting(guest2.intId, guest2.name, guest2.role, guest2.guest, "", guest2.authed)
GuestsWaiting.add(liveMeeting.guestsWaiting, guestWait2)
val vu1 = FakeUserGenerator.createFakeVoiceOnlyUser(CallingWith.PHONE, muted = false, talking = false, listenOnly = false)

View File

@ -71,6 +71,7 @@ services {
apps {
checkPermissions = true
ejectOnViolation = false
endMeetingWhenNoMoreAuthedUsers = false
endMeetingWhenNoMoreAuthedUsersAfterMinutes = 2
}

View File

@ -19,7 +19,7 @@ case class GetGuestsWaitingApprovalRespMsg(
body: GetGuestsWaitingApprovalRespMsgBody
) extends BbbCoreMsg
case class GetGuestsWaitingApprovalRespMsgBody(guests: Vector[GuestWaitingVO])
case class GuestWaitingVO(intId: String, name: String, role: String, guest: Boolean, authenticated: Boolean)
case class GuestWaitingVO(intId: String, name: String, role: String, guest: Boolean, avatar: String, authenticated: Boolean)
/**
* Message sent to client for list of guest waiting for approval. This is sent when

View File

@ -70,6 +70,16 @@ public class ApiParams {
public static final String LOCK_SETTINGS_LOCK_ON_JOIN = "lockSettingsLockOnJoin";
public static final String LOCK_SETTINGS_LOCK_ON_JOIN_CONFIGURABLE = "lockSettingsLockOnJoinConfigurable";
// New param passed on create call to callback when meeting ends.
// This is a duplicate of the endCallbackUrl meta param as we want this
// param to stay on the server and not propagated to client and recordings.
public static final String MEETING_ENDED_CALLBACK_URL = "meetingEndedURL";
// Param to end the meeting when there are no moderators after a certain period of time.
// Needed for classes where teacher gets disconnected and can't get back in. Prevents
// students from running amok.
public static final String END_WHEN_NO_MODERATOR = "endWhenNoModerator";
private ApiParams() {
throw new IllegalStateException("ApiParams is a utility class. Instanciation is forbidden.");
}

View File

@ -40,6 +40,7 @@ import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.client.utils.URIBuilder;
import org.bigbluebutton.api.domain.GuestPolicy;
import org.bigbluebutton.api.domain.Meeting;
@ -81,8 +82,9 @@ import org.bigbluebutton.api2.IBbbWebApiGWApp;
import org.bigbluebutton.api2.domain.UploadedTrack;
import org.bigbluebutton.common2.redis.RedisStorageService;
import org.bigbluebutton.presentation.PresentationUrlDownloadService;
import org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask;
import org.bigbluebutton.web.services.WaitingGuestCleanupTimerTask;
import org.bigbluebutton.web.services.UserCleanupTimerTask;
import org.bigbluebutton.web.services.EnteredUserCleanupTimerTask;
import org.bigbluebutton.web.services.callback.CallbackUrlService;
import org.bigbluebutton.web.services.callback.MeetingEndedEvent;
import org.bigbluebutton.web.services.turn.StunTurnService;
@ -107,13 +109,17 @@ public class MeetingService implements MessageListener {
private final ConcurrentMap<String, UserSession> sessions;
private RecordingService recordingService;
private RegisteredUserCleanupTimerTask registeredUserCleaner;
private WaitingGuestCleanupTimerTask waitingGuestCleaner;
private UserCleanupTimerTask userCleaner;
private EnteredUserCleanupTimerTask enteredUserCleaner;
private StunTurnService stunTurnService;
private RedisStorageService storeService;
private CallbackUrlService callbackUrlService;
private boolean keepEvents;
private long usersTimeout;
private long enteredUsersTimeout;
private ParamsProcessorUtil paramsProcessorUtil;
private PresentationUrlDownloadService presDownloadService;
@ -180,22 +186,63 @@ public class MeetingService implements MessageListener {
}
/**
* Remove registered users who did not successfully joined the meeting.
* Remove users who did not successfully reconnected to the meeting.
*/
public void purgeRegisteredUsers() {
public void purgeUsers() {
for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
Long now = System.currentTimeMillis();
Meeting meeting = entry.getValue();
ConcurrentMap<String, User> users = meeting.getUsersMap();
for (AbstractMap.Entry<String, User> userEntry : meeting.getUsersMap().entrySet()) {
String userId = userEntry.getKey();
User user = userEntry.getValue();
for (AbstractMap.Entry<String, RegisteredUser> registeredUser : meeting.getRegisteredUsers().entrySet()) {
String registeredUserID = registeredUser.getKey();
RegisteredUser registeredUserDate = registeredUser.getValue();
if (!user.hasLeft()) continue;
long elapsedTime = now - registeredUserDate.registeredOn;
if (elapsedTime >= 60000 && !users.containsKey(registeredUserID)) {
meeting.userUnregistered(registeredUserID);
long elapsedTime = now - user.getLeftOn();
if (elapsedTime >= usersTimeout) {
meeting.removeUser(userId);
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", meeting.getInternalId());
logData.put("userId", userId);
logData.put("logCode", "removed_user");
logData.put("description", "User left and was removed from the meeting.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
}
}
}
}
/**
* Remove entered users who did not join.
*/
public void purgeEnteredUsers() {
for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
Long now = System.currentTimeMillis();
Meeting meeting = entry.getValue();
for (AbstractMap.Entry<String, Long> enteredUser : meeting.getEnteredUsers().entrySet()) {
String userId = enteredUser.getKey();
long elapsedTime = now - enteredUser.getValue();
if (elapsedTime >= enteredUsersTimeout) {
meeting.removeEnteredUser(userId);
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", meeting.getInternalId());
logData.put("userId", userId);
logData.put("logCode", "purged_entered_user");
logData.put("description", "Purged user that called ENTER from the API but never joined");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
}
}
}
@ -778,27 +825,38 @@ public class MeetingService implements MessageListener {
String endCallbackUrl = "endCallbackUrl".toLowerCase();
Map<String, String> metadata = m.getMetadata();
if (!m.isBreakout() && metadata.containsKey(endCallbackUrl)) {
String callbackUrl = metadata.get(endCallbackUrl);
try {
if (!m.isBreakout()) {
if (metadata.containsKey(endCallbackUrl)) {
String callbackUrl = metadata.get(endCallbackUrl);
try {
callbackUrl = new URIBuilder(new URI(callbackUrl))
.addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false")
.addParameter("meetingID", m.getExternalId()).build().toURL().toString();
callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl));
} catch (MalformedURLException e) {
log.error("Malformed URL in callback url=[{}]", callbackUrl, e);
} catch (URISyntaxException e) {
log.error("URI Syntax error in callback url=[{}]", callbackUrl, e);
} catch (Exception e) {
log.error("Error in callback url=[{}]", callbackUrl, e);
.addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false")
.addParameter("meetingID", m.getExternalId()).build().toURL().toString();
MeetingEndedEvent event = new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl);
processMeetingEndedCallback(event);
} catch (Exception e) {
log.error("Error in callback url=[{}]", callbackUrl, e);
}
}
if (! StringUtils.isEmpty(m.getMeetingEndedCallbackURL())) {
String meetingEndedCallbackURL = m.getMeetingEndedCallbackURL();
callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), meetingEndedCallbackURL));
}
}
processRemoveEndedMeeting(message);
}
}
private void processMeetingEndedCallback(MeetingEndedEvent event) {
try {
callbackUrlService.handleMessage(event);
} catch (Exception e) {
log.error("Error in callback url=[{}]", event.getCallbackUrl(), e);
}
}
private void userJoined(UserJoined message) {
Meeting m = getMeeting(message.meetingId);
if (m != null) {
@ -867,13 +925,6 @@ public class MeetingService implements MessageListener {
// the meeting ended.
m.setEndTime(System.currentTimeMillis());
}
RegisteredUser userRegistered = m.userUnregistered(message.userId);
if (userRegistered != null) {
log.info("User unregistered from meeting");
} else {
log.info("User was not unregistered from meeting because it was not found");
}
}
}
}
@ -911,7 +962,7 @@ public class MeetingService implements MessageListener {
} else {
if (message.userId.startsWith("v_")) {
// A dial-in user joined the meeting. Dial-in users by convention has userId that starts with "v_".
User vuser = new User(message.userId, message.userId, message.name, "DIAL-IN-USER", "no-avatar-url",
User vuser = new User(message.userId, message.userId, message.name, "DIAL-IN-USER", "",
true, GuestPolicy.ALLOW, "DIAL-IN");
vuser.setVoiceJoined(true);
m.userJoined(vuser);
@ -1086,8 +1137,9 @@ public class MeetingService implements MessageListener {
public void stop() {
processMessage = false;
registeredUserCleaner.stop();
waitingGuestCleaner.stop();
userCleaner.stop();
enteredUserCleaner.stop();
}
public void setRecordingService(RecordingService s) {
@ -1112,11 +1164,16 @@ public class MeetingService implements MessageListener {
waitingGuestCleaner.start();
}
public void setRegisteredUserCleanupTimerTask(
RegisteredUserCleanupTimerTask c) {
registeredUserCleaner = c;
registeredUserCleaner.setMeetingService(this);
registeredUserCleaner.start();
public void setEnteredUserCleanupTimerTask(EnteredUserCleanupTimerTask c) {
enteredUserCleaner = c;
enteredUserCleaner.setMeetingService(this);
enteredUserCleaner.start();
}
public void setUserCleanupTimerTask(UserCleanupTimerTask c) {
userCleaner = c;
userCleaner.setMeetingService(this);
userCleaner.start();
}
public void setStunTurnService(StunTurnService s) {
@ -1126,4 +1183,12 @@ public class MeetingService implements MessageListener {
public void setKeepEvents(boolean value) {
keepEvents = value;
}
public void setUsersTimeout(long value) {
usersTimeout = value;
}
public void setEnteredUsersTimeout(long value) {
enteredUsersTimeout = value;
}
}

View File

@ -78,6 +78,7 @@ public class ParamsProcessorUtil {
private Boolean moderatorsJoinViaHTML5Client;
private Boolean attendeesJoinViaHTML5Client;
private Boolean allowRequestsWithoutSession;
private Boolean useDefaultAvatar = false;
private String defaultAvatarURL;
private String defaultConfigURL;
private String defaultGuestPolicy;
@ -114,6 +115,7 @@ public class ParamsProcessorUtil {
private Integer userInactivityThresholdInMinutes = 30;
private Integer userActivitySignResponseDelayInMinutes = 5;
private Boolean defaultAllowDuplicateExtUserid = true;
private Boolean defaultEndWhenNoModerator = false;
private String formatConfNum(String s) {
if (s.length() > 5) {
@ -420,6 +422,15 @@ public class ParamsProcessorUtil {
}
}
boolean endWhenNoModerator = defaultEndWhenNoModerator;
if (!StringUtils.isEmpty(params.get(ApiParams.END_WHEN_NO_MODERATOR))) {
try {
endWhenNoModerator = Boolean.parseBoolean(params.get(ApiParams.END_WHEN_NO_MODERATOR));
} catch (Exception ex) {
log.warn("Invalid param [endWhenNoModerator] for meeting=[{}]", internalMeetingId);
}
}
String guestPolicy = defaultGuestPolicy;
if (!StringUtils.isEmpty(params.get(ApiParams.GUEST_POLICY))) {
guestPolicy = params.get(ApiParams.GUEST_POLICY);
@ -454,6 +465,8 @@ public class ParamsProcessorUtil {
externalMeetingId = externalHash + "-" + timeStamp;
}
String avatarURL = useDefaultAvatar ? defaultAvatarURL : "";
// Create the meeting with all passed in parameters.
Meeting meeting = new Meeting.Builder(externalMeetingId,
internalMeetingId, createTime).withName(meetingName)
@ -464,7 +477,7 @@ public class ParamsProcessorUtil {
.withBannerText(bannerText).withBannerColor(bannerColor)
.withTelVoice(telVoice).withWebVoice(webVoice)
.withDialNumber(dialNumber)
.withDefaultAvatarURL(defaultAvatarURL)
.withDefaultAvatarURL(avatarURL)
.withAutoStartRecording(autoStartRec)
.withAllowStartStopRecording(allowStartStoptRec)
.withWebcamsOnlyForModerator(webcamsOnlyForMod)
@ -487,6 +500,11 @@ public class ParamsProcessorUtil {
meeting.setModeratorOnlyMessage(moderatorOnlyMessage);
}
if (!StringUtils.isEmpty(params.get(ApiParams.MEETING_ENDED_CALLBACK_URL))) {
String meetingEndedCallbackURL = params.get(ApiParams.MEETING_ENDED_CALLBACK_URL);
meeting.setMeetingEndedCallbackURL(meetingEndedCallbackURL);
}
meeting.setMeetingExpireIfNoUserJoinedInMinutes(meetingExpireIfNoUserJoinedInMinutes);
meeting.setMeetingExpireWhenLastUserLeftInMinutes(meetingExpireWhenLastUserLeftInMinutes);
meeting.setUserInactivityInspectTimerInMinutes(userInactivityInspectTimerInMinutes);
@ -937,6 +955,10 @@ public class ParamsProcessorUtil {
this.webcamsOnlyForModerator = webcamsOnlyForModerator;
}
public void setUseDefaultAvatar(Boolean value) {
this.useDefaultAvatar = value;
}
public void setdefaultAvatarURL(String url) {
this.defaultAvatarURL = url;
}
@ -1115,4 +1137,10 @@ public class ParamsProcessorUtil {
public void setAllowDuplicateExtUserid(Boolean allow) {
this.defaultAllowDuplicateExtUserid = allow;
}
public void setEndWhenNoModerator(Boolean val) {
this.defaultEndWhenNoModerator = val;
}
}

View File

@ -73,6 +73,7 @@ public class Meeting {
private Map<String, Object> userCustomData;
private final ConcurrentMap<String, User> users;
private final ConcurrentMap<String, RegisteredUser> registeredUsers;
private final ConcurrentMap<String, Long> enteredUsers;
private final ConcurrentMap<String, Config> configs;
private final Boolean isBreakout;
private final List<String> breakoutRooms = new ArrayList<>();
@ -92,6 +93,11 @@ public class Meeting {
public final Boolean allowDuplicateExtUserid;
private String meetingEndedCallbackURL = "";
public final Boolean endWhenNoModerator;
public Meeting(Meeting.Builder builder) {
name = builder.name;
extMeetingId = builder.externalId;
@ -120,12 +126,14 @@ public class Meeting {
guestPolicy = builder.guestPolicy;
breakoutRoomsParams = builder.breakoutRoomsParams;
lockSettingsParams = builder.lockSettingsParams;
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
endWhenNoModerator = builder.endWhenNoModerator;
userCustomData = new HashMap<>();
users = new ConcurrentHashMap<>();
registeredUsers = new ConcurrentHashMap<>();
enteredUsers = new ConcurrentHashMap<>();;
configs = new ConcurrentHashMap<>();
}
@ -448,12 +456,28 @@ public class Meeting {
}
public void userJoined(User user) {
userHasJoined = true;
this.users.put(user.getInternalUserId(), user);
User u = getUserById(user.getInternalUserId());
if (u != null) {
u.joined();
} else {
if (!userHasJoined) userHasJoined = true;
this.users.put(user.getInternalUserId(), user);
// Clean this user up from the entered user's list
removeEnteredUser(user.getInternalUserId());
}
}
public User userLeft(String userid){
return users.remove(userid);
public User userLeft(String userId) {
User user = getUserById(userId);
if (user != null) {
user.left();
}
return user;
}
public User removeUser(String userId) {
return this.users.remove(userId);
}
public User getUserById(String id){
@ -563,6 +587,14 @@ public class Meeting {
this.userActivitySignResponseDelayInMinutes = userActivitySignResponseDelayInMinutes;
}
public String getMeetingEndedCallbackURL() {
return meetingEndedCallbackURL;
}
public void setMeetingEndedCallbackURL(String meetingEndedCallbackURL) {
this.meetingEndedCallbackURL = meetingEndedCallbackURL;
}
public Map<String, Object> getUserCustomData(String userID){
return (Map<String, Object>) userCustomData.get(userID);
}
@ -579,6 +611,29 @@ public class Meeting {
return registeredUsers;
}
public ConcurrentMap<String, Long> getEnteredUsers() {
return this.enteredUsers;
}
public void userEntered(String userId) {
// Skip if user already joined
User u = getUserById(userId);
if (u != null) return;
if (!enteredUsers.containsKey(userId)) {
Long time = System.currentTimeMillis();
this.enteredUsers.put(userId, time);
}
}
public Long removeEnteredUser(String userId) {
return this.enteredUsers.remove(userId);
}
public Long getEnteredUserById(String userId) {
return this.enteredUsers.get(userId);
}
/***
* Meeting Builder
*
@ -612,6 +667,7 @@ public class Meeting {
private BreakoutRoomsParams breakoutRoomsParams;
private LockSettingsParams lockSettingsParams;
private Boolean allowDuplicateExtUserid;
private Boolean endWhenNoModerator;
public Builder(String externalId, String internalId, long createTime) {
this.externalId = externalId;
@ -743,6 +799,11 @@ public class Meeting {
this.allowDuplicateExtUserid = allowDuplicateExtUserid;
return this;
}
public Builder withEndWhenNoModerator(Boolean endWhenNoModerator) {
this.endWhenNoModerator = endWhenNoModerator;
return this;
}
public Meeting build() {
return new Meeting(this);

View File

@ -38,6 +38,7 @@ public class User {
private Boolean voiceJoined = false;
private String clientType;
private List<String> streams;
private Long leftOn = null;
public User(String internalUserId,
String externalUserId,
@ -90,6 +91,22 @@ public class User {
return this.guestStatus;
}
public Boolean hasLeft() {
return leftOn != null;
}
public void joined() {
this.leftOn = null;
}
public void left() {
this.leftOn = System.currentTimeMillis();
}
public Long getLeftOn() {
return this.leftOn;
}
public String getFullname() {
return fullname;
}

View File

@ -1,231 +1,224 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.presentation.imp;
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import org.bigbluebutton.presentation.ConversionMessageConstants;
import org.bigbluebutton.presentation.SupportedFileTypes;
import org.bigbluebutton.presentation.UploadedPresentation;
import org.jodconverter.core.office.OfficeException;
import org.jodconverter.core.office.OfficeUtils;
import org.jodconverter.local.LocalConverter;
import org.jodconverter.local.office.ExternalOfficeManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
public class OfficeToPdfConversionService {
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
private OfficeDocumentValidator2 officeDocumentValidator;
private final ArrayList<ExternalOfficeManager> officeManagers;
private ExternalOfficeManager currentManager = null;
private boolean skipOfficePrecheck = false;
private int sofficeBasePort = 0;
private int sofficeManagers = 0;
private String sofficeWorkingDirBase = null;
public OfficeToPdfConversionService() throws OfficeException {
officeManagers = new ArrayList<>();
}
/*
* Convert the Office document to PDF. If successful, update
* UploadPresentation.uploadedFile with the new PDF out and
* UploadPresentation.lastStepSuccessful to TRUE.
*/
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
initialize(pres);
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
// Check if we need to precheck office document
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "problems_office_to_pdf_validation");
logData.put("message", "Problems detected prior to converting the file to PDF.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
return pres;
}
File pdfOutput = setupOutputPdfFile(pres);
if (convertOfficeDocToPdf(pres, pdfOutput)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_success");
logData.put("message", "Successfully converted office file to pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
} else {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_failed");
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
return pres;
}
}
return pres;
}
public void initialize(UploadedPresentation pres) {
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
}
private File setupOutputPdfFile(UploadedPresentation pres) {
File presentationFile = pres.getUploadedFile();
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
presentationFile.getAbsolutePath().lastIndexOf('.'));
return new File(filenameWithoutExt + ".pdf");
}
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
File pdfOutput) {
boolean success = false;
int attempts = 0;
while(!success) {
LocalConverter documentConverter = LocalConverter
.builder()
.officeManager(currentManager)
.filterChain(new OfficeDocumentConversionFilter())
.build();
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
if(!success) {
// In case of failure, try with other open Office Manager
if(++attempts != officeManagers.size()) {
// Go to next Office Manager ( if the last retry with the first one )
int currentManagerIndex = officeManagers.indexOf(currentManager);
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
if(isLastManager) {
currentManager = officeManagers.get(0);
} else {
currentManager = officeManagers.get(currentManagerIndex+1);
}
} else {
// We tried to use all our office managers and it's still failing
break;
}
}
}
return success;
}
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
pres.setUploadedFile(pdf);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
}
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
officeDocumentValidator = v;
}
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
this.skipOfficePrecheck = skipOfficePrecheck;
}
public void setSofficeBasePort(int sofficeBasePort) {
this.sofficeBasePort = sofficeBasePort;
}
public void setSofficeManagers(int sofficeServiceManagers) {
this.sofficeManagers = sofficeServiceManagers;
}
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
}
public void start() {
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
Integer instanceNumber = managerIndex + 1; // starts at 1
try {
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
if(!workingDir.exists()) {
workingDir.mkdir();
}
ExternalOfficeManager officeManager = ExternalOfficeManager
.builder()
.connectTimeout(2000L)
.retryInterval(500L)
.portNumber(sofficeBasePort + managerIndex)
.connectOnStart(false) // If it's true and soffice is not available, exception is thrown here ( we don't want exception here - we want the manager alive trying to reconnect )
.workingDir(workingDir)
.build();
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
method.setAccessible(true);
method.invoke(officeManager);
// End of workaround for jodconverter not calling makeTempDir
officeManager.start();
officeManagers.add(officeManager);
} catch (Exception e) {
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
}
}
if (officeManagers.size() == 0) {
log.error("No office managers could be started");
return;
}
currentManager = officeManagers.get(0);
}
public void stop() {
try {
officeManagers.forEach(officeManager -> officeManager.stop() );
} catch (Exception e) {
log.error("Could not stop Office Manager", e);
}
}
}
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.presentation.imp;
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import org.bigbluebutton.presentation.ConversionMessageConstants;
import org.bigbluebutton.presentation.SupportedFileTypes;
import org.bigbluebutton.presentation.UploadedPresentation;
import org.jodconverter.core.office.OfficeException;
import org.jodconverter.core.office.OfficeUtils;
import org.jodconverter.local.LocalConverter;
import org.jodconverter.local.office.ExternalOfficeManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.sun.star.document.UpdateDocMode;
import com.google.gson.Gson;
public class OfficeToPdfConversionService {
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
private OfficeDocumentValidator2 officeDocumentValidator;
private final ArrayList<ExternalOfficeManager> officeManagers;
private ExternalOfficeManager currentManager = null;
private boolean skipOfficePrecheck = false;
private int sofficeBasePort = 0;
private int sofficeManagers = 0;
private String sofficeWorkingDirBase = null;
public OfficeToPdfConversionService() throws OfficeException {
officeManagers = new ArrayList<>();
}
/*
* Convert the Office document to PDF. If successful, update
* UploadPresentation.uploadedFile with the new PDF out and
* UploadPresentation.lastStepSuccessful to TRUE.
*/
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
initialize(pres);
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
// Check if we need to precheck office document
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "problems_office_to_pdf_validation");
logData.put("message", "Problems detected prior to converting the file to PDF.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
return pres;
}
File pdfOutput = setupOutputPdfFile(pres);
if (convertOfficeDocToPdf(pres, pdfOutput)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_success");
logData.put("message", "Successfully converted office file to pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
} else {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_failed");
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
return pres;
}
}
return pres;
}
public void initialize(UploadedPresentation pres) {
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
}
private File setupOutputPdfFile(UploadedPresentation pres) {
File presentationFile = pres.getUploadedFile();
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
presentationFile.getAbsolutePath().lastIndexOf('.'));
return new File(filenameWithoutExt + ".pdf");
}
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
File pdfOutput) {
boolean success = false;
int attempts = 0;
while(!success) {
final Map<String, Object> loadProperties = new HashMap<>();
loadProperties.put("Hidden", true);
loadProperties.put("ReadOnly", true);
loadProperties.put("UpdateDocMode", UpdateDocMode.NO_UPDATE);
LocalConverter documentConverter = LocalConverter
.builder()
.officeManager(currentManager)
.loadProperties(loadProperties)
.filterChain(new OfficeDocumentConversionFilter())
.build();
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
if(!success) {
// In case of failure, try with other open Office Manager
if(++attempts != officeManagers.size()) {
// Go to next Office Manager ( if the last retry with the first one )
int currentManagerIndex = officeManagers.indexOf(currentManager);
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
if(isLastManager) {
currentManager = officeManagers.get(0);
} else {
currentManager = officeManagers.get(currentManagerIndex+1);
}
} else {
// We tried to use all our office managers and it's still failing
break;
}
}
}
return success;
}
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
pres.setUploadedFile(pdf);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
}
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
officeDocumentValidator = v;
}
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
this.skipOfficePrecheck = skipOfficePrecheck;
}
public void setSofficeBasePort(int sofficeBasePort) {
this.sofficeBasePort = sofficeBasePort;
}
public void setSofficeManagers(int sofficeServiceManagers) {
this.sofficeManagers = sofficeServiceManagers;
}
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
}
public void start() {
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
Integer instanceNumber = managerIndex + 1; // starts at 1
try {
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
if(!workingDir.exists()) {
workingDir.mkdir();
}
ExternalOfficeManager officeManager = ExternalOfficeManager
.builder()
.connectTimeout(2000L)
.retryInterval(500L)
.portNumber(sofficeBasePort + managerIndex)
.connectOnStart(false) // If it's true and soffice is not available, exception is thrown here ( we don't want exception here - we want the manager alive trying to reconnect )
.workingDir(workingDir)
.build();
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
method.setAccessible(true);
method.invoke(officeManager);
// End of workaround for jodconverter not calling makeTempDir
officeManager.start();
officeManagers.add(officeManager);
} catch (Exception e) {
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
}
}
if (officeManagers.size() == 0) {
log.error("No office managers could be started");
return;
}
currentManager = officeManagers.get(0);
}
public void stop() {
try {
officeManagers.forEach(officeManager -> officeManager.stop() );
} catch (Exception e) {
log.error("Could not stop Office Manager", e);
}
}
}

View File

@ -1,7 +1,7 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
@ -25,11 +25,11 @@ import java.util.concurrent.TimeUnit;
import org.bigbluebutton.api.MeetingService;
public class RegisteredUserCleanupTimerTask {
public class EnteredUserCleanupTimerTask {
private MeetingService service;
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
private long runEvery = 60000;
private long runEvery = 30000;
public void setMeetingService(MeetingService svc) {
this.service = svc;
@ -50,7 +50,7 @@ public class RegisteredUserCleanupTimerTask {
private class CleanupTask implements Runnable {
@Override
public void run() {
service.purgeRegisteredUsers();
service.purgeEnteredUsers();
}
}
}
}

View File

@ -0,0 +1,56 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.web.services;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.bigbluebutton.api.MeetingService;
public class UserCleanupTimerTask {
private MeetingService service;
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
private long runEvery = 15000;
public void setMeetingService(MeetingService svc) {
this.service = svc;
}
public void start() {
scheduledThreadPool.scheduleWithFixedDelay(new CleanupTask(), 60000, runEvery, TimeUnit.MILLISECONDS);
}
public void stop() {
scheduledThreadPool.shutdownNow();
}
public void setRunEvery(long v) {
runEvery = v;
}
private class CleanupTask implements Runnable {
@Override
public void run() {
service.purgeUsers();
}
}
}

View File

@ -3,7 +3,7 @@ bbb-webhooks
This is a node.js application that listens for all events on BigBlueButton and sends POST requests with details about these events to hooks registered via an API. A hook is any external URL that can receive HTTP POST requests.
You can read the full documentation at: http://docs.bigbluebutton.org/labs/webhooks.html
You can read the full documentation at: https://docs.bigbluebutton.org/dev/webhooks.html
Development

View File

@ -1,83 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<title>Guest Lobby</title>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
<style></style>
<script src="lib/jquery-2.1.1.min.js" type="text/javascript"></script>
<script type="text/javascript">
function updateMessage(message) {
$('#content > p').html(message);
}
function findSessionToken() {
return location.search
.substr(1)
.split('&')
.find(function(item) {
return item.split('=')[0] === 'sessionToken'
});
};
function fetchGuestWait(sessionToken) {
const GUEST_WAIT_ENDPOINT = '/bigbluebutton/api/guestWait';
return $.get(GUEST_WAIT_ENDPOINT, sessionToken.concat('&redirect=false'));
};
function pollGuestStatus(token, attempt, limit, everyMs) {
setTimeout(function() {
var REDIRECT_STATUSES = ['ALLOW', 'DENY'];
if (attempt >= limit) {
updateMessage('TIMEOUT_MESSAGE_HERE');
return;
}
fetchGuestWait(token).always(function(data) {
console.log("data=" + JSON.stringify(data));
var status = data.response.guestStatus;
if (REDIRECT_STATUSES.includes(status)) {
window.location = data.response.url;
return;
}
return pollGuestStatus(token, attempt + 1, limit, everyMs);
})
}, everyMs);
};
window.onload = function() {
try {
var ATTEMPT_EVERY_MS = 5000;
var ATTEMPT_LIMIT = 100;
var sessionToken = findSessionToken();
if(!sessionToken) {
updateMessage('NO_SESSION_TOKEN_MESSAGE');
return;
}
pollGuestStatus(sessionToken, 0, ATTEMPT_LIMIT, ATTEMPT_EVERY_MS);
} catch (e) {
console.error(e);
updateMessage('GENERIC_ERROR_MESSAGE');
}
};
</script>
</head>
<body>
<div id="banner"></div>
<div id="content">
<p>Please wait for a moderator to approve you joining the meeting.</p>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
# which (if exists) will be run by `bbb-conf --setip` and `bbb-conf --restart` before restarting
# BigBlueButton.
#
# The purpose of apply-config.sh is to make it easy for you apply defaults to BigBlueButton server that get applied after
# each package update (since the last step in doing an upate is to run `bbb-conf --setip`.
# The purpose of apply-config.sh is to make it easy to apply your configuration changes to a BigBlueButton server
# before BigBlueButton starts
#
@ -74,7 +74,19 @@ HERE
}
# Enable firewall rules to lock down access to server
enableHTML5CameraQualityThresholds() {
echo " - Enable HTML5 cameraQualityThresholds"
yq w -i $HTML5_CONFIG public.kurento.cameraQualityThresholds.enabled true
}
enableHTML5WebcamPagination() {
echo " - Enable HTML5 webcam pagination"
yq w -i $HTML5_CONFIG public.kurento.pagination.enabled true
}
#
# Enable firewall rules to open only
#
enableUFWRules() {
echo " - Enable Firewall and opening 22/tcp, 80/tcp, 443/tcp and 16384:32768/udp"
@ -90,6 +102,123 @@ enableUFWRules() {
}
enableMultipleKurentos() {
echo " - Configuring three Kurento Media Servers: one for listen only, webcam, and screeshare"
# Step 1. Setup shared certificate between FreeSWITCH and Kurento
HOSTNAME=$(cat /etc/nginx/sites-available/bigbluebutton | grep -v '#' | sed -n '/server_name/{s/.*server_name[ ]*//;s/;//;p}' | cut -d' ' -f1 | head -n 1)
openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 3650 -subj "/C=BR/ST=Ottawa/O=BigBlueButton Inc./OU=Live/CN=$HOSTNAME" -keyout /tmp/dtls-srtp-key.pem -out /tmp/dtls-srtp-cert.pem
cat /tmp/dtls-srtp-key.pem /tmp/dtls-srtp-cert.pem > /etc/kurento/dtls-srtp.pem
cat /tmp/dtls-srtp-key.pem /tmp/dtls-srtp-cert.pem > /opt/freeswitch/etc/freeswitch/tls/dtls-srtp.pem
sed -i 's/;pemCertificateRSA=.*/pemCertificateRSA=\/etc\/kurento\/dtls-srtp.pem/g' /etc/kurento/modules/kurento/WebRtcEndpoint.conf.ini
# Step 2. Setup systemd unit files to launch three separate instances of Kurento
for i in `seq 8888 8890`; do
cat > /usr/lib/systemd/system/kurento-media-server-${i}.service << HERE
# /usr/lib/systemd/system/kurento-media-server-#{i}.service
[Unit]
Description=Kurento Media Server daemon (${i})
After=network.target
PartOf=kurento-media-server.service
After=kurento-media-server.service
[Service]
UMask=0002
Environment=KURENTO_LOGS_PATH=/var/log/kurento-media-server
Environment=KURENTO_CONF_FILE=/etc/kurento/kurento-${i}.conf.json
User=kurento
Group=kurento
LimitNOFILE=1000000
ExecStartPre=-/bin/rm -f /var/kurento/.cache/gstreamer-1.5/registry.x86_64.bin
ExecStart=/usr/bin/kurento-media-server --gst-debug-level=3 --gst-debug="3,Kurento*:4,kms*:4,KurentoWebSocketTransport:5"
Type=simple
PIDFile=/var/run/kurento-media-server-${i}.pid
Restart=always
[Install]
WantedBy=kurento-media-server.service
HERE
# Make a new configuration file each instance of Kurento that binds to a different port
cp /etc/kurento/kurento.conf.json /etc/kurento/kurento-${i}.conf.json
sed -i "s/8888/${i}/g" /etc/kurento/kurento-${i}.conf.json
done
# Step 3. Override the main kurento-media-server unit to start/stop the three Kurento instances
cat > /etc/systemd/system/kurento-media-server.service << HERE
[Unit]
Description=Kurento Media Server
[Service]
Type=oneshot
ExecStart=/bin/true
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
HERE
systemctl daemon-reload
for i in `seq 8888 8890`; do
systemctl enable kurento-media-server-${i}.service
done
# Step 4. Modify bbb-webrtc-sfu config to use the three Kurento servers
KURENTO_CONFIG=/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml
MEDIA_TYPE=(main audio content)
IP=$(yq r /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml kurento[0].ip)
for i in `seq 0 2`; do
yq w -i $KURENTO_CONFIG "kurento[$i].ip" $IP
yq w -i $KURENTO_CONFIG "kurento[$i].url" "ws://127.0.0.1:$(($i + 8888))/kurento"
yq w -i $KURENTO_CONFIG "kurento[$i].mediaType" "${MEDIA_TYPE[$i]}"
yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.local" ""
yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.private" ""
yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.public" ""
yq w -i $KURENTO_CONFIG "kurento[$i].options.failAfter" 5
yq w -i $KURENTO_CONFIG "kurento[$i].options.request_timeout" 30000
yq w -i $KURENTO_CONFIG "kurento[$i].options.response_timeout" 30000
done
yq w -i $KURENTO_CONFIG balancing-strategy MEDIA_TYPE
}
disableMultipleKurentos() {
echo " - Configuring a single Kurento Media Server for listen only, webcam, and screeshare"
systemctl stop kurento-media-server.service
for i in `seq 8888 8890`; do
systemctl disable kurento-media-server-${i}.service
done
# Remove the overrride (restoring the original kurento-media-server.service unit file)
rm -f /etc/systemd/system/kurento-media-server.service
systemctl daemon-reload
# Restore bbb-webrtc-sfu configuration to use a single instance of Kurento
KURENTO_CONFIG=/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml
yq d -i $KURENTO_CONFIG kurento[1]
yq d -i $KURENTO_CONFIG kurento[1]
yq w -i $KURENTO_CONFIG "kurento[0].url" "ws://127.0.0.1:8888/kurento"
yq w -i $KURENTO_CONFIG "kurento[0].mediaType" ""
yq w -i $KURENTO_CONFIG balancing-strategy ROUND_ROBIN
}
notCalled() {
#
# This function is not called.
@ -112,6 +241,9 @@ source /etc/bigbluebutton/bbb-conf/apply-lib.sh
#enableHTML5ClientLog
#enableUFWRules
#enableHTML5CameraQualityThresholds
#enableHTML5WebcamPagination
HERE
chmod +x /etc/bigbluebutton/bbb-conf/apply-config.sh
## Stop Copying HERE

View File

@ -1996,16 +1996,17 @@ if [ -n "$HOST" ]; then
#fi
fi
ESL_PASSWORD=$(xmlstarlet sel -t -m 'configuration/settings/param[@name="password"]' -v @value /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml)
#
# Update ESL passwords in three configuration files
#
ESL_PASSWORD=$(cat /usr/share/bbb-fsesl-akka/conf/application.conf | grep password | head -n 1 | sed 's/.*="//g' | sed 's/"//g')
if [ "$ESL_PASSWORD" == "ClueCon" ]; then
ESL_PASSWORD=$(openssl rand -hex 8)
echo "Changing default password for FreeSWITCH Event Socket Layer (see /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml)"
sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /usr/share/bbb-fsesl-akka/conf/application.conf
fi
# Update all references to ESL password
sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml
sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /usr/share/bbb-fsesl-akka/conf/application.conf
sudo yq w -i /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml freeswitch.esl_password "$ESL_PASSWORD"
sudo xmlstarlet edit --inplace --update 'configuration/settings//param[@name="password"]/@value' --value $ESL_PASSWORD /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml
echo "Restarting the BigBlueButton $BIGBLUEBUTTON_RELEASE ..."

View File

@ -81,7 +81,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
});
</script>
<script src="compatibility/adapter.js?v=VERSION" language="javascript"></script>
<script src="compatibility/bowser.js?v=VERSION" language="javascript"></script>
<script src="compatibility/sip.js?v=VERSION" language="javascript"></script>
<script src="compatibility/kurento-extension.js?v=VERSION" language="javascript"></script>
<script src="compatibility/kurento-utils.js?v=VERSION" language="javascript"></script>
@ -89,7 +88,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
<body style="background-color: #06172A">
<div id="app" role="document"></div>
<span id="destination"></span>
<audio id="remote-media" autoPlay="autoplay">
<track kind="captions" /> {/* These captions are brought to you by eslint */}
<audio id="remote-media" autoplay>
</audio>
</body>

View File

@ -2,8 +2,6 @@ import { check } from 'meteor/check';
const ANNOTATION_TYPE_TEXT = 'text';
const ANNOTATION_TYPE_PENCIL = 'pencil';
const DEFAULT_TEXT_WIDTH = 30;
const DEFAULT_TEXT_HEIGHT = 20;
// line, triangle, ellipse, rectangle
function handleCommonAnnotation(meetingId, whiteboardId, userId, annotation) {
@ -41,23 +39,6 @@ function handleTextUpdate(meetingId, whiteboardId, userId, annotation) {
id, status, annotationType, annotationInfo, wbId, position,
} = annotation;
const { textBoxWidth, textBoxHeight, calcedFontSize } = annotationInfo;
const useDefaultSize = (textBoxWidth === 0 && textBoxHeight === 0)
|| textBoxWidth < calcedFontSize
|| textBoxHeight < calcedFontSize;
if (useDefaultSize) {
annotationInfo.textBoxWidth = DEFAULT_TEXT_WIDTH;
annotationInfo.textBoxHeight = DEFAULT_TEXT_HEIGHT;
if (100 - annotationInfo.x < DEFAULT_TEXT_WIDTH) {
annotationInfo.textBoxWidth = 100 - annotationInfo.x;
}
if (100 - annotationInfo.y < DEFAULT_TEXT_HEIGHT) {
annotationInfo.textBoxHeight = 100 - annotationInfo.y;
}
}
const selector = {
meetingId,
id,

View File

@ -60,14 +60,22 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
let iceServers = [];
try {
logger.info({
logCode: 'sfuaudiobridge_stunturn_fetch_start',
extraInfo: { iceServers },
}, 'SFU audio bridge starting STUN/TURN fetch');
iceServers = await fetchWebRTCMappedStunTurnServers(this.user.sessionToken);
} catch (error) {
logger.error({ logCode: 'sfuaudiobridge_stunturn_fetch_failed' },
'SFU audio bridge failed to fetch STUN/TURN info, using default servers');
iceServers = getMappedFallbackStun();
} finally {
logger.debug({ logCode: 'sfuaudiobridge_stunturn_fetch_sucess', extraInfo: { iceServers } },
'SFU audio bridge got STUN/TURN servers');
logger.info({
logCode: 'sfuaudiobridge_stunturn_fetch_sucess',
extraInfo: { iceServers },
}, 'SFU audio bridge got STUN/TURN servers');
const options = {
wsUrl: Auth.authenticateURL(SFU_URL),
userName: this.user.name,
@ -131,12 +139,25 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
this.hasSuccessfullyStarted = true;
if (webRtcPeer) {
logger.info({
logCode: 'sfuaudiobridge_audio_negotiation_success',
}, 'SFU audio bridge negotiated audio with success');
const stream = webRtcPeer.getRemoteStream();
audioTag.pause();
audioTag.srcObject = stream;
audioTag.muted = false;
logger.info({
logCode: 'sfuaudiobridge_audio_ready_to_play',
}, 'SFU audio bridge is ready to play');
playElement();
} else {
logger.info({
logCode: 'sfuaudiobridge_audio_negotiation_failed',
}, 'SFU audio bridge failed to negotiate audio');
this.callback({
status: this.baseCallStates.failed,
error: this.baseErrorCodes.CONNECTION_ERROR,
@ -218,6 +239,9 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
return reject(new Error('Invalid bridge option'));
}
logger.info({
logCode: 'sfuaudiobridge_ready_to_join_audio',
}, 'SFU audio bridge is ready to join audio');
window.kurentoJoinAudio(
MEDIA_TAG,
this.voiceBridge,

View File

@ -1,7 +1,10 @@
import browser from 'browser-detect';
import BaseAudioBridge from './base';
import logger from '/imports/startup/client/logger';
import { fetchStunTurnServers, getFallbackStun } from '/imports/utils/fetchStunTurnServers';
import {
fetchWebRTCMappedStunTurnServers,
getMappedFallbackStun,
} from '/imports/utils/fetchStunTurnServers';
import {
isUnifiedPlan,
toUnifiedPlan,
@ -20,13 +23,13 @@ const MEDIA_TAG = MEDIA.mediaTag;
const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
const RELAY_ONLY_ON_RECONNECT = MEDIA.relayOnlyOnReconnect;
const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain;
const ICE_NEGOTIATION_FAILED = ['iceConnectionFailed'];
const CALL_CONNECT_TIMEOUT = 20000;
const ICE_NEGOTIATION_TIMEOUT = 20000;
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
const USER_AGENT_RECONNECTION_ATTEMPTS = 3;
const USER_AGENT_RECONNECTION_DELAY_MS = 5000;
const USER_AGENT_CONNECTION_TIMEOUT_MS = 5000;
const getAudioSessionNumber = () => {
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
@ -39,6 +42,24 @@ const getAudioSessionNumber = () => {
return currItem;
};
/**
* Get error code from SIP.js websocket messages.
*/
const getErrorCode = (error) => {
try {
if (!error) return error;
const match = error.message.match(/code: \d+/g);
const _codeArray = match[0].split(':');
return parseInt(_codeArray[1].trim(), 10);
} catch (e) {
return 0;
}
};
class SIPSession {
constructor(user, userData, protocol, hostname,
baseCallStates, baseErrorCodes, reconnectAttempt) {
@ -49,9 +70,15 @@ class SIPSession {
this.baseCallStates = baseCallStates;
this.baseErrorCodes = baseErrorCodes;
this.reconnectAttempt = reconnectAttempt;
this.currentSession = null;
this.remoteStream = null;
this.inputDeviceId = null;
this._hangupFlag = false;
this._reconnecting = false;
this._currentSessionState = null;
}
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
joinAudio({ isListenOnly, extension, inputDeviceId }, managerCallback) {
return new Promise((resolve, reject) => {
const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge;
@ -78,7 +105,7 @@ class SIPSession {
// If there's an extension passed it means that we're joining the echo test first
this.inEchoTest = !!extension;
return this.doCall({ callExtension, isListenOnly, inputStream })
return this.doCall({ callExtension, isListenOnly, inputDeviceId })
.catch((reason) => {
reject(reason);
});
@ -87,7 +114,7 @@ class SIPSession {
async getIceServers(sessionToken) {
try {
const iceServers = await fetchStunTurnServers(sessionToken);
const iceServers = await fetchWebRTCMappedStunTurnServers(sessionToken);
return iceServers;
} catch (error) {
logger.error({
@ -98,15 +125,18 @@ class SIPSession {
callerIdName: this.user.callerIdName,
},
}, 'Full audio bridge failed to fetch STUN/TURN info');
return getFallbackStun();
return getMappedFallbackStun();
}
}
doCall(options) {
const {
isListenOnly,
inputDeviceId,
} = options;
this.inputDeviceId = inputDeviceId;
const {
userId,
name,
@ -124,8 +154,7 @@ class SIPSession {
return this.getIceServers(sessionToken)
.then(this.createUserAgent.bind(this))
.then(this.inviteUserAgent.bind(this))
.then(this.setupEventHandlers.bind(this));
.then(this.inviteUserAgent.bind(this));
}
transferCall(onTransferSuccess) {
@ -149,7 +178,18 @@ class SIPSession {
}, CALL_TRANSFER_TIMEOUT);
// This is is the call transfer code ask @chadpilkey
this.currentSession.dtmf(1);
if (this.sessionSupportRTPPayloadDtmf(this.currentSession)) {
this.currentSession.sessionDescriptionHandler.sendDtmf(1);
} else {
// RFC4733 not supported , sending DTMF through INFO
logger.debug({
logCode: 'sip_js_rtp_payload_dtmf_not_supported',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'Browser do not support payload dtmf, using INFO instead');
this.sendDtmf(1);
}
Tracker.autorun((c) => {
trackerControl = c;
@ -171,28 +211,82 @@ class SIPSession {
});
}
/**
*
* sessionSupportRTPPayloadDtmf
* tells if browser support RFC4733 DTMF.
* Safari 13 doens't support it yet
*/
sessionSupportRTPPayloadDtmf(session) {
try {
const sessionDescriptionHandler = session
? session.sessionDescriptionHandler
: this.currentSession.sessionDescriptionHandler;
const senders = sessionDescriptionHandler.peerConnection.getSenders();
return !!(senders[0].dtmf);
} catch (error) {
return false;
}
}
/**
* sendDtmf - send DTMF Tones using INFO message
*
* same as SimpleUser's dtmf
*/
sendDtmf(tone) {
const dtmf = tone;
const duration = 2000;
const body = {
contentDisposition: 'render',
contentType: 'application/dtmf-relay',
content: `Signal=${dtmf}\r\nDuration=${duration}`,
};
const requestOptions = { body };
return this.currentSession.info({ requestOptions });
}
exitAudio() {
return new Promise((resolve, reject) => {
let hangupRetries = 0;
let hangup = false;
this._hangupFlag = false;
this.userRequestedHangup = true;
if (this.currentSession) {
const { mediaHandler } = this.currentSession;
// Removing termination events to avoid triggering an error
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e));
}
const tryHangup = () => {
if ((this.currentSession && this.currentSession.endTime)
|| (this.userAgent && this.userAgent.status === SIP.UA.C.STATUS_USER_CLOSED)) {
hangup = true;
if (this._hangupFlag) {
resolve();
}
if ((this.currentSession
&& (this.currentSession.state === SIP.SessionState.Terminated))
|| (this.userAgent && (!this.userAgent.isConnected()))) {
this._hangupFlag = true;
return resolve();
}
if (this.currentSession) this.currentSession.bye();
if (this.userAgent) this.userAgent.stop();
if (this.currentSession
&& ((this.currentSession.state === SIP.SessionState.Establishing))) {
this.currentSession.cancel().then(() => {
this._hangupFlag = true;
return resolve();
});
}
if (this.currentSession
&& ((this.currentSession.state === SIP.SessionState.Established))) {
this.currentSession.bye().then(() => {
this._hangupFlag = true;
return resolve();
});
}
if (this.userAgent && this.userAgent.isConnected()) {
this.userAgent.stop();
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
hangupRetries += 1;
@ -206,23 +300,24 @@ class SIPSession {
return reject(this.baseErrorCodes.REQUEST_TIMEOUT);
}
if (!hangup) return tryHangup();
if (!this._hangupFlag) return tryHangup();
return resolve();
}, CALL_HANGUP_TIMEOUT);
};
if (this.currentSession) {
this.currentSession.on('bye', () => {
hangup = true;
resolve();
});
}
return tryHangup();
});
}
createUserAgent({ stun, turn }) {
onBeforeUnload() {
if (this.userAgent) {
return this.userAgent.stop();
}
return Promise.resolve();
}
createUserAgent(iceServers) {
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
@ -236,17 +331,6 @@ class SIPSession {
sessionToken,
} = this.user;
// WebView safari needs a transceiver to be added. Made it a SIP.js hack.
// Don't like the UA picking though, we should straighten everything to user
// transceivers - prlanzarin 2019/05/21
const browserUA = window.navigator.userAgent.toLocaleLowerCase();
const isSafariWebview = ((browserUA.indexOf('iphone') > -1
|| browserUA.indexOf('ipad') > -1) && browserUA.indexOf('safari') === -1);
// Second UA check to get all Safari browsers to enable Unified Plan <-> PlanB
// translation
const isSafari = browser().name === 'safari';
logger.debug({ logCode: 'sip_js_creating_user_agent', extraInfo: { callerIdName } }, 'Creating the user agent');
if (this.userAgent && this.userAgent.isConnected()) {
@ -275,112 +359,261 @@ class SIPSession {
let userAgentConnected = false;
const token = `sessionToken=${sessionToken}`;
this.userAgent = new window.SIP.UA({
uri: `sip:${encodeURIComponent(callerIdName)}@${hostname}`,
wsServers: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
this.userAgent = new SIP.UserAgent({
uri: SIP.UserAgent.makeURI(`sip:${encodeURIComponent(callerIdName)}@${hostname}`),
transportOptions: {
server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS,
},
sessionDescriptionHandlerFactoryOptions: {
peerConnectionConfiguration: {
iceServers,
},
},
displayName: callerIdName,
register: false,
traceSip: true,
autostart: false,
userAgentString: 'BigBlueButton',
stunServers: stun,
turnServers: turn,
hackPlanBUnifiedPlanTranslation: isSafari,
hackAddAudioTransceiver: isSafariWebview,
relayOnlyOnReconnect: this.reconnectAttempt && RELAY_ONLY_ON_RECONNECT,
localSdpCallback,
remoteSdpCallback,
});
const handleUserAgentConnection = () => {
userAgentConnected = true;
resolve(this.userAgent);
if (!userAgentConnected) {
userAgentConnected = true;
resolve(this.userAgent);
}
};
const handleUserAgentDisconnection = () => {
if (this.userAgent) {
this.userAgent.removeAllListeners();
this.userAgent.stop();
if (this.userRequestedHangup) {
userAgentConnected = false;
return;
}
let error;
let bridgeError;
if (!this._reconnecting) {
logger.info({
logCode: 'sip_js_session_ua_disconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent disconnected: trying to reconnect...'
+ ` (userHangup = ${!!this.userRequestedHangup})`);
logger.info({
logCode: 'sip_js_session_ua_reconnecting',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent disconnected, reconnecting');
this.reconnect().then(() => {
logger.info({
logCode: 'sip_js_session_ua_reconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent succesfully reconnected');
}).catch(() => {
if (userAgentConnected) {
error = 1001;
bridgeError = 'Websocket disconnected';
} else {
error = 1002;
bridgeError = 'Websocket failed to connect';
}
this.callback({
status: this.baseCallStates.failed,
error,
bridgeError,
});
reject(this.baseErrorCodes.CONNECTION_ERROR);
});
}
}
let error;
let bridgeError;
if (this.userRequestedHangup) return;
if (userAgentConnected) {
error = 1001;
bridgeError = 'Websocket disconnected';
} else {
error = 1002;
bridgeError = 'Websocket failed to connect';
}
this.callback({
status: this.baseCallStates.failed,
error,
bridgeError,
});
reject(this.baseErrorCodes.CONNECTION_ERROR);
};
this.userAgent.on('connected', handleUserAgentConnection);
this.userAgent.on('disconnected', handleUserAgentDisconnection);
this.userAgent.transport.onConnect = handleUserAgentConnection;
this.userAgent.transport.onDisconnect = handleUserAgentDisconnection;
this.userAgent.start();
const preturn = this.userAgent.start().then(() => {
logger.info({
logCode: 'sip_js_session_ua_connected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent succesfully connected');
window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
resolve();
}).catch((error) => {
logger.info({
logCode: 'sip_js_session_ua_reconnecting',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent failed to connect, reconnecting');
const code = getErrorCode(error);
if (code === 1006) {
this.callback({
status: this.baseCallStates.failed,
error: 1006,
bridgeError: 'Websocket failed to connect',
});
return reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}
this.reconnect().then(() => {
logger.info({
logCode: 'sip_js_session_ua_reconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent succesfully reconnected');
resolve();
}).catch(() => {
logger.info({
logCode: 'sip_js_session_ua_disconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent failed to reconnect after'
+ ` ${USER_AGENT_RECONNECTION_ATTEMPTS} attemps`);
this.callback({
status: this.baseCallStates.failed,
error: 1002,
bridgeError: 'Websocket failed to connect',
});
reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
});
});
return preturn;
});
}
reconnect(attempts = 1) {
return new Promise((resolve, reject) => {
if (this._reconnecting) {
return resolve();
}
if (attempts > USER_AGENT_RECONNECTION_ATTEMPTS) {
return reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}
this._reconnecting = true;
setTimeout(() => {
this.userAgent.reconnect().then(() => {
this._reconnecting = false;
resolve();
}).catch(() => {
this._reconnecting = false;
this.reconnect(++attempts).then(() => {
resolve();
}).catch((error) => {
reject(error);
});
});
}, USER_AGENT_RECONNECTION_DELAY_MS);
});
}
inviteUserAgent(userAgent) {
if (this.userRequestedHangup === true) Promise.reject();
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
const {
hostname,
} = this;
const {
hostname,
} = this;
const {
callExtension,
isListenOnly,
} = this.callOptions;
const {
inputStream,
callExtension,
} = this.callOptions;
const options = {
media: {
stream: inputStream,
constraints: {
audio: true,
video: false,
const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`);
const audioDeviceConstraint = this.inputDeviceId
? { deviceId: { exact: this.inputDeviceId } }
: true;
const inviterOptions = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: isListenOnly
? false
: audioDeviceConstraint,
video: false,
},
},
render: {
remote: document.querySelector(MEDIA_TAG),
},
},
RTCConstraints: {
offerToReceiveAudio: true,
offerToReceiveVideo: false,
},
};
sessionDescriptionHandlerModifiersPostICEGathering:
[stripMDnsCandidates],
};
return userAgent.invite(`sip:${callExtension}@${hostname}`, options);
if (isListenOnly) {
inviterOptions.sessionDescriptionHandlerOptions.offerOptions = {
offerToReceiveAudio: true,
};
}
const inviter = new SIP.Inviter(userAgent, target, inviterOptions);
this.currentSession = inviter;
this.setupEventHandlers(inviter).then(() => {
inviter.invite().then(() => {
resolve();
}).catch(e => reject(e));
});
});
}
setupEventHandlers(currentSession) {
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
const { mediaHandler } = currentSession;
let iceCompleted = false;
let fsReady = false;
this.currentSession = currentSession;
const setupRemoteMedia = () => {
const mediaElement = document.querySelector(MEDIA_TAG);
let connectionCompletedEvents = ['iceConnectionCompleted', 'iceConnectionConnected'];
// Edge sends a connected first and then a completed, but the call isn't ready until
// the completed comes in. Due to the way that we have the listeners set up, the only
// way to ignore one status is to not listen for it.
if (browser().name === 'edge') {
connectionCompletedEvents = ['iceConnectionCompleted'];
}
this.remoteStream = new MediaStream();
this.currentSession.sessionDescriptionHandler
.peerConnection.getReceivers().forEach((receiver) => {
if (receiver.track) {
this.remoteStream.addTrack(receiver.track);
}
});
logger.info({
logCode: 'sip_js_session_playing_remote_media',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'Audio call - playing remote media');
mediaElement.srcObject = this.remoteStream;
mediaElement.play();
};
const checkIfCallReady = () => {
if (this.userRequestedHangup === true) {
@ -388,8 +621,28 @@ class SIPSession {
resolve();
}
logger.info({
logCode: 'sip_js_session_check_if_call_ready',
extraInfo: {
iceCompleted,
fsReady,
},
}, 'Audio call - check if ICE is finished and FreeSWITCH is ready');
if (iceCompleted && fsReady) {
this.webrtcConnected = true;
setupRemoteMedia();
const { sdp } = this.currentSession.sessionDescriptionHandler
.peerConnection.remoteDescription;
logger.info({
logCode: 'sip_js_session_setup_remote_media',
extraInfo: {
callerIdName: this.user.callerIdName,
sdp,
},
}, 'Audio call - setup remote media');
this.callback({ status: this.baseCallStates.started });
resolve();
}
@ -412,7 +665,6 @@ class SIPSession {
const handleSessionAccepted = () => {
logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted');
clearTimeout(callTimeout);
currentSession.off('accepted', handleSessionAccepted);
// If ICE isn't connected yet then start timeout waiting for ICE to finish
if (!iceCompleted) {
@ -420,45 +672,114 @@ class SIPSession {
this.callback({
status: this.baseCallStates.failed,
error: 1010,
bridgeError: `ICE negotiation timeout after ${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
bridgeError: 'ICE negotiation timeout after '
+ `${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
});
this.exitAudio();
reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}, ICE_NEGOTIATION_TIMEOUT);
}
};
currentSession.on('accepted', handleSessionAccepted);
const handleSessionProgress = (update) => {
logger.info({ logCode: 'sip_js_session_progress', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session progress update');
clearTimeout(callTimeout);
currentSession.off('progress', handleSessionProgress);
};
currentSession.on('progress', handleSessionProgress);
const handleConnectionCompleted = (peer) => {
logger.info({
logCode: 'sip_js_ice_connection_success',
extraInfo: {
currentState: peer.iceConnectionState,
callerIdName: this.user.callerIdName,
},
}, `ICE connection success. Current state - ${peer.iceConnectionState}`);
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
connectionCompletedEvents.forEach(e => mediaHandler.off(e, handleConnectionCompleted));
iceCompleted = true;
logSelectedCandidate(peer, this.protocolIsIpv6);
checkIfCallReady();
};
connectionCompletedEvents.forEach(e => mediaHandler.on(e, handleConnectionCompleted));
const handleIceNegotiationFailed = (peer) => {
if (iceCompleted) {
logger.error({
logCode: 'sipjs_ice_failed_after',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'ICE connection failed after success');
} else {
logger.error({
logCode: 'sipjs_ice_failed_before',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'ICE connection failed before success');
}
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
this.callback({
status: this.baseCallStates.failed,
error: 1007,
bridgeError: 'ICE negotiation failed. Current state '
+ `- ${peer.iceConnectionState}`,
});
};
const handleIceConnectionTerminated = (peer) => {
if (!this.userRequestedHangup) {
logger.error({
logCode: 'sipjs_ice_closed',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'ICE connection closed');
}
this.callback({
status: this.baseCallStates.failed,
error: 1012,
bridgeError: 'ICE connection closed. Current state -'
+ `${peer.iceConnectionState}`,
});
};
const handleSessionProgress = (update) => {
logger.info({
logCode: 'sip_js_session_progress',
extraInfo: {
callerIdName: this.user.callerIdName,
update,
},
}, 'Audio call session progress update');
this.currentSession.sessionDescriptionHandler.peerConnectionDelegate = {
onconnectionstatechange: (event) => {
const peer = event.target;
switch (peer.connectionState) {
case 'connected':
logger.info({
logCode: 'sip_js_ice_connection_success',
extraInfo: {
currentState: peer.connectionState,
callerIdName: this.user.callerIdName,
},
}, 'ICE connection success. Current state - '
+ `${peer.iceConnectionState}`);
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
iceCompleted = true;
logSelectedCandidate(peer, this.protocolIsIpv6);
checkIfCallReady();
break;
case 'failed':
handleIceNegotiationFailed(peer);
break;
case 'closed':
handleIceConnectionTerminated(peer);
break;
default:
break;
}
},
};
};
const handleSessionTerminated = (message, cause) => {
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
currentSession.off('terminated', handleSessionTerminated);
if (!message && !cause && !!this.userRequestedHangup) {
return this.callback({
@ -466,6 +787,10 @@ class SIPSession {
});
}
// if session hasn't even started, we let audio-modal to handle
// any possile errors
if (!this._currentSessionState) return false;
logger.error({
logCode: 'sip_js_call_terminated',
extraInfo: { cause, callerIdName: this.user.callerIdName },
@ -484,39 +809,33 @@ class SIPSession {
bridgeError: cause,
});
};
currentSession.on('terminated', handleSessionTerminated);
const handleIceNegotiationFailed = (peer) => {
if (iceCompleted) {
logger.error({ logCode: 'sipjs_ice_failed_after', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed after success');
} else {
logger.error({ logCode: 'sipjs_ice_failed_before', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed before success');
currentSession.stateChange.addListener((state) => {
switch (state) {
case SIP.SessionState.Initial:
break;
case SIP.SessionState.Establishing:
handleSessionProgress();
break;
case SIP.SessionState.Established:
handleSessionAccepted();
break;
case SIP.SessionState.Terminating:
break;
case SIP.SessionState.Terminated:
handleSessionTerminated();
break;
default:
logger.error({
logCode: 'sipjs_ice_session_unknown_state',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'SIP.js unknown session state');
break;
}
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e, handleIceNegotiationFailed));
this.callback({
status: this.baseCallStates.failed,
error: 1007,
bridgeError: `ICE negotiation failed. Current state - ${peer.iceConnectionState}`,
});
};
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.on(e, handleIceNegotiationFailed));
const handleIceConnectionTerminated = (peer) => {
['iceConnectionClosed'].forEach(e => mediaHandler.off(e, handleIceConnectionTerminated));
if (!this.userRequestedHangup) {
logger.error({ logCode: 'sipjs_ice_closed', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection closed');
}
/*
this.callback({
status: this.baseCallStates.failed,
error: 1012,
bridgeError: "ICE connection closed. Current state - " + peer.iceConnectionState,
});
*/
};
['iceConnectionClosed'].forEach(e => mediaHandler.on(e, handleIceConnectionTerminated));
this._currentSessionState = state;
});
Tracker.autorun((c) => {
const selector = { meetingId: Auth.meetingID, userId: Auth.userID };
@ -534,6 +853,8 @@ class SIPSession {
},
});
});
resolve();
});
}
}
@ -571,7 +892,11 @@ export default class SIPBridge extends BaseAudioBridge {
window.clientLogger = logger;
}
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
get inputDeviceId () {
return this.media.inputDevice ? this.media.inputDevice.inputDeviceId : null;
}
joinAudio({ isListenOnly, extension }, managerCallback) {
const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== '';
return new Promise((resolve, reject) => {
@ -603,7 +928,12 @@ export default class SIPBridge extends BaseAudioBridge {
const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined;
this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
hostname, this.baseCallStates, this.baseErrorCodes, true);
this.activeSession.joinAudio({ isListenOnly, extension: fallbackExtension, inputStream }, callback)
const { inputDeviceId } = this.media.inputDevice;
this.activeSession.joinAudio({
isListenOnly,
extension: fallbackExtension,
inputDeviceId,
}, callback)
.then((value) => {
resolve(value);
}).catch((reason) => {
@ -615,7 +945,12 @@ export default class SIPBridge extends BaseAudioBridge {
return managerCallback(message);
};
this.activeSession.joinAudio({ isListenOnly, extension, inputStream }, callback)
const { inputDeviceId } = this.media.inputDevice;
this.activeSession.joinAudio({
isListenOnly,
extension,
inputDeviceId,
}, callback)
.then((value) => {
resolve(value);
}).catch((reason) => {
@ -630,8 +965,8 @@ export default class SIPBridge extends BaseAudioBridge {
getPeerConnection() {
const { currentSession } = this.activeSession;
if (currentSession && currentSession.mediaHandler) {
return currentSession.mediaHandler.peerConnection;
if (currentSession && currentSession.sessionDescriptionHandler) {
return currentSession.sessionDescriptionHandler.peerConnection;
}
return null;
}
@ -641,62 +976,16 @@ export default class SIPBridge extends BaseAudioBridge {
}
setDefaultInputDevice() {
const handleMediaSuccess = (mediaStream) => {
const deviceLabel = mediaStream.getAudioTracks()[0].label;
window.defaultInputStream = mediaStream.getTracks();
return navigator.mediaDevices.enumerateDevices().then((mediaDevices) => {
const device = mediaDevices.find(d => d.label === deviceLabel);
return this.changeInputDevice(device.deviceId, deviceLabel);
});
};
return navigator.mediaDevices.getUserMedia({ audio: true }).then(handleMediaSuccess);
this.media.inputDevice.inputDeviceId = DEFAULT_INPUT_DEVICE_ID;
}
changeInputDevice(deviceId, deviceLabel) {
const {
media,
} = this;
if (media.inputDevice.audioContext) {
const handleAudioContextCloseSuccess = () => {
media.inputDevice.audioContext = null;
media.inputDevice.scriptProcessor = null;
media.inputDevice.source = null;
return this.changeInputDevice(deviceId);
};
return media.inputDevice.audioContext.close().then(handleAudioContextCloseSuccess);
async changeInputDeviceId(inputDeviceId) {
if (!inputDeviceId) {
throw new Error();
}
if ('AudioContext' in window) {
media.inputDevice.audioContext = new window.AudioContext();
} else {
media.inputDevice.audioContext = new window.webkitAudioContext();
}
media.inputDevice.id = deviceId;
media.inputDevice.label = deviceLabel;
media.inputDevice.scriptProcessor = media.inputDevice.audioContext
.createScriptProcessor(2048, 1, 1);
media.inputDevice.source = null;
const constraints = {
audio: {
deviceId,
},
};
const handleMediaSuccess = (mediaStream) => {
media.inputDevice.stream = mediaStream;
media.inputDevice.source = media.inputDevice.audioContext
.createMediaStreamSource(mediaStream);
media.inputDevice.source.connect(media.inputDevice.scriptProcessor);
media.inputDevice.scriptProcessor.connect(media.inputDevice.audioContext.destination);
return this.media.inputDevice;
};
return navigator.mediaDevices.getUserMedia(constraints).then(handleMediaSuccess);
this.media.inputDevice.inputDeviceId = inputDeviceId;
return inputDeviceId;
}
async changeOutputDevice(value) {

View File

@ -6,11 +6,14 @@ import { extractCredentials } from '/imports/api/common/server/helpers';
export default function createBreakoutRoom(rooms, durationInMinutes, record = false) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const BREAKOUT_LIM = Meteor.settings.public.app.breakoutRoomLimit;
const MIN_BREAKOUT_ROOMS = 2;
const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
const { meetingId, requesterUserId } = extractCredentials(this.userId);
const eventName = 'CreateBreakoutRoomsCmdMsg';
if (rooms.length > 8) return Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`);
if (rooms.length > MAX_BREAKOUT_ROOMS) return Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`);
const payload = {
record,
durationInMinutes,

View File

@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import Logger from '/imports/startup/server/logger';
import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers';
@ -10,16 +11,29 @@ export default function startWatchingExternalVideo(options) {
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'StartExternalVideoMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
const { meetingId, requesterUserId: userId } = extractCredentials(this.userId);
const { externalVideoUrl } = options;
check(externalVideoUrl, String);
try {
check(meetingId, String);
check(userId, String);
check(externalVideoUrl, String);
Meetings.update({ meetingId }, { $set: { externalVideoUrl } });
const user = Users.findOne({ meetingId, userId, presenter: true }, { presenter: 1 });
const payload = { externalVideoUrl };
if (!user) {
Logger.error(`Only presenters are allowed to start external video for a meeting. meeting=${meetingId} userId=${userId}`);
return;
}
Logger.info(`User id=${requesterUserId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`);
Meetings.update({ meetingId }, { $set: { externalVideoUrl } });
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
const payload = { externalVideoUrl };
Logger.info(`User id=${userId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`);
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
} catch (error) {
Logger.error(`Error on sharing an external video: ${externalVideoUrl} ${error}`);
}
}

View File

@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import Logger from '/imports/startup/server/logger';
import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers';
@ -9,19 +10,33 @@ export default function stopWatchingExternalVideo(options) {
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'StopExternalVideoMsg';
if (this.userId) {
options = extractCredentials(this.userId);
const { meetingId, requesterUserId } = this.userId ? extractCredentials(this.userId) : options;
try {
check(meetingId, String);
check(requesterUserId, String);
const user = Users.findOne({
meetingId,
userId: requesterUserId,
presenter: true,
}, { presenter: 1 });
if (this.userId && !user) {
Logger.error(`Only presenters are allowed to stop external video for a meeting. meeting=${meetingId} userId=${requesterUserId}`);
return;
}
const meeting = Meetings.findOne({ meetingId });
if (!meeting || meeting.externalVideoUrl === null) return;
Meetings.update({ meetingId }, { $set: { externalVideoUrl: null } });
const payload = {};
Logger.info(`User id=${requesterUserId} stopped sharing an external video for meeting=${meetingId}`);
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
} catch (error) {
Logger.error(`Error on stop sharing an external video for meeting=${meetingId} ${error}`);
}
const { meetingId, requesterUserId } = options;
const meeting = Meetings.findOne({ meetingId });
if (!meeting || meeting.externalVideoUrl === null) return;
Meetings.update({ meetingId }, { $set: { externalVideoUrl: null } });
const payload = {};
Logger.info(`User id=${requesterUserId} stopped sharing an external video for meeting=${meetingId}`);
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -12,7 +12,7 @@ export default function endMeeting() {
const payload = {
userId: requesterUserId,
};
Logger.verbose(`Meeting '${meetingId}' is destroyed by '${requesterUserId}'`);
Logger.warn(`Meeting '${meetingId}' is destroyed by '${requesterUserId}'`);
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -22,6 +22,7 @@ import clearLocalSettings from '/imports/api/local-settings/server/modifiers/cle
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 BannedUsers from '/imports/api/users/server/store/bannedUsers';
export default function meetingHasEnded(meetingId) {
removeAnnotationsStreamer(meetingId);
@ -46,6 +47,7 @@ export default function meetingHasEnded(meetingId) {
clearRecordMeeting(meetingId);
clearVoiceCallStates(meetingId);
clearVideoStreams(meetingId);
BannedUsers.delete(meetingId);
return Logger.info(`Cleared Meetings with id ${meetingId}`);
});

View File

@ -4,34 +4,15 @@ import Polls from '/imports/api/polls';
import Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function publishVote(id, pollAnswerId) { // TODO discuss location
export default function publishVote(pollId, pollAnswerId) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'RespondToPollReqMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
/*
We keep an array of people who were in the meeting at the time the poll
was started. The poll is published to them only.
Once they vote - their ID is removed and they cannot see the poll anymore
*/
const currentPoll = Polls.findOne({
users: requesterUserId,
meetingId,
'answers.id': pollAnswerId,
id,
});
check(pollAnswerId, Number);
check(currentPoll, Object);
check(currentPoll.meetingId, String);
const payload = {
requesterId: requesterUserId,
pollId: currentPoll.id,
questionId: 0,
answerId: pollAnswerId,
};
check(pollId, String);
const selector = {
users: requesterUserId,
@ -39,6 +20,18 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
'answers.id': pollAnswerId,
};
const payload = {
requesterId: requesterUserId,
pollId,
questionId: 0,
answerId: pollAnswerId,
};
/*
We keep an array of people who were in the meeting at the time the poll
was started. The poll is published to them only.
Once they vote - their ID is removed and they cannot see the poll anymore
*/
const modifier = {
$pull: {
users: requesterUserId,
@ -47,11 +40,11 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
const cb = (err) => {
if (err) {
return Logger.error(`Updating Polls collection: ${err}`);
return Logger.error(`Removing responded user from Polls collection: ${err}`);
}
return Logger.info(`Updating Polls collection (meetingId: ${meetingId}, `
+ `pollId: ${currentPoll.id}!)`);
return Logger.info(`Removed responded user=${requesterUserId} from poll (meetingId: ${meetingId}, `
+ `pollId: ${pollId}!)`);
};
Polls.update(selector, modifier, cb);

View File

@ -63,15 +63,80 @@ export default class KurentoScreenshareBridge {
return normalizedError;
}
async kurentoWatchVideo() {
static playElement(screenshareMediaElement) {
const mediaTagPlayed = () => {
logger.info({
logCode: 'screenshare_media_play_success',
}, 'Screenshare media played successfully');
};
if (screenshareMediaElement.paused) {
// Tag isn't playing yet. Play it.
screenshareMediaElement.play()
.then(mediaTagPlayed)
.catch((error) => {
// NotAllowedError equals autoplay issues, fire autoplay handling event.
// This will be handled in the screenshare react component.
if (error.name === 'NotAllowedError') {
logger.error({
logCode: 'screenshare_error_autoplay',
extraInfo: { errorName: error.name },
}, 'Screenshare play failed due to autoplay error');
const tagFailedEvent = new CustomEvent('screensharePlayFailed',
{ detail: { mediaElement: screenshareMediaElement } });
window.dispatchEvent(tagFailedEvent);
} else {
// Tag failed for reasons other than autoplay. Log the error and
// try playing again a few times until it works or fails for good
const played = playAndRetry(screenshareMediaElement);
if (!played) {
logger.error({
logCode: 'screenshare_error_media_play_failed',
extraInfo: { errorName: error.name },
}, `Screenshare media play failed due to ${error.name}`);
} else {
mediaTagPlayed();
}
}
});
} else {
// Media tag is already playing, so log a success. This is really a
// logging fallback for a case that shouldn't happen. But if it does
// (ie someone re-enables the autoPlay prop in the element), then it
// means the stream is playing properly and it'll be logged.
mediaTagPlayed();
}
}
static screenshareElementLoadAndPlay(stream, element, muted) {
element.muted = muted;
element.pause();
element.srcObject = stream;
KurentoScreenshareBridge.playElement(element);
}
kurentoViewLocalPreview() {
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
const { webRtcPeer } = window.kurentoManager.kurentoScreenshare;
if (webRtcPeer) {
const stream = webRtcPeer.getLocalStream();
KurentoScreenshareBridge.screenshareElementLoadAndPlay(stream, screenshareMediaElement, true);
}
}
async kurentoViewScreen() {
const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
let iceServers = [];
let started = false;
try {
iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken());
} catch (error) {
logger.error({ logCode: 'screenshare_viwer_fetchstunturninfo_error', extraInfo: { error } },
'Screenshare bridge failed to fetch STUN/TURN info, using default');
logger.error({
logCode: 'screenshare_viewer_fetchstunturninfo_error',
extraInfo: { error },
}, 'Screenshare bridge failed to fetch STUN/TURN info, using default');
iceServers = getMappedFallbackStun();
} finally {
const options = {
@ -81,52 +146,6 @@ export default class KurentoScreenshareBridge {
userName: getUsername(),
};
const screenshareTag = document.getElementById(SCREENSHARE_VIDEO_TAG);
const playElement = () => {
const mediaTagPlayed = () => {
logger.info({
logCode: 'screenshare_viewer_media_play_success',
}, 'Screenshare viewer media played successfully');
};
if (screenshareTag.paused) {
// Tag isn't playing yet. Play it.
screenshareTag.play()
.then(mediaTagPlayed)
.catch((error) => {
// NotAllowedError equals autoplay issues, fire autoplay handling event.
// This will be handled in the screenshare react component.
if (error.name === 'NotAllowedError') {
logger.error({
logCode: 'screenshare_viewer_error_autoplay',
extraInfo: { errorName: error.name },
}, 'Screenshare viewer play failed due to autoplay error');
const tagFailedEvent = new CustomEvent('screensharePlayFailed',
{ detail: { mediaElement: screenshareTag } });
window.dispatchEvent(tagFailedEvent);
} else {
// Tag failed for reasons other than autoplay. Log the error and
// try playing again a few times until it works or fails for good
const played = playAndRetry(screenshareTag);
if (!played) {
logger.error({
logCode: 'screenshare_viewer_error_media_play_failed',
extraInfo: { errorName: error.name },
}, `Screenshare viewer media play failed due to ${error.name}`);
} else {
mediaTagPlayed();
}
}
});
} else {
// Media tag is already playing, so log a success. This is really a
// logging fallback for a case that shouldn't happen. But if it does
// (ie someone re-enables the autoPlay prop in the element), then it
// means the stream is playing properly and it'll be logged.
mediaTagPlayed();
}
};
const onFail = (error) => {
KurentoScreenshareBridge.handleViewerFailure(error, started);
};
@ -139,10 +158,11 @@ export default class KurentoScreenshareBridge {
const { webRtcPeer } = window.kurentoManager.kurentoVideo;
if (webRtcPeer) {
const stream = webRtcPeer.getRemoteStream();
screenshareTag.muted = true;
screenshareTag.pause();
screenshareTag.srcObject = stream;
playElement();
KurentoScreenshareBridge.screenshareElementLoadAndPlay(
stream,
screenshareMediaElement,
true,
);
}
};

View File

@ -31,6 +31,7 @@ const oldParametersKeys = Object.keys(oldParameters);
const currentParameters = [
// APP
'bbb_ask_for_feedback_on_logout',
'bbb_override_default_locale',
'bbb_auto_join_audio',
'bbb_client_title',
'bbb_force_listen_only',
@ -69,7 +70,7 @@ const currentParameters = [
function valueParser(val) {
try {
const parsedValue = JSON.parse(val.toLowerCase());
const parsedValue = JSON.parse(val.toLowerCase().trim());
return parsedValue;
} catch (error) {
logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`);
@ -86,21 +87,22 @@ export default function addUserSettings(settings) {
settings.forEach((el) => {
const settingKey = Object.keys(el).shift();
const normalizedKey = settingKey.trim();
if (currentParameters.includes(settingKey)) {
if (!Object.keys(parameters).includes(settingKey)) {
if (currentParameters.includes(normalizedKey)) {
if (!Object.keys(parameters).includes(normalizedKey)) {
parameters = {
[settingKey]: valueParser(el[settingKey]),
[normalizedKey]: valueParser(el[settingKey]),
...parameters,
};
} else {
parameters[settingKey] = el[settingKey];
parameters[normalizedKey] = el[settingKey];
}
return;
}
if (oldParametersKeys.includes(settingKey)) {
const matchingNewKey = oldParameters[settingKey];
if (oldParametersKeys.includes(normalizedKey)) {
const matchingNewKey = oldParameters[normalizedKey];
if (!Object.keys(parameters).includes(matchingNewKey)) {
parameters = {
[matchingNewKey]: valueParser(el[settingKey]),
@ -110,7 +112,7 @@ export default function addUserSettings(settings) {
return;
}
logger.warn(`Parameter ${settingKey} not handled`);
logger.warn(`Parameter ${normalizedKey} not handled`);
});
const settingsAdded = [];

View File

@ -59,6 +59,7 @@ export default function handleValidateAuthToken({ body }, meetingId) {
/* Logic migrated from validateAuthToken method ( postponed to only run in case of success response ) - Begin */
const sessionId = `${meetingId}--${userId}`;
methodInvocationObject.setUserId(sessionId);
const User = Users.findOne({

View File

@ -24,6 +24,7 @@ export default function userLeaving(meetingId, userId, connectionId) {
// If the current user connection is not the same that triggered the leave we skip
if (User.connectionId !== connectionId) {
Logger.info(`Skipping userLeaving. User connectionId=${User.connectionId} is different from requester connectionId=${connectionId}`);
return false;
}

View File

@ -3,6 +3,7 @@ import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
import pendingAuthenticationsStore from '../store/pendingAuthentications';
import BannedUsers from '../store/bannedUsers';
import Users from '/imports/api/users';
export default function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) {
const REDIS_CONFIG = Meteor.settings.private.redis;
@ -13,10 +14,27 @@ export default function validateAuthToken(meetingId, requesterUserId, requesterT
if (externalId) {
if (BannedUsers.has(meetingId, externalId)) {
Logger.warn(`A banned user with extId ${externalId} tried to enter in meeting ${meetingId}`);
return;
return { invalid: true, reason: 'User has been banned', error_type: 'user_banned' };
}
}
// Prevent users who have left or been ejected to use the same sessionToken again.
const isUserInvalid = Users.findOne({
meetingId,
userId: requesterUserId,
authToken: requesterToken,
$or: [{ ejected: true }, { loggedOut: true }],
});
if (isUserInvalid) {
Logger.warn(`An invalid sessionToken tried to validateAuthToken meetingId=${meetingId} authToken=${requesterToken}`);
return {
invalid: true,
reason: `User has an invalid sessionToken due to ${isUserInvalid.ejected ? 'ejection' : 'log out'}`,
error_type: `invalid_session_token_due_to_${isUserInvalid.ejected ? 'eject' : 'log_out'}`,
};
}
// Store reference of methodInvocationObject ( to postpone the connection userId definition )
pendingAuthenticationsStore.add(meetingId, requesterUserId, requesterToken, this);

View File

@ -7,7 +7,7 @@ class BannedUsers {
}
init(meetingId) {
Logger.debug('BannedUsers :: init', meetingId);
Logger.debug('BannedUsers :: init', { meetingId });
if (!this.store[meetingId]) this.store[meetingId] = new Set();
}
@ -20,7 +20,7 @@ class BannedUsers {
}
delete(meetingId) {
Logger.debug('BannedUsers :: delete', meetingId);
Logger.debug('BannedUsers :: delete', { meetingId });
delete this.store[meetingId];
}

View File

@ -359,8 +359,9 @@ const BaseContainer = withTracker(() => {
changed: (newDocument) => {
if (newDocument.validated && newDocument.name && newDocument.userId !== localUserId) {
if (userJoinAudioAlerts) {
const audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/userJoin.mp3`);
audio.play();
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/userJoin.mp3');
}
if (userJoinPushAlerts) {

View File

@ -1,109 +1,10 @@
import React, { Component } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl';
import { IntlProvider } from 'react-intl';
import Settings from '/imports/ui/services/settings';
import LoadingScreen from '/imports/ui/components/loading-screen/component';
// currently supported locales.
import ar from 'react-intl/locale-data/ar';
import az from 'react-intl/locale-data/az';
import bg from 'react-intl/locale-data/bg';
import ca from 'react-intl/locale-data/ca';
import cs from 'react-intl/locale-data/cs';
import da from 'react-intl/locale-data/da';
import de from 'react-intl/locale-data/de';
import el from 'react-intl/locale-data/el';
import en from 'react-intl/locale-data/en';
import eo from 'react-intl/locale-data/eo';
import es from 'react-intl/locale-data/es';
import et from 'react-intl/locale-data/et';
import eu from 'react-intl/locale-data/eu';
import fa from 'react-intl/locale-data/fa';
import fi from 'react-intl/locale-data/fi';
import fr from 'react-intl/locale-data/fr';
import gl from 'react-intl/locale-data/gl';
import he from 'react-intl/locale-data/he';
import hi from 'react-intl/locale-data/hi';
import hr from 'react-intl/locale-data/hr';
import hu from 'react-intl/locale-data/hu';
import hy from 'react-intl/locale-data/hy';
import id from 'react-intl/locale-data/id';
import it from 'react-intl/locale-data/it';
import ja from 'react-intl/locale-data/ja';
import ka from 'react-intl/locale-data/ka';
import km from 'react-intl/locale-data/km';
import kn from 'react-intl/locale-data/kn';
import ko from 'react-intl/locale-data/ko';
import lt from 'react-intl/locale-data/lt';
import lv from 'react-intl/locale-data/lv';
import nb from 'react-intl/locale-data/nb';
import nl from 'react-intl/locale-data/nl';
import pl from 'react-intl/locale-data/pl';
import pt from 'react-intl/locale-data/pt';
import ro from 'react-intl/locale-data/ro';
import ru from 'react-intl/locale-data/ru';
import sk from 'react-intl/locale-data/sk';
import sl from 'react-intl/locale-data/sl';
import sr from 'react-intl/locale-data/sr';
import sv from 'react-intl/locale-data/sv';
import te from 'react-intl/locale-data/te';
import th from 'react-intl/locale-data/th';
import tr from 'react-intl/locale-data/tr';
import uk from 'react-intl/locale-data/uk';
import vi from 'react-intl/locale-data/vi';
import zh from 'react-intl/locale-data/zh';
addLocaleData([
...ar,
...az,
...bg,
...ca,
...cs,
...da,
...de,
...el,
...et,
...en,
...eo,
...es,
...eu,
...fa,
...fi,
...fr,
...gl,
...he,
...hi,
...hr,
...hu,
...hy,
...id,
...it,
...ja,
...ka,
...km,
...kn,
...ko,
...lt,
...lv,
...nb,
...nl,
...pl,
...pt,
...ro,
...ru,
...sk,
...sl,
...sr,
...sv,
...te,
...th,
...tr,
...uk,
...vi,
...zh,
]);
import getFromUserSettings from '/imports/ui/services/users-settings';
const propTypes = {
locale: PropTypes.string,
@ -120,7 +21,12 @@ const defaultProps = {
class IntlStartup extends Component {
static saveLocale(localeName) {
if (Settings.application.locale !== localeName) {
Settings.application.changedLocale = localeName;
}
Settings.application.locale = localeName;
if (RTL_LANGUAGES.includes(localeName.substring(0, 2))) {
document.body.parentNode.setAttribute('dir', 'rtl');
Settings.application.isRTL = true;
@ -155,16 +61,32 @@ class IntlStartup extends Component {
componentDidUpdate(prevProps) {
const { fetching, normalizedLocale, localeChanged } = this.state;
const { locale } = this.props;
const { locale, overrideLocale, changedLocale } = this.props;
if (prevProps.locale !== locale) {
this.setState({
localeChanged: true,
});
}
if (overrideLocale) {
if (!fetching
&& (overrideLocale !== normalizedLocale.toLowerCase())
&& !localeChanged
&& !changedLocale) {
this.fetchLocalizedMessages(overrideLocale);
}
if (!localeChanged) {
return;
}
}
if (!fetching
&& normalizedLocale
&& ((locale.toLowerCase() !== normalizedLocale.toLowerCase()))) {
if (((DEFAULT_LANGUAGE === normalizedLocale.toLowerCase()) && !localeChanged)) return;
this.fetchLocalizedMessages(locale);
}
}
@ -209,10 +131,12 @@ class IntlStartup extends Component {
}
const IntlStartupContainer = withTracker(() => {
const { locale } = Settings.application;
const { locale, changedLocale } = Settings.application;
const overrideLocale = getFromUserSettings('bbb_override_default_locale', null);
return {
locale,
overrideLocale,
changedLocale,
};
})(IntlStartup);

View File

@ -71,7 +71,8 @@ class MeteorStream {
'logClient',
nameFromLevel[this.rec.level],
this.rec.msg,
{ clientURL },
this.rec.logCode,
{ ...rec.extraInfo, clientURL },
);
}
}

View File

@ -8,11 +8,38 @@ import { lookup as lookupUserAgent } from 'useragent';
import { check } from 'meteor/check';
import Logger from './logger';
import Redis from './redis';
import setMinBrowserVersions from './minBrowserVersion';
import userLeaving from '/imports/api/users/server/methods/userLeaving';
let guestWaitHtml = '';
const AVAILABLE_LOCALES = fs.readdirSync('assets/app/locales');
let avaibleLocalesNames = [];
const FALLBACK_LOCALES = JSON.parse(Assets.getText('config/fallbackLocales.json'));
const generateLocaleOptions = () => {
try {
Logger.warn('Calculating aggregateLocales (heavy)');
const tempAggregateLocales = AVAILABLE_LOCALES
.map(file => file.replace('.json', ''))
.map(file => file.replace('_', '-'))
.map((locale) => {
const localeName = (Langmap[locale] || {}).nativeName
|| (FALLBACK_LOCALES[locale] || {}).nativeName
|| locale;
return {
locale,
name: localeName,
};
});
Logger.warn(`Total locales: ${tempAggregateLocales.length}`, tempAggregateLocales);
return tempAggregateLocales;
} catch (e) {
Logger.error(`'Could not process locales error: ${e}`);
return [];
}
};
let avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions());
Meteor.startup(() => {
const APP_CONFIG = Meteor.settings.public.app;
@ -151,23 +178,13 @@ WebApp.connectHandlers.use('/locale', (req, res) => {
});
WebApp.connectHandlers.use('/locales', (req, res) => {
if (!avaibleLocalesNames.length) {
try {
avaibleLocalesNames = AVAILABLE_LOCALES
.map(file => file.replace('.json', ''))
.map(file => file.replace('_', '-'))
.map(locale => ({
locale,
name: Langmap[locale].nativeName,
}));
} catch (e) {
Logger.warn(`'Could not process locales error: ${e}`);
}
if (!avaibleLocalesNamesJSON) {
avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions());
}
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify(avaibleLocalesNames));
res.end(avaibleLocalesNamesJSON);
});
WebApp.connectHandlers.use('/feedback', (req, res) => {
@ -227,6 +244,21 @@ WebApp.connectHandlers.use('/useragent', (req, res) => {
res.end(response);
});
WebApp.connectHandlers.use('/guestWait', (req, res) => {
if (!guestWaitHtml) {
try {
guestWaitHtml = Assets.getText('static/guest-wait/guest-wait.html');
} catch (e) {
Logger.warn(`Could not process guest wait html file: ${e}`);
}
}
res.setHeader('Content-Type', 'text/html');
res.writeHead(200);
res.end(guestWaitHtml);
});
export const eventEmitter = Redis.emitter;
export const redisPubSub = Redis;

View File

@ -1,7 +1,7 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, intlShape } from 'react-intl';
import { defineMessages } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import Dropdown from '/imports/ui/components/dropdown/component';
import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component';
@ -17,7 +17,7 @@ import { styles } from '../styles';
const propTypes = {
amIPresenter: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
mountModal: PropTypes.func.isRequired,
amIModerator: PropTypes.bool.isRequired,
shortcuts: PropTypes.string,

View File

@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from '/imports/ui/components/actions-bar/styles';
import Button from '/imports/ui/components/button/component';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
isActive: PropTypes.bool.isRequired,
handleOnClick: PropTypes.func.isRequired,
};

View File

@ -118,7 +118,7 @@ class ActionsBar extends PureComponent {
}}
/>
<Button
className={cx(styles.button, autoArrangeLayout || styles.btn)}
className={cx(styles.btn, autoArrangeLayout || styles.btn)}
icon={autoArrangeLayout ? 'lock' : 'unlock'}
color={autoArrangeLayout ? 'primary' : 'default'}
ghost={!autoArrangeLayout}

View File

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import cx from 'classnames';
import browser from 'browser-detect';
@ -109,11 +109,12 @@ const intlMessages = defineMessages({
},
});
const BREAKOUT_LIM = Meteor.settings.public.app.breakoutRoomLimit;
const MIN_BREAKOUT_ROOMS = 2;
const MAX_BREAKOUT_ROOMS = 8;
const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
isInvitation: PropTypes.bool.isRequired,
meetingName: PropTypes.string.isRequired,
users: PropTypes.arrayOf(PropTypes.object).isRequired,

View File

@ -117,11 +117,12 @@ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-i
}
.boxContainer {
height: 50vh;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 33% 33% 33%;
grid-template-columns: repeat(3, minmax(4rem, 16rem));
grid-template-rows: repeat(auto-fill, minmax(4rem, 8rem));
grid-gap: 1.5rem 1rem;
box-sizing: border-box;
padding-bottom: 1rem;
}
.changeToWarn {

View File

@ -1,6 +1,6 @@
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import browser from 'browser-detect';
import Button from '/imports/ui/components/button/component';
import logger from '/imports/startup/client/logger';
@ -12,7 +12,7 @@ import { styles } from '../styles';
import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
amIPresenter: PropTypes.bool.isRequired,
handleShareScreen: PropTypes.func.isRequired,
handleUnshareScreen: PropTypes.func.isRequired,

View File

@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import MediaService from '/imports/ui/components/media/service';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
toggleSwapLayout: PropTypes.func.isRequired,
};

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, intlShape } from 'react-intl';
import { defineMessages } from 'react-intl';
import _ from 'lodash';
import { makeCall } from '/imports/ui/services/api';
import Button from '/imports/ui/components/button/component';
@ -35,7 +35,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
parseCurrentSlideContent: PropTypes.func.isRequired,
amIPresenter: PropTypes.bool.isRequired,
};

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, intlShape } from 'react-intl';
import { defineMessages } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import Modal from '/imports/ui/components/modal/simple/component';
@ -9,7 +9,7 @@ import { makeCall } from '/imports/ui/services/api';
import { styles } from './styles';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
responseDelay: PropTypes.number.isRequired,
};

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { throttle } from 'lodash';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Modal from 'react-modal';
import browser from 'browser-detect';
import PanelManager from '/imports/ui/components/panel-manager/component';
@ -83,7 +83,7 @@ const propTypes = {
actionsbar: PropTypes.element,
captions: PropTypes.element,
locale: PropTypes.string,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
};
const defaultProps = {

View File

@ -12,6 +12,7 @@ import getFromUserSettings from '/imports/ui/services/users-settings';
import deviceInfo from '/imports/utils/deviceInfo';
import UserInfos from '/imports/api/users-infos';
import { startBandwidthMonitoring, updateNavigatorConnection } from '/imports/ui/services/network-information/index';
import logger from '/imports/startup/client/logger';
import {
getFontSize,
@ -72,9 +73,9 @@ const currentUserEmoji = currentUser => (currentUser ? {
status: currentUser.emoji,
changedAt: currentUser.emojiTime,
} : {
status: 'none',
changedAt: null,
});
status: 'none',
changedAt: null,
});
export default injectIntl(withModalMounter(withTracker(({ intl, baseControls }) => {
const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { approved: 1, emoji: 1 } });
@ -91,7 +92,18 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
changed(id, fields) {
const hasNewConnection = 'connectionId' in fields && (fields.connectionId !== Meteor.connection._lastSessionId);
if (fields.ejected || hasNewConnection) {
if (hasNewConnection) {
logger.info({
logCode: 'user_connection_id_changed',
extraInfo: {
currentConnectionId: fields.connectionId,
previousConnectionId: Meteor.connection._lastSessionId,
},
}, 'User connectionId changed ');
endMeeting('401');
}
if (fields.ejected) {
endMeeting('403');
}
},

View File

@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { defineMessages, intlShape, injectIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import getFromUserSettings from '/imports/ui/services/users-settings';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
@ -37,7 +37,7 @@ const propTypes = {
showMute: PropTypes.bool.isRequired,
inAudio: PropTypes.bool.isRequired,
listenOnly: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
talking: PropTypes.bool.isRequired,
};

View File

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages, intlShape } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';
import { styles } from './styles';
const intlMessages = defineMessages({
@ -23,7 +23,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
formattedDialNum: PropTypes.string.isRequired,
telVoice: PropTypes.string.isRequired,
};

View File

@ -5,7 +5,7 @@ import Modal from '/imports/ui/components/modal/simple/component';
import Button from '/imports/ui/components/button/component';
import { Session } from 'meteor/session';
import {
defineMessages, injectIntl, intlShape, FormattedMessage,
defineMessages, injectIntl, FormattedMessage,
} from 'react-intl';
import { styles } from './styles';
import PermissionsOverlay from '../permissions-overlay/component';
@ -16,7 +16,7 @@ import AudioDial from '../audio-dial/component';
import AudioAutoplayPrompt from '../autoplay/component';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
closeModal: PropTypes.func.isRequired,
joinMicrophone: PropTypes.func.isRequired,
joinListenOnly: PropTypes.func.isRequired,
@ -245,8 +245,6 @@ class AudioModal extends Component {
}
const {
inputDeviceId,
outputDeviceId,
joinEchoTest,
} = this.props;
@ -268,12 +266,27 @@ class AudioModal extends Component {
disableActions: false,
});
}).catch((err) => {
if (err.type === 'MEDIA_ERROR') {
this.setState({
content: 'help',
errCode: err.code,
disableActions: false,
});
const { type } = err;
switch (type) {
case 'MEDIA_ERROR':
this.setState({
content: 'help',
errCode: 0,
disableActions: false,
});
break;
case 'CONNECTION_ERROR':
this.setState({
errCode: 0,
disableActions: false,
});
break;
default:
this.setState({
errCode: 0,
disableActions: false,
});
break;
}
});
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import { withModalMounter } from '/imports/ui/components/modal/service';
import DeviceSelector from '/imports/ui/components/audio/device-selector/component';
@ -9,7 +9,7 @@ import cx from 'classnames';
import { styles } from './styles';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
changeInputDevice: PropTypes.func.isRequired,
changeOutputDevice: PropTypes.func.isRequired,
handleBack: PropTypes.func.isRequired,

View File

@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '/imports/ui/components/button/component';
import { defineMessages, intlShape, injectIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles.scss';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
handlePlayAudioSample: PropTypes.func.isRequired,
outputDeviceId: PropTypes.string,
};

View File

@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import Button from '/imports/ui/components/button/component';
import { defineMessages, intlShape, injectIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles';
@ -18,7 +18,7 @@ const intlMessages = defineMessages({
const propTypes = {
handleAllowAutoplay: PropTypes.func.isRequired,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
};
class AudioAutoplayPrompt extends PureComponent {

View File

@ -2,7 +2,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Session } from 'meteor/session';
import Button from '/imports/ui/components/button/component';
import { defineMessages, intlShape, injectIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles';
const intlMessages = defineMessages({
@ -27,7 +27,7 @@ const intlMessages = defineMessages({
const propTypes = {
handleYes: PropTypes.func.isRequired,
handleNo: PropTypes.func.isRequired,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
};
class EchoTest extends Component {

View File

@ -1,11 +1,11 @@
import React from 'react';
import { injectIntl, intlShape, defineMessages } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';
import Modal from '/imports/ui/components/modal/simple/component';
import PropTypes from 'prop-types';
import { styles } from './styles';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
closeModal: PropTypes.func.isRequired,
};

View File

@ -88,4 +88,5 @@ export default {
isVoiceUser,
autoplayBlocked: () => AudioManager.autoplayBlocked,
handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(),
playAlertSound: url => AudioManager.playAlertSound(url),
};

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { withModalMounter } from '/imports/ui/components/modal/service';
import Modal from '/imports/ui/components/modal/fullscreen/component';
import logger from '/imports/startup/client/logger';
@ -40,7 +40,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
breakout: PropTypes.objectOf(Object).isRequired,
getURL: PropTypes.func.isRequired,
mountModal: PropTypes.func.isRequired,

View File

@ -224,13 +224,12 @@ class BreakoutRoom extends PureComponent {
(
<Button
label={
moderatorJoinedAudio
&& stateBreakoutId === breakoutId
&& joinedAudioOnly
stateBreakoutId === breakoutId && joinedAudioOnly
? intl.formatMessage(intlMessages.breakoutReturnAudio)
: intl.formatMessage(intlMessages.breakoutJoinAudio)
}
className={styles.button}
disabled={stateBreakoutId !== breakoutId && joinedAudioOnly}
key={`join-audio-${breakoutId}`}
onClick={audioAction}
/>
@ -261,7 +260,7 @@ class BreakoutRoom extends PureComponent {
>
<div className={styles.content} key={`breakoutRoomList-${breakout.breakoutId}`}>
<span aria-hidden>
{intl.formatMessage(intlMessages.breakoutRoom, breakout.sequence.toString())}
{intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence })}
<span className={styles.usersAssignedNumberLabel}>
(
{breakout.joinedUsers.length}

View File

@ -1,5 +1,6 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import AudioService from '/imports/ui/components/audio/service';
const propTypes = {
play: PropTypes.bool.isRequired,
@ -8,17 +9,16 @@ const propTypes = {
class ChatAudioAlert extends Component {
constructor(props) {
super(props);
this.audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/notify.mp3`);
this.handleAudioLoaded = this.handleAudioLoaded.bind(this);
this.playAudio = this.playAudio.bind(this);
}
componentDidMount() {
this.audio.addEventListener('loadedmetadata', this.handleAudioLoaded);
this.handleAudioLoaded();
}
componentWillUnmount() {
this.audio.removeEventListener('loadedmetadata', this.handleAudioLoaded);
this.handleAudioLoaded();
}
handleAudioLoaded() {
@ -28,7 +28,9 @@ class ChatAudioAlert extends Component {
playAudio() {
const { play } = this.props;
if (!play) return;
this.audio.play();
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/notify.mp3');
}
render() {

View File

@ -116,7 +116,7 @@ export default injectIntl(withTracker(({ intl }) => {
const messagesFormated = messagesBeforeWelcomeMsg
.concat(welcomeMsg)
.concat((amIModerator && modOnlyMessage) || [])
.concat((amIModerator && modOnlyMessage) ? moderatorMsg : [])
.concat(messagesAfterWelcomeMsg);
messages = messagesFormated.sort((a, b) => (a.time - b.time));

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import cx from 'classnames';
import TextareaAutosize from 'react-autosize-textarea';
import browser from 'browser-detect';
@ -10,7 +10,7 @@ import { styles } from './styles.scss';
import Button from '../../button/component';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
chatId: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
minMessageLength: PropTypes.number.isRequired,

View File

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import {
defineMessages, injectIntl, intlShape, FormattedMessage,
defineMessages, injectIntl, FormattedMessage,
} from 'react-intl';
import browser from 'browser-detect';
import PropTypes from 'prop-types';
@ -8,7 +8,7 @@ import cx from 'classnames';
import { styles } from '../styles.scss';
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
typingUsers: PropTypes.arrayOf(Object).isRequired,
};

View File

@ -147,7 +147,7 @@ class MessageList extends Component {
handleScrollUpdate,
} = this.props;
if (position !== null && position + target.offsetHeight === target.scrollHeight) {
if (position !== null && position + target?.offsetHeight === target?.scrollHeight) {
// I used one because the null value is used to notify that
// the user has sent a message and the message list should scroll to bottom
handleScrollUpdate(1);
@ -159,10 +159,10 @@ class MessageList extends Component {
handleScrollChange(e) {
const { scrollArea } = this.state;
const scrollCursorPosition = e.scrollTop + scrollArea.offsetHeight;
const scrollCursorPosition = e.scrollTop + scrollArea?.offsetHeight;
const shouldScrollBottom = e.scrollTop === null
|| scrollCursorPosition === scrollArea.scrollHeight
|| (scrollArea.scrollHeight - scrollCursorPosition < 1);
|| scrollCursorPosition === scrollArea?.scrollHeight
|| (scrollArea?.scrollHeight - scrollCursorPosition < 1);
if ((e.scrollTop < this.lastKnowScrollPosition) && !shouldScrollBottom) {
this.setState({ shouldScrollToBottom: false });

View File

@ -126,6 +126,7 @@ class MessageListItem extends Component {
className={styles.avatar}
color={user.color}
moderator={user.isModerator}
avatar={user.avatar}
>
{user.name.toLowerCase().slice(0, 2)}
</UserAvatar>

View File

@ -50,13 +50,18 @@ const mapGroupMessage = (message) => {
const sender = Users.findOne({ userId: message.sender },
{
fields: {
color: 1, role: 1, name: 1, connectionStatus: 1,
color: 1,
role: 1,
name: 1,
avatar: 1,
connectionStatus: 1,
},
});
const {
color,
role,
name,
avatar,
connectionStatus,
} = sender;
@ -64,6 +69,7 @@ const mapGroupMessage = (message) => {
color,
isModerator: role === ROLE_MODERATOR,
name,
avatar,
isOnline: connectionStatus === CONNECTION_STATUS_ONLINE,
};

View File

@ -84,8 +84,9 @@ class ConnectionStatusComponent extends PureComponent {
<div className={styles.left}>
<div className={styles.avatar}>
<UserAvatar
className={styles.icon}
className={cx({ [styles.initials]: conn.avatar.length === 0 })}
you={conn.you}
avatar={conn.avatar}
moderator={conn.moderator}
color={conn.color}
>

View File

@ -82,7 +82,7 @@
justify-content: center;
align-items: center;
.icon {
.initials {
min-width: 2.25rem;
height: 2.25rem;
}

View File

@ -87,6 +87,7 @@ const getConnectionStatus = () => {
userId: 1,
name: 1,
role: 1,
avatar: 1,
color: 1,
connectionStatus: 1,
},
@ -96,6 +97,7 @@ const getConnectionStatus = () => {
userId,
name,
role,
avatar,
color,
connectionStatus: userStatus,
} = user;
@ -105,6 +107,7 @@ const getConnectionStatus = () => {
if (status) {
result.push({
name,
avatar,
offline: userStatus === 'offline',
you: Auth.userID === userId,
moderator: role === ROLE_MODERATOR,

View File

@ -35,10 +35,7 @@ export function publishCursorUpdate(payload) {
}
export function initCursorStreamListener() {
logger.info({
logCode: 'init_cursor_stream_listener',
extraInfo: { meetingId: Auth.meetingID, userId: Auth.userID },
}, 'initCursorStreamListener called');
logger.info({ logCode: 'init_cursor_stream_listener' }, 'initCursorStreamListener called');
/**
* We create a promise to add the handlers after a ddp subscription stop.
@ -60,9 +57,7 @@ export function initCursorStreamListener() {
});
startStreamHandlersPromise.then(() => {
logger.debug({
logCode: 'init_cursor_stream_listener',
}, 'initCursorStreamListener called');
logger.debug({ logCode: 'cursor_stream_handler_attach' }, 'Attaching handlers for cursor stream');
cursorStreamListener.on('message', ({ cursors }) => {
Object.keys(cursors).forEach((userId) => {

View File

@ -4,7 +4,7 @@ import { findDOMNode } from 'react-dom';
import { isMobile } from 'react-device-detect';
import TetherComponent from 'react-tether';
import cx from 'classnames';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import screenreaderTrap from 'makeup-screenreader-trap';
import { styles } from './styles';
@ -52,7 +52,7 @@ const propTypes = {
onHide: PropTypes.func,
onShow: PropTypes.func,
autoFocus: PropTypes.bool,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
tethered: PropTypes.bool,
};
@ -300,4 +300,4 @@ class Dropdown extends Component {
Dropdown.propTypes = propTypes;
Dropdown.defaultProps = defaultProps;
export default injectIntl(Dropdown);
export default injectIntl(Dropdown, { forwardRef: true });

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import cx from 'classnames';
import Icon from '/imports/ui/components/icon/component';
@ -18,7 +19,13 @@ const defaultProps = {
tabIndex: 0,
};
export default class DropdownListItem extends Component {
const messages = defineMessages({
activeAriaLabel: {
id: 'app.dropdown.list.item.activeLabel',
},
});
class DropdownListItem extends Component {
constructor(props) {
super(props);
this.labelID = _.uniqueId('dropdown-item-label-');
@ -38,9 +45,12 @@ export default class DropdownListItem extends Component {
render() {
const {
id, label, description, children, injectRef, tabIndex, onClick, onKeyDown,
className, style,
className, style, intl,
} = this.props;
const isSelected = className && className.includes('emojiSelected');
const _label = isSelected ? `${label} (${intl.formatMessage(messages.activeAriaLabel)})` : label;
return (
<li
id={id}
@ -59,8 +69,8 @@ export default class DropdownListItem extends Component {
children || this.renderDefault()
}
{
label ?
(<span id={this.labelID} key="labelledby" hidden>{label}</span>)
label
? (<span id={this.labelID} key="labelledby" hidden>{_label}</span>)
: null
}
<span id={this.descID} key="describedby" hidden>{description}</span>
@ -69,5 +79,7 @@ export default class DropdownListItem extends Component {
}
}
export default injectIntl(DropdownListItem);
DropdownListItem.propTypes = propTypes;
DropdownListItem.defaultProps = defaultProps;

View File

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import Modal from '/imports/ui/components/modal/simple/component';
import { styles } from './styles';
@ -26,7 +26,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
closeModal: PropTypes.func.isRequired,
endMeeting: PropTypes.func.isRequired,
};

View File

@ -3,6 +3,7 @@ import { withTracker } from 'meteor/react-meteor-data';
import { withModalMounter } from '/imports/ui/components/modal/service';
import { makeCall } from '/imports/ui/services/api';
import EndMeetingComponent from './component';
import logger from '/imports/startup/client/logger';
const EndMeetingContainer = props => <EndMeetingComponent {...props} />;
@ -12,6 +13,10 @@ export default withModalMounter(withTracker(({ mountModal }) => ({
},
endMeeting: () => {
logger.warn({
logCode: 'moderator_forcing_end_meeting',
extraInfo: { logType: 'user_action' },
}, 'this user clicked on EndMeeting and confirmed, removing everybody from the meeting');
makeCall('endMeeting');
mountModal(null);
},

View File

@ -15,6 +15,9 @@ const intlMessages = defineMessages({
410: {
id: 'app.error.410',
},
408: {
id: 'app.error.408',
},
404: {
id: 'app.error.404',
defaultMessage: 'Not found',

View File

@ -104,6 +104,7 @@ class VideoPlayer extends Component {
componentDidMount() {
window.addEventListener('resize', this.resizeListener);
window.addEventListener('layoutSizesSets', this.resizeListener);
window.addEventListener('beforeunload', this.onBeforeUnload);
clearInterval(this.syncInterval);

View File

@ -1,7 +1,7 @@
import loadScript from 'load-script';
import React, { Component } from 'react'
const MATCH_URL = new RegExp("https?:\/\/(\\w+)[.](instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
const MATCH_URL = new RegExp("https?:\/\/(.*)(instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
const SDK_URL = 'https://files.instructuremedia.com/instructure-media-script/instructure-media-1.1.0.js';

View File

@ -1,5 +1,5 @@
import React from 'react';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import cx from 'classnames';
import PropTypes from 'prop-types';
@ -10,10 +10,14 @@ const intlMessages = defineMessages({
id: 'app.fullscreenButton.label',
description: 'Fullscreen label',
},
fullscreenUndoButton: {
id: 'app.fullscreenUndoButton.label',
description: 'Undo fullscreen label',
},
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
fullscreenRef: PropTypes.instanceOf(Element),
dark: PropTypes.bool,
bottom: PropTypes.bool,
@ -47,10 +51,17 @@ const FullscreenButtonComponent = ({
}) => {
if (isIphone) return null;
const formattedLabel = intl.formatMessage(
intlMessages.fullscreenButton,
({ 0: elementName || '' }),
);
const formattedLabel = (isFullscreen) => {
return(isFullscreen ?
intl.formatMessage(
intlMessages.fullscreenUndoButton,
({ 0: elementName || '' }),
) :
intl.formatMessage(
intlMessages.fullscreenButton,
({ 0: elementName || '' }),
));
};
const wrapperClassName = cx({
[styles.wrapper]: true,
@ -67,7 +78,7 @@ const FullscreenButtonComponent = ({
icon={!isFullscreen ? 'fullscreen' : 'exit_fullscreen'}
size="sm"
onClick={() => handleToggleFullScreen(fullscreenRef)}
label={formattedLabel}
label={formattedLabel(isFullscreen)}
hideLabel
className={cx(styles.button, styles.fullScreenButton, className)}
data-test="presentationFullscreenButton"

View File

@ -178,6 +178,7 @@ class JoinHandler extends Component {
const { response } = parseToJson;
setLogoutURL(response);
logUserInfo();
if (response.returncode !== 'FAILED') {
await setAuth(response);
@ -185,7 +186,6 @@ class JoinHandler extends Component {
setBannerProps(response);
setLogoURL(response);
setModOnlyMessage(response);
logUserInfo();
Tracker.autorun(async (cd) => {
const user = Users.findOne({ userId: Auth.userID, approved: true }, { fields: { _id: 1 } });

View File

@ -312,25 +312,43 @@ class LayoutManager extends Component {
};
}
if (openPanel === 'userlist') {
newChatSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
}
if (openPanel === '') {
newUserListSize = {
width: 0,
};
newChatSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
switch (openPanel) {
case 'userlist': {
newChatSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
break;
}
case 'chat': {
newBreakoutRoomSize = {
width: 0,
};
break;
}
case 'breakoutroom': {
newChatSize = {
width: 0,
};
break;
}
case '': {
newUserListSize = {
width: 0,
};
newChatSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
break;
}
default: {
throw new Error('Unexpected openPanel value');
}
}
return {

View File

@ -5,70 +5,70 @@ import './styles.css';
// currently supported locales.
import ar from 'react-intl/locale-data/ar';
import bg from 'react-intl/locale-data/bg';
import cs from 'react-intl/locale-data/cs';
import de from 'react-intl/locale-data/de';
import el from 'react-intl/locale-data/el';
import en from 'react-intl/locale-data/en';
import es from 'react-intl/locale-data/es';
import eu from 'react-intl/locale-data/eu';
import fa from 'react-intl/locale-data/fa';
import fi from 'react-intl/locale-data/fi';
import fr from 'react-intl/locale-data/fr';
import he from 'react-intl/locale-data/he';
import hi from 'react-intl/locale-data/hi';
import hu from 'react-intl/locale-data/hu';
import id from 'react-intl/locale-data/id';
import it from 'react-intl/locale-data/it';
import ja from 'react-intl/locale-data/ja';
import km from 'react-intl/locale-data/km';
import pl from 'react-intl/locale-data/pl';
import pt from 'react-intl/locale-data/pt';
import ru from 'react-intl/locale-data/ru';
import sv from 'react-intl/locale-data/sv';
import tr from 'react-intl/locale-data/tr';
import uk from 'react-intl/locale-data/uk';
import vi from 'react-intl/locale-data/vi';
import zh from 'react-intl/locale-data/zh';
// import ar from 'react-intl/locale-data/ar';
// import bg from 'react-intl/locale-data/bg';
// import cs from 'react-intl/locale-data/cs';
// import de from 'react-intl/locale-data/de';
// import el from 'react-intl/locale-data/el';
// import en from 'react-intl/locale-data/en';
// import es from 'react-intl/locale-data/es';
// import eu from 'react-intl/locale-data/eu';
// import fa from 'react-intl/locale-data/fa';
// import fi from 'react-intl/locale-data/fi';
// import fr from 'react-intl/locale-data/fr';
// import he from 'react-intl/locale-data/he';
// import hi from 'react-intl/locale-data/hi';
// import hu from 'react-intl/locale-data/hu';
// import id from 'react-intl/locale-data/id';
// import it from 'react-intl/locale-data/it';
// import ja from 'react-intl/locale-data/ja';
// import km from 'react-intl/locale-data/km';
// import pl from 'react-intl/locale-data/pl';
// import pt from 'react-intl/locale-data/pt';
// import ru from 'react-intl/locale-data/ru';
// import sv from 'react-intl/locale-data/sv';
// import tr from 'react-intl/locale-data/tr';
// import uk from 'react-intl/locale-data/uk';
// import vi from 'react-intl/locale-data/vi';
// import zh from 'react-intl/locale-data/zh';
// This class is the only component loaded on legacy (unsupported) browsers.
// What is included here needs to be minimal and carefully considered because some
// things can't be polyfilled.
// // This class is the only component loaded on legacy (unsupported) browsers.
// // What is included here needs to be minimal and carefully considered because some
// // things can't be polyfilled.
addLocaleData([
...ar,
...bg,
...cs,
...de,
...el,
...en,
...es,
...eu,
...fa,
...fi,
...fr,
...he,
...hi,
...hu,
...id,
...it,
...ja,
...km,
...pl,
...pt,
...ru,
...sv,
...tr,
...uk,
...vi,
...zh,
]);
// addLocaleData([
// ...ar,
// ...bg,
// ...cs,
// ...de,
// ...el,
// ...en,
// ...es,
// ...eu,
// ...fa,
// ...fi,
// ...fr,
// ...he,
// ...hi,
// ...hu,
// ...id,
// ...it,
// ...ja,
// ...km,
// ...pl,
// ...pt,
// ...ru,
// ...sv,
// ...tr,
// ...uk,
// ...vi,
// ...zh,
// ]);
const FETCHING = 'fetching';
const FALLBACK = 'fallback';
const READY = 'ready';
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge'];
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge', 'yandex'];
export default class Legacy extends Component {
constructor(props) {

View File

@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Settings from '/imports/ui/services/settings';
import { isMobile, isIPad13 } from 'react-device-detect';
import WebcamDraggable from './webcam-draggable-overlay/component';
import { styles } from './styles';
@ -65,6 +66,9 @@ export default class Media extends Component {
[styles.containerV]: webcamsPlacement === 'top' || webcamsPlacement === 'bottom' || webcamsPlacement === 'floating',
[styles.containerH]: webcamsPlacement === 'left' || webcamsPlacement === 'right',
});
const { viewParticipantsWebcams } = Settings.dataSaving;
const showVideo = usersVideo.length > 0 && viewParticipantsWebcams;
const fullHeight = !showVideo || (webcamsPlacement === 'floating');
return (
<div
@ -103,22 +107,18 @@ export default class Media extends Component {
>
{children}
</div>
{
usersVideo.length > 0
? (
<WebcamDraggable
refMediaContainer={this.refContainer}
swapLayout={swapLayout}
singleWebcam={singleWebcam}
usersVideoLenght={usersVideo.length}
hideOverlay={hideOverlay}
disableVideo={disableVideo}
audioModalIsOpen={audioModalIsOpen}
usersVideo={usersVideo}
/>
)
: null
}
{showVideo ? (
<WebcamDraggable
refMediaContainer={this.refContainer}
swapLayout={swapLayout}
singleWebcam={singleWebcam}
usersVideoLenght={usersVideo.length}
hideOverlay={hideOverlay}
disableVideo={disableVideo}
audioModalIsOpen={audioModalIsOpen}
usersVideo={usersVideo}
/>
) : null}
</div>
);
}

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Settings from '/imports/ui/services/settings';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import { Session } from 'meteor/session';
import { notify } from '/imports/ui/services/notification';
@ -22,7 +22,7 @@ const KURENTO_CONFIG = Meteor.settings.public.kurento;
const propTypes = {
isScreensharing: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
};
const intlMessages = defineMessages({
@ -49,19 +49,22 @@ const intlMessages = defineMessages({
});
class MediaContainer extends Component {
componentWillMount() {
componentDidMount() {
document.addEventListener('installChromeExtension', this.installChromeExtension.bind(this));
document.addEventListener('screenshareNotSupported', this.screenshareNotSupported.bind(this));
}
componentWillReceiveProps(nextProps) {
componentDidUpdate(prevProps) {
const {
isScreensharing,
intl,
} = this.props;
const {
isScreensharing: wasScreenSharing,
} = prevProps;
if (isScreensharing !== nextProps.isScreensharing) {
if (nextProps.isScreensharing) {
if (isScreensharing !== wasScreenSharing) {
if (wasScreenSharing) {
notify(intl.formatMessage(intlMessages.screenshareStarted), 'info', 'desktop');
} else {
notify(intl.formatMessage(intlMessages.screenshareEnded), 'info', 'desktop');
@ -107,7 +110,7 @@ export default withLayoutConsumer(withModalMounter(withTracker(() => {
const { dataSaving } = Settings;
const { viewParticipantsWebcams, viewScreenshare } = dataSaving;
const hidePresentation = getFromUserSettings('bbb_hide_presentation', LAYOUT_CONFIG.hidePresentation);
const autoSwapLayout = getFromUserSettings('userdata-bbb_auto_swap_layout', LAYOUT_CONFIG.autoSwapLayout);
const autoSwapLayout = getFromUserSettings('bbb_auto_swap_layout', LAYOUT_CONFIG.autoSwapLayout);
const { current_presentation: hasPresentation } = MediaService.getPresentationInfo();
const data = {
children: <DefaultContent {...{ autoSwapLayout, hidePresentation }} />,

View File

@ -4,6 +4,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import { Meteor } from 'meteor/meteor';
import Auth from '/imports/ui/services/auth';
import Button from '/imports/ui/components/button/component';
import allowRedirectToLogoutURL from './service';
import getFromUserSettings from '/imports/ui/services/users-settings';
import logoutRouteHandler from '/imports/utils/logoutRouteHandler';
import Rating from './rating/component';
@ -11,6 +12,7 @@ import { styles } from './styles';
import logger from '/imports/startup/client/logger';
import Users from '/imports/api/users';
import AudioManager from '/imports/ui/services/audio-manager';
import { meetingIsBreakout } from '/imports/ui/components/app/service';
const intlMessage = defineMessages({
410: {
@ -102,6 +104,7 @@ class MeetingEnded extends PureComponent {
super(props);
this.state = {
selected: 0,
dispatched: false,
};
const user = Users.findOne({ userId: Auth.userID });
@ -110,8 +113,9 @@ class MeetingEnded extends PureComponent {
}
this.setSelectedStar = this.setSelectedStar.bind(this);
this.confirmRedirect = this.confirmRedirect.bind(this);
this.sendFeedback = this.sendFeedback.bind(this);
this.shouldShowFeedback = getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout);
this.shouldShowFeedback = this.shouldShowFeedback.bind(this);
AudioManager.exitAudio();
Meteor.disconnect();
@ -123,15 +127,26 @@ class MeetingEnded extends PureComponent {
});
}
sendFeedback() {
shouldShowFeedback() {
const { dispatched } = this.state;
return getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout) && !dispatched;
}
confirmRedirect() {
const {
selected,
} = this.state;
if (selected <= 0) {
logoutRouteHandler();
return;
if (meetingIsBreakout()) window.close();
if (allowRedirectToLogoutURL()) logoutRouteHandler();
}
}
sendFeedback() {
const {
selected,
} = this.state;
const { fullname } = Auth.credentials;
@ -156,25 +171,70 @@ class MeetingEnded extends PureComponent {
// client logger
logger.info({ logCode: 'feedback_functionality', extraInfo: { feedback: message } }, 'Feedback component');
const FEEDBACK_WAIT_TIME = 500;
setTimeout(() => {
fetch(url, options)
.then(() => {
logoutRouteHandler();
})
.catch(() => {
logoutRouteHandler();
});
}, FEEDBACK_WAIT_TIME);
this.setState({
dispatched: true,
});
if (allowRedirectToLogoutURL()) {
const FEEDBACK_WAIT_TIME = 500;
setTimeout(() => {
fetch(url, options)
.then(() => {
logoutRouteHandler();
})
.catch(() => {
logoutRouteHandler();
});
}, FEEDBACK_WAIT_TIME);
}
}
render() {
const { code, intl, reason } = this.props;
const { selected } = this.state;
renderNoFeedback() {
const { intl, code, reason } = this.props;
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component, no feedback configured');
return (
<div className={styles.parent}>
<div className={styles.modal}>
<div className={styles.content}>
<h1 className={styles.title} data-test="meetingEndedModalTitle">
{
intl.formatMessage(intlMessage[code] || intlMessage[430])
}
</h1>
{!allowRedirectToLogoutURL() ? null : (
<div>
<div className={styles.text}>
{intl.formatMessage(intlMessage.messageEnded)}
</div>
<Button
color="primary"
onClick={this.confirmRedirect}
className={styles.button}
label={intl.formatMessage(intlMessage.buttonOkay)}
description={intl.formatMessage(intlMessage.confirmDesc)}
/>
</div>
)}
</div>
</div>
</div>
);
}
renderFeedback() {
const { intl, code, reason } = this.props;
const {
selected,
dispatched,
} = this.state;
const noRating = selected <= 0;
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component');
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component, feedback allowed');
return (
<div className={styles.parent}>
@ -186,11 +246,12 @@ class MeetingEnded extends PureComponent {
}
</h1>
<div className={styles.text}>
{this.shouldShowFeedback
{this.shouldShowFeedback()
? intl.formatMessage(intlMessage.subtitle)
: intl.formatMessage(intlMessage.messageEnded)}
</div>
{this.shouldShowFeedback ? (
{this.shouldShowFeedback() ? (
<div data-test="rating">
<Rating
total="5"
@ -207,22 +268,35 @@ class MeetingEnded extends PureComponent {
) : null}
</div>
) : null }
<Button
color="primary"
onClick={this.sendFeedback}
className={styles.button}
label={noRating
? intl.formatMessage(intlMessage.buttonOkay)
: intl.formatMessage(intlMessage.sendLabel)}
description={noRating
? intl.formatMessage(intlMessage.confirmDesc)
: intl.formatMessage(intlMessage.sendDesc)}
/>
{noRating && allowRedirectToLogoutURL() ? (
<Button
color="primary"
onClick={this.confirmRedirect}
className={styles.button}
label={intl.formatMessage(intlMessage.buttonOkay)}
description={intl.formatMessage(intlMessage.confirmDesc)}
/>
) : null}
{!noRating && !dispatched ? (
<Button
color="primary"
onClick={this.sendFeedback}
className={styles.button}
label={intl.formatMessage(intlMessage.sendLabel)}
description={intl.formatMessage(intlMessage.sendDesc)}
/>
) : null}
</div>
</div>
</div>
);
}
render() {
if (this.shouldShowFeedback()) return this.renderFeedback();
return this.renderNoFeedback();
}
}
MeetingEnded.propTypes = propTypes;

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles';
const intlMessages = defineMessages({
@ -16,7 +16,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
onRate: PropTypes.func.isRequired,
total: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,22 @@
import { Meteor } from 'meteor/meteor';
import Auth from '/imports/ui/services/auth';
export default function allowRedirectToLogoutURL() {
const ALLOW_DEFAULT_LOGOUT_URL = Meteor.settings.public.app.allowDefaultLogoutUrl;
const protocolPattern = /^((http|https):\/\/)/;
if (Auth.logoutURL) {
// default logoutURL
// compare only the host to ignore protocols
const urlWithoutProtocolForAuthLogout = Auth.logoutURL.replace(protocolPattern, '');
const urlWithoutProtocolForLocationOrigin = window.location.origin.replace(protocolPattern, '');
if (urlWithoutProtocolForAuthLogout === urlWithoutProtocolForLocationOrigin) {
return ALLOW_DEFAULT_LOGOUT_URL;
}
// custom logoutURL
return true;
}
// no logout url
return false;
}

View File

@ -3,7 +3,7 @@ import RecordingContainer from '/imports/ui/components/recording/container';
import humanizeSeconds from '/imports/utils/humanizeSeconds';
import Tooltip from '/imports/ui/components/tooltip/component';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles';
const intlMessages = defineMessages({
@ -46,7 +46,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
amIModerator: PropTypes.bool,
record: PropTypes.bool,
recording: PropTypes.bool,

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { withModalMounter } from '/imports/ui/components/modal/service';
@ -92,7 +92,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
handleToggleFullscreen: PropTypes.func.isRequired,
mountModal: PropTypes.func.isRequired,
noIOSFullscreen: PropTypes.bool,

View File

@ -98,27 +98,6 @@ class PanelManager extends Component {
this.setUserListWidth = this.setUserListWidth.bind(this);
}
shouldComponentUpdate(prevProps) {
const { layoutContextState } = this.props;
const { layoutContextState: prevLayoutContextState } = prevProps;
const {
userListSize,
chatSize,
breakoutRoomSize,
} = layoutContextState;
const {
userListSize: prevUserListSize,
chatSize: prevChatSize,
breakoutRoomSize: prevBreakoutRoomSize,
} = prevLayoutContextState;
if ((layoutContextState !== prevLayoutContextState)
&& (userListSize.width === prevUserListSize.width
&& chatSize.width === prevChatSize.width
&& breakoutRoomSize.width === prevBreakoutRoomSize.width)) return false;
return true;
}
componentDidUpdate(prevProps) {
const {
userlistWidth,

View File

@ -5,6 +5,7 @@ import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrap
import { defineMessages, injectIntl } from 'react-intl';
import cx from 'classnames';
import { styles } from './styles.scss';
import AudioService from '/imports/ui/components/audio/service';
const MAX_INPUT_CHARS = 45;
@ -55,8 +56,9 @@ class Polling extends Component {
}
play() {
this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/Poll.mp3`);
this.alert.play();
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/Poll.mp3');
}
handleUpdateResponseInput(e) {
@ -78,7 +80,10 @@ class Polling extends Component {
typedAns,
} = this.state;
if (!poll) return null;
const { stackOptions, answers, question } = poll;
const pollAnswerStyles = {
[styles.pollingAnswers]: true,
[styles.removeColumns]: answers.length === 1,

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import WhiteboardOverlayContainer from '/imports/ui/components/whiteboard/whiteboard-overlay/container';
import WhiteboardToolbarContainer from '/imports/ui/components/whiteboard/whiteboard-toolbar/container';
import { HUNDRED_PERCENT, MAX_PERCENT } from '/imports/utils/slideCalcUtils';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { toast } from 'react-toastify';
import PresentationToolbarContainer from './presentation-toolbar/container';
import CursorWrapperContainer from './cursor/cursor-wrapper-container/container';
@ -854,7 +854,7 @@ class PresentationArea extends PureComponent {
export default injectIntl(withDraggableConsumer(withLayoutConsumer(PresentationArea)));
PresentationArea.propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
podId: PropTypes.string.isRequired,
// Defines a boolean value to detect whether a current user is a presenter
userIsPresenter: PropTypes.bool.isRequired,

View File

@ -38,7 +38,7 @@ export default class Cursor extends Component {
return obj;
}
static getScaledSizes(props) {
static getScaledSizes(props, state) {
// TODO: This might need to change for the use case of fit-to-width portrait
// slides in non-presenter view. Some elements are still shrinking.
const scaleFactor = props.widthRatio / props.physicalWidthRatio;
@ -58,14 +58,25 @@ export default class Cursor extends Component {
yOffset: props.cursorLabelBox.yOffset * scaleFactor,
// making width and height a little bit larger than the size of the text
// received from BBox, so that the text didn't touch the border
width: (props.labelBoxWidth + 3) * scaleFactor,
height: (props.labelBoxHeight + 3) * scaleFactor,
width: (state.labelBoxWidth + 3) * scaleFactor,
height: (state.labelBoxHeight + 3) * scaleFactor,
strokeWidth: props.cursorLabelBox.labelBoxStrokeWidth * scaleFactor,
},
};
}
componentWillMount() {
constructor(props) {
super(props);
this.state = {
scaledSizes: null,
labelBoxWidth: 0,
labelBoxHeight: 0,
};
this.setLabelBoxDimensions = this.setLabelBoxDimensions.bind(this);
}
componentDidMount() {
const {
cursorX,
cursorY,
@ -75,8 +86,9 @@ export default class Cursor extends Component {
isMultiUser,
} = this.props;
// setting the initial cursor info
this.scaledSizes = Cursor.getScaledSizes(this.props);
this.setState({
scaledSizes: Cursor.getScaledSizes(this.props, this.state),
});
this.cursorCoordinate = Cursor.getCursorCoordinates(
cursorX,
cursorY,
@ -87,66 +99,83 @@ export default class Cursor extends Component {
const { fill, displayLabel } = Cursor.getFillAndLabel(presenter, isMultiUser);
this.fill = fill;
this.displayLabel = displayLabel;
}
componentDidMount() {
// we need to find the BBox of the text, so that we could set a proper border box arount it
this.calculateCursorLabelBoxDimensions();
}
componentWillReceiveProps(nextProps) {
componentDidUpdate(prevProps, prevState) {
const {
scaledSizes,
} = this.state;
if (!prevState.scaledSizes && scaledSizes) {
this.calculateCursorLabelBoxDimensions();
}
const {
presenter,
isMultiUser,
widthRatio,
physicalWidthRatio,
labelBoxWidth,
labelBoxHeight,
cursorX,
cursorY,
slideWidth,
slideHeight,
} = this.props;
const {
labelBoxWidth,
labelBoxHeight,
} = this.state;
if (presenter !== nextProps.presenter || isMultiUser !== nextProps.isMultiUser) {
const {
labelBoxWidth: prevLabelBoxWidth,
labelBoxHeight: prevLabelBoxHeight,
} = prevState;
if (presenter !== prevProps.presenter || isMultiUser !== prevProps.isMultiUser) {
const { fill, displayLabel } = Cursor.getFillAndLabel(
nextProps.presenter,
nextProps.isMultiUser,
presenter,
isMultiUser,
);
this.displayLabel = displayLabel;
this.fill = fill;
}
if ((widthRatio !== nextProps.widthRatio
|| physicalWidthRatio !== nextProps.physicalWidthRatio)
|| (labelBoxWidth !== nextProps.labelBoxWidth
|| labelBoxHeight !== nextProps.labelBoxHeight)) {
this.scaledSizes = Cursor.getScaledSizes(nextProps);
if ((widthRatio !== prevProps.widthRatio
|| physicalWidthRatio !== prevProps.physicalWidthRatio)
|| (labelBoxWidth !== prevLabelBoxWidth
|| labelBoxHeight !== prevLabelBoxHeight)) {
this.setState({
scaledSizes: Cursor.getScaledSizes(this.props, this.state),
});
}
if (cursorX !== nextProps.cursorX || cursorY !== nextProps.cursorY) {
if (cursorX !== prevProps.cursorX || cursorY !== prevProps.cursorY) {
const cursorCoordinate = Cursor.getCursorCoordinates(
nextProps.cursorX,
nextProps.cursorY,
nextProps.slideWidth,
nextProps.slideHeight,
cursorX,
cursorY,
slideWidth,
slideHeight,
);
this.cursorCoordinate = cursorCoordinate;
}
}
setLabelBoxDimensions(labelBoxWidth, labelBoxHeight) {
this.setState({
labelBoxWidth,
labelBoxHeight,
});
}
// this function retrieves the text node, measures its BBox and sets the size for the outer box
calculateCursorLabelBoxDimensions() {
const {
setLabelBoxDimensions,
} = this.props;
let labelBoxWidth = 0;
let labelBoxHeight = 0;
if (this.cursorLabelRef) {
const { width, height } = this.cursorLabelRef.getBBox();
const { widthRatio, physicalWidthRatio, cursorLabelBox } = this.props;
labelBoxWidth = Cursor.invertScale(width, widthRatio, physicalWidthRatio);
labelBoxHeight = Cursor.invertScale(height, widthRatio, physicalWidthRatio);
// if the width of the text node is bigger than the maxSize - set the width to maxWidth
if (labelBoxWidth > cursorLabelBox.maxWidth) {
labelBoxWidth = cursorLabelBox.maxWidth;
@ -154,26 +183,30 @@ export default class Cursor extends Component {
}
// updating labelBoxWidth and labelBoxHeight in the container, which then passes it down here
setLabelBoxDimensions(labelBoxWidth, labelBoxHeight);
this.setLabelBoxDimensions(labelBoxWidth, labelBoxHeight);
}
render() {
const {
scaledSizes,
} = this.state;
const {
cursorId,
userName,
isRTL,
} = this.props;
const {
cursorCoordinate,
fill,
} = this;
if (!scaledSizes) return null;
const {
cursorLabelBox,
cursorLabelText,
finalRadius,
} = this.scaledSizes;
} = scaledSizes;
const {
x,
@ -191,7 +224,7 @@ export default class Cursor extends Component {
<circle
cx={x}
cy={y}
r={finalRadius === Infinity ? 0 : finalRadius}
r={finalRadius}
fill={fill}
fillOpacity="0.6"
/>
@ -292,17 +325,6 @@ Cursor.propTypes = {
fontSize: PropTypes.number.isRequired,
}),
// Defines the width of the label box
labelBoxWidth: PropTypes.number.isRequired,
// Defines the height of the label box
labelBoxHeight: PropTypes.number.isRequired,
// Defines the function, which sets the state for the label box and passes it back down
// we need it, since we need to render the text first -> measure its dimensions ->
// set proper width and height of the border box -> pass it down ->
// catch in the 'componentWillReceiveProps' -> apply new values
setLabelBoxDimensions: PropTypes.func.isRequired,
// Defines the direction the client text should be displayed
isRTL: PropTypes.bool.isRequired,
};

View File

@ -6,33 +6,15 @@ import Cursor from './component';
class CursorContainer extends Component {
constructor() {
super();
this.state = {
labelBoxWidth: 0,
labelBoxHeight: 0,
};
this.setLabelBoxDimensions = this.setLabelBoxDimensions.bind(this);
}
setLabelBoxDimensions(labelBoxWidth, labelBoxHeight) {
this.setState({
labelBoxWidth,
labelBoxHeight,
});
}
render() {
const { cursorX, cursorY } = this.props;
const { labelBoxWidth, labelBoxHeight } = this.state;
if (cursorX > 0 && cursorY > 0) {
return (
<Cursor
cursorX={cursorX}
cursorY={cursorY}
labelBoxWidth={labelBoxWidth}
labelBoxHeight={labelBoxHeight}
setLabelBoxDimensions={this.setLabelBoxDimensions}
{...this.props}
/>

View File

@ -1,5 +1,5 @@
import React from 'react';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import cx from 'classnames';
import PropTypes from 'prop-types';
@ -13,7 +13,7 @@ const intlMessages = defineMessages({
});
const propTypes = {
intl: intlShape.isRequired,
intl: PropTypes.object.isRequired,
handleDownloadPresentation: PropTypes.func.isRequired,
dark: PropTypes.bool,
};

View File

@ -11,6 +11,7 @@ import ZoomTool from './zoom-tool/component';
import FullscreenButtonContainer from '../../fullscreen-button/container';
import TooltipContainer from '/imports/ui/components/tooltip/container';
import QuickPollDropdownContainer from '/imports/ui/components/actions-bar/quick-poll-dropdown/container';
import FullscreenService from '/imports/ui/components/fullscreen-button/service';
import KEY_CODES from '/imports/utils/keyCodes';
const intlMessages = defineMessages({
@ -101,6 +102,7 @@ class PresentationToolbar extends PureComponent {
switchSlide(event) {
const { target, which } = event;
const isBody = target.nodeName === 'BODY';
const { fullscreenRef } = this.props;
if (isBody) {
switch (which) {
@ -112,6 +114,9 @@ class PresentationToolbar extends PureComponent {
case KEY_CODES.PAGE_DOWN:
this.nextSlideHandler();
break;
case KEY_CODES.ENTER:
FullscreenService.toggleFullScreen(fullscreenRef);
break;
default:
}
}

Some files were not shown because too many files have changed in this diff Show More