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 fromAkkaAppsJsonChannel = Try(config.getString("eventBus.fromAkkaAppsChannel")).getOrElse("from-akka-apps-json-channel")
lazy val applyPermissionCheck = Try(config.getBoolean("apps.checkPermissions")).getOrElse(false) 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 voiceConfRecordPath = Try(config.getString("voiceConf.recordPath")).getOrElse("/var/freeswitch/meetings")
lazy val voiceConfRecordCodec = Try(config.getString("voiceConf.recordCodec")).getOrElse("wav") 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 MOD_LEVEL = 100
val AUTHED_LEVEL = 50 val AUTHED_LEVEL = 50
@ -83,12 +83,17 @@ object PermissionCheck {
def ejectUserForFailedPermission(meetingId: String, userId: String, reason: String, def ejectUserForFailedPermission(meetingId: String, userId: String, reason: String,
outGW: OutMsgRouter, liveMeeting: LiveMeeting): Unit = { 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 // send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW) 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 = { def addOldPresenter(users: Users2x, userId: String): OldPresenter = {

View File

@ -56,7 +56,7 @@ trait RegisterUserReqMsgHdlr {
val g = GuestApprovedVO(regUser.id, GuestStatus.ALLOW) val g = GuestApprovedVO(regUser.id, GuestStatus.ALLOW)
UsersApp.approveOrRejectGuest(liveMeeting, outGW, g, SystemUser.ID) UsersApp.approveOrRejectGuest(liveMeeting, outGW, g, SystemUser.ID)
case GuestStatus.WAIT => 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) addGuestToWaitingForApproval(guest, liveMeeting.guestsWaiting)
notifyModeratorsOfGuestWaiting(Vector(guest), liveMeeting.users2x, liveMeeting.props.meetingProp.intId) notifyModeratorsOfGuestWaiting(Vector(guest), liveMeeting.users2x, liveMeeting.props.meetingProp.intId)
case GuestStatus.DENY => case GuestStatus.DENY =>

View File

@ -51,7 +51,7 @@ class GuestsWaiting {
def setGuestPolicy(policy: GuestPolicy) = guestPolicy = policy 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) case class GuestPolicy(policy: String, setBy: String)
object GuestPolicyType { object GuestPolicyType {

View File

@ -27,7 +27,7 @@ import java.util.*;
* Source:<br> * Source:<br>
* Phoenix: An Interactive Curve Design System Based on the Automatic Fitting * Phoenix: An Interactive Curve Design System Based on the Automatic Fitting
* of Hand-Sketched Curves.<br> * 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 * A thesis submitted in partial fulfillment of the requirements for the degree
* of Master of Science, University of Washington. * of Master of Science, University of Washington.
* <p> * <p>
@ -238,7 +238,7 @@ public class Bezier {
* @param digitizedPoints Digitized points * @param digitizedPoints Digitized points
* @param maxAngle maximal angle in radians between the current point and its * @param maxAngle maximal angle in radians between the current point and its
* predecessor and successor up to which the point does not break the * 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 * @return Segments of digitized points, each segment having less than maximal
* angle between points. * angle between points.
*/ */

View File

@ -44,7 +44,7 @@ object MsgBuilder {
val envelope = BbbCoreEnvelope(GetGuestsWaitingApprovalRespMsg.NAME, routing) val envelope = BbbCoreEnvelope(GetGuestsWaitingApprovalRespMsg.NAME, routing)
val header = BbbClientMsgHeader(GetGuestsWaitingApprovalRespMsg.NAME, meetingId, userId) 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 body = GetGuestsWaitingApprovalRespMsgBody(guestsWaiting)
val event = GetGuestsWaitingApprovalRespMsg(header, body) val event = GetGuestsWaitingApprovalRespMsg(header, body)
@ -56,7 +56,7 @@ object MsgBuilder {
val envelope = BbbCoreEnvelope(GuestsWaitingForApprovalEvtMsg.NAME, routing) val envelope = BbbCoreEnvelope(GuestsWaitingForApprovalEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(GuestsWaitingForApprovalEvtMsg.NAME, meetingId, userId) 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 body = GuestsWaitingForApprovalEvtMsgBody(guestsWaiting)
val event = GuestsWaitingForApprovalEvtMsg(header, body) 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, val guest1 = createUserVoiceAndCam(liveMeeting, Roles.VIEWER_ROLE, guest = true, authed = true, CallingWith.WEBRTC, muted = false,
talking = false, listenOnly = false) talking = false, listenOnly = false)
Users2x.add(liveMeeting.users2x, guest1) 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) GuestsWaiting.add(liveMeeting.guestsWaiting, guestWait1)
val guest2 = createUserVoiceAndCam(liveMeeting, Roles.VIEWER_ROLE, guest = true, authed = true, CallingWith.FLASH, muted = false, val guest2 = createUserVoiceAndCam(liveMeeting, Roles.VIEWER_ROLE, guest = true, authed = true, CallingWith.FLASH, muted = false,
talking = false, listenOnly = false) talking = false, listenOnly = false)
Users2x.add(liveMeeting.users2x, guest2) 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) GuestsWaiting.add(liveMeeting.guestsWaiting, guestWait2)
val vu1 = FakeUserGenerator.createFakeVoiceOnlyUser(CallingWith.PHONE, muted = false, talking = false, listenOnly = false) val vu1 = FakeUserGenerator.createFakeVoiceOnlyUser(CallingWith.PHONE, muted = false, talking = false, listenOnly = false)

View File

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

View File

@ -19,7 +19,7 @@ case class GetGuestsWaitingApprovalRespMsg(
body: GetGuestsWaitingApprovalRespMsgBody body: GetGuestsWaitingApprovalRespMsgBody
) extends BbbCoreMsg ) extends BbbCoreMsg
case class GetGuestsWaitingApprovalRespMsgBody(guests: Vector[GuestWaitingVO]) 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 * 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 = "lockSettingsLockOnJoin";
public static final String LOCK_SETTINGS_LOCK_ON_JOIN_CONFIGURABLE = "lockSettingsLockOnJoinConfigurable"; 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() { private ApiParams() {
throw new IllegalStateException("ApiParams is a utility class. Instanciation is forbidden."); 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.Executors;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.client.utils.URIBuilder; import org.apache.http.client.utils.URIBuilder;
import org.bigbluebutton.api.domain.GuestPolicy; import org.bigbluebutton.api.domain.GuestPolicy;
import org.bigbluebutton.api.domain.Meeting; import org.bigbluebutton.api.domain.Meeting;
@ -81,8 +82,9 @@ import org.bigbluebutton.api2.IBbbWebApiGWApp;
import org.bigbluebutton.api2.domain.UploadedTrack; import org.bigbluebutton.api2.domain.UploadedTrack;
import org.bigbluebutton.common2.redis.RedisStorageService; import org.bigbluebutton.common2.redis.RedisStorageService;
import org.bigbluebutton.presentation.PresentationUrlDownloadService; import org.bigbluebutton.presentation.PresentationUrlDownloadService;
import org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask;
import org.bigbluebutton.web.services.WaitingGuestCleanupTimerTask; 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.CallbackUrlService;
import org.bigbluebutton.web.services.callback.MeetingEndedEvent; import org.bigbluebutton.web.services.callback.MeetingEndedEvent;
import org.bigbluebutton.web.services.turn.StunTurnService; import org.bigbluebutton.web.services.turn.StunTurnService;
@ -107,13 +109,17 @@ public class MeetingService implements MessageListener {
private final ConcurrentMap<String, UserSession> sessions; private final ConcurrentMap<String, UserSession> sessions;
private RecordingService recordingService; private RecordingService recordingService;
private RegisteredUserCleanupTimerTask registeredUserCleaner;
private WaitingGuestCleanupTimerTask waitingGuestCleaner; private WaitingGuestCleanupTimerTask waitingGuestCleaner;
private UserCleanupTimerTask userCleaner;
private EnteredUserCleanupTimerTask enteredUserCleaner;
private StunTurnService stunTurnService; private StunTurnService stunTurnService;
private RedisStorageService storeService; private RedisStorageService storeService;
private CallbackUrlService callbackUrlService; private CallbackUrlService callbackUrlService;
private boolean keepEvents; private boolean keepEvents;
private long usersTimeout;
private long enteredUsersTimeout;
private ParamsProcessorUtil paramsProcessorUtil; private ParamsProcessorUtil paramsProcessorUtil;
private PresentationUrlDownloadService presDownloadService; 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()) { for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
Long now = System.currentTimeMillis(); Long now = System.currentTimeMillis();
Meeting meeting = entry.getValue(); 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()) { if (!user.hasLeft()) continue;
String registeredUserID = registeredUser.getKey();
RegisteredUser registeredUserDate = registeredUser.getValue();
long elapsedTime = now - registeredUserDate.registeredOn; long elapsedTime = now - user.getLeftOn();
if (elapsedTime >= 60000 && !users.containsKey(registeredUserID)) { if (elapsedTime >= usersTimeout) {
meeting.userUnregistered(registeredUserID); 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(); String endCallbackUrl = "endCallbackUrl".toLowerCase();
Map<String, String> metadata = m.getMetadata(); Map<String, String> metadata = m.getMetadata();
if (!m.isBreakout() && metadata.containsKey(endCallbackUrl)) { if (!m.isBreakout()) {
String callbackUrl = metadata.get(endCallbackUrl); if (metadata.containsKey(endCallbackUrl)) {
try { String callbackUrl = metadata.get(endCallbackUrl);
try {
callbackUrl = new URIBuilder(new URI(callbackUrl)) callbackUrl = new URIBuilder(new URI(callbackUrl))
.addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false") .addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false")
.addParameter("meetingID", m.getExternalId()).build().toURL().toString(); .addParameter("meetingID", m.getExternalId()).build().toURL().toString();
callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl)); MeetingEndedEvent event = new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl);
} catch (MalformedURLException e) { processMeetingEndedCallback(event);
log.error("Malformed URL in callback url=[{}]", callbackUrl, e); } catch (Exception e) {
} catch (URISyntaxException e) { log.error("Error in callback url=[{}]", callbackUrl, e);
log.error("URI Syntax error in callback url=[{}]", callbackUrl, e); }
} 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); 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) { private void userJoined(UserJoined message) {
Meeting m = getMeeting(message.meetingId); Meeting m = getMeeting(message.meetingId);
if (m != null) { if (m != null) {
@ -867,13 +925,6 @@ public class MeetingService implements MessageListener {
// the meeting ended. // the meeting ended.
m.setEndTime(System.currentTimeMillis()); 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 { } else {
if (message.userId.startsWith("v_")) { if (message.userId.startsWith("v_")) {
// A dial-in user joined the meeting. Dial-in users by convention has userId that starts with "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"); true, GuestPolicy.ALLOW, "DIAL-IN");
vuser.setVoiceJoined(true); vuser.setVoiceJoined(true);
m.userJoined(vuser); m.userJoined(vuser);
@ -1086,8 +1137,9 @@ public class MeetingService implements MessageListener {
public void stop() { public void stop() {
processMessage = false; processMessage = false;
registeredUserCleaner.stop();
waitingGuestCleaner.stop(); waitingGuestCleaner.stop();
userCleaner.stop();
enteredUserCleaner.stop();
} }
public void setRecordingService(RecordingService s) { public void setRecordingService(RecordingService s) {
@ -1112,11 +1164,16 @@ public class MeetingService implements MessageListener {
waitingGuestCleaner.start(); waitingGuestCleaner.start();
} }
public void setRegisteredUserCleanupTimerTask( public void setEnteredUserCleanupTimerTask(EnteredUserCleanupTimerTask c) {
RegisteredUserCleanupTimerTask c) { enteredUserCleaner = c;
registeredUserCleaner = c; enteredUserCleaner.setMeetingService(this);
registeredUserCleaner.setMeetingService(this); enteredUserCleaner.start();
registeredUserCleaner.start(); }
public void setUserCleanupTimerTask(UserCleanupTimerTask c) {
userCleaner = c;
userCleaner.setMeetingService(this);
userCleaner.start();
} }
public void setStunTurnService(StunTurnService s) { public void setStunTurnService(StunTurnService s) {
@ -1126,4 +1183,12 @@ public class MeetingService implements MessageListener {
public void setKeepEvents(boolean value) { public void setKeepEvents(boolean value) {
keepEvents = 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 moderatorsJoinViaHTML5Client;
private Boolean attendeesJoinViaHTML5Client; private Boolean attendeesJoinViaHTML5Client;
private Boolean allowRequestsWithoutSession; private Boolean allowRequestsWithoutSession;
private Boolean useDefaultAvatar = false;
private String defaultAvatarURL; private String defaultAvatarURL;
private String defaultConfigURL; private String defaultConfigURL;
private String defaultGuestPolicy; private String defaultGuestPolicy;
@ -114,6 +115,7 @@ public class ParamsProcessorUtil {
private Integer userInactivityThresholdInMinutes = 30; private Integer userInactivityThresholdInMinutes = 30;
private Integer userActivitySignResponseDelayInMinutes = 5; private Integer userActivitySignResponseDelayInMinutes = 5;
private Boolean defaultAllowDuplicateExtUserid = true; private Boolean defaultAllowDuplicateExtUserid = true;
private Boolean defaultEndWhenNoModerator = false;
private String formatConfNum(String s) { private String formatConfNum(String s) {
if (s.length() > 5) { 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; String guestPolicy = defaultGuestPolicy;
if (!StringUtils.isEmpty(params.get(ApiParams.GUEST_POLICY))) { if (!StringUtils.isEmpty(params.get(ApiParams.GUEST_POLICY))) {
guestPolicy = params.get(ApiParams.GUEST_POLICY); guestPolicy = params.get(ApiParams.GUEST_POLICY);
@ -454,6 +465,8 @@ public class ParamsProcessorUtil {
externalMeetingId = externalHash + "-" + timeStamp; externalMeetingId = externalHash + "-" + timeStamp;
} }
String avatarURL = useDefaultAvatar ? defaultAvatarURL : "";
// Create the meeting with all passed in parameters. // Create the meeting with all passed in parameters.
Meeting meeting = new Meeting.Builder(externalMeetingId, Meeting meeting = new Meeting.Builder(externalMeetingId,
internalMeetingId, createTime).withName(meetingName) internalMeetingId, createTime).withName(meetingName)
@ -464,7 +477,7 @@ public class ParamsProcessorUtil {
.withBannerText(bannerText).withBannerColor(bannerColor) .withBannerText(bannerText).withBannerColor(bannerColor)
.withTelVoice(telVoice).withWebVoice(webVoice) .withTelVoice(telVoice).withWebVoice(webVoice)
.withDialNumber(dialNumber) .withDialNumber(dialNumber)
.withDefaultAvatarURL(defaultAvatarURL) .withDefaultAvatarURL(avatarURL)
.withAutoStartRecording(autoStartRec) .withAutoStartRecording(autoStartRec)
.withAllowStartStopRecording(allowStartStoptRec) .withAllowStartStopRecording(allowStartStoptRec)
.withWebcamsOnlyForModerator(webcamsOnlyForMod) .withWebcamsOnlyForModerator(webcamsOnlyForMod)
@ -487,6 +500,11 @@ public class ParamsProcessorUtil {
meeting.setModeratorOnlyMessage(moderatorOnlyMessage); 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.setMeetingExpireIfNoUserJoinedInMinutes(meetingExpireIfNoUserJoinedInMinutes);
meeting.setMeetingExpireWhenLastUserLeftInMinutes(meetingExpireWhenLastUserLeftInMinutes); meeting.setMeetingExpireWhenLastUserLeftInMinutes(meetingExpireWhenLastUserLeftInMinutes);
meeting.setUserInactivityInspectTimerInMinutes(userInactivityInspectTimerInMinutes); meeting.setUserInactivityInspectTimerInMinutes(userInactivityInspectTimerInMinutes);
@ -937,6 +955,10 @@ public class ParamsProcessorUtil {
this.webcamsOnlyForModerator = webcamsOnlyForModerator; this.webcamsOnlyForModerator = webcamsOnlyForModerator;
} }
public void setUseDefaultAvatar(Boolean value) {
this.useDefaultAvatar = value;
}
public void setdefaultAvatarURL(String url) { public void setdefaultAvatarURL(String url) {
this.defaultAvatarURL = url; this.defaultAvatarURL = url;
} }
@ -1115,4 +1137,10 @@ public class ParamsProcessorUtil {
public void setAllowDuplicateExtUserid(Boolean allow) { public void setAllowDuplicateExtUserid(Boolean allow) {
this.defaultAllowDuplicateExtUserid = 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 Map<String, Object> userCustomData;
private final ConcurrentMap<String, User> users; private final ConcurrentMap<String, User> users;
private final ConcurrentMap<String, RegisteredUser> registeredUsers; private final ConcurrentMap<String, RegisteredUser> registeredUsers;
private final ConcurrentMap<String, Long> enteredUsers;
private final ConcurrentMap<String, Config> configs; private final ConcurrentMap<String, Config> configs;
private final Boolean isBreakout; private final Boolean isBreakout;
private final List<String> breakoutRooms = new ArrayList<>(); private final List<String> breakoutRooms = new ArrayList<>();
@ -92,6 +93,11 @@ public class Meeting {
public final Boolean allowDuplicateExtUserid; public final Boolean allowDuplicateExtUserid;
private String meetingEndedCallbackURL = "";
public final Boolean endWhenNoModerator;
public Meeting(Meeting.Builder builder) { public Meeting(Meeting.Builder builder) {
name = builder.name; name = builder.name;
extMeetingId = builder.externalId; extMeetingId = builder.externalId;
@ -120,12 +126,14 @@ public class Meeting {
guestPolicy = builder.guestPolicy; guestPolicy = builder.guestPolicy;
breakoutRoomsParams = builder.breakoutRoomsParams; breakoutRoomsParams = builder.breakoutRoomsParams;
lockSettingsParams = builder.lockSettingsParams; lockSettingsParams = builder.lockSettingsParams;
allowDuplicateExtUserid = builder.allowDuplicateExtUserid; allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
endWhenNoModerator = builder.endWhenNoModerator;
userCustomData = new HashMap<>(); userCustomData = new HashMap<>();
users = new ConcurrentHashMap<>(); users = new ConcurrentHashMap<>();
registeredUsers = new ConcurrentHashMap<>(); registeredUsers = new ConcurrentHashMap<>();
enteredUsers = new ConcurrentHashMap<>();;
configs = new ConcurrentHashMap<>(); configs = new ConcurrentHashMap<>();
} }
@ -448,12 +456,28 @@ public class Meeting {
} }
public void userJoined(User user) { public void userJoined(User user) {
userHasJoined = true; User u = getUserById(user.getInternalUserId());
this.users.put(user.getInternalUserId(), user); 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){ public User userLeft(String userId) {
return users.remove(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){ public User getUserById(String id){
@ -563,6 +587,14 @@ public class Meeting {
this.userActivitySignResponseDelayInMinutes = userActivitySignResponseDelayInMinutes; this.userActivitySignResponseDelayInMinutes = userActivitySignResponseDelayInMinutes;
} }
public String getMeetingEndedCallbackURL() {
return meetingEndedCallbackURL;
}
public void setMeetingEndedCallbackURL(String meetingEndedCallbackURL) {
this.meetingEndedCallbackURL = meetingEndedCallbackURL;
}
public Map<String, Object> getUserCustomData(String userID){ public Map<String, Object> getUserCustomData(String userID){
return (Map<String, Object>) userCustomData.get(userID); return (Map<String, Object>) userCustomData.get(userID);
} }
@ -579,6 +611,29 @@ public class Meeting {
return registeredUsers; 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 * Meeting Builder
* *
@ -612,6 +667,7 @@ public class Meeting {
private BreakoutRoomsParams breakoutRoomsParams; private BreakoutRoomsParams breakoutRoomsParams;
private LockSettingsParams lockSettingsParams; private LockSettingsParams lockSettingsParams;
private Boolean allowDuplicateExtUserid; private Boolean allowDuplicateExtUserid;
private Boolean endWhenNoModerator;
public Builder(String externalId, String internalId, long createTime) { public Builder(String externalId, String internalId, long createTime) {
this.externalId = externalId; this.externalId = externalId;
@ -743,6 +799,11 @@ public class Meeting {
this.allowDuplicateExtUserid = allowDuplicateExtUserid; this.allowDuplicateExtUserid = allowDuplicateExtUserid;
return this; return this;
} }
public Builder withEndWhenNoModerator(Boolean endWhenNoModerator) {
this.endWhenNoModerator = endWhenNoModerator;
return this;
}
public Meeting build() { public Meeting build() {
return new Meeting(this); return new Meeting(this);

View File

@ -38,6 +38,7 @@ public class User {
private Boolean voiceJoined = false; private Boolean voiceJoined = false;
private String clientType; private String clientType;
private List<String> streams; private List<String> streams;
private Long leftOn = null;
public User(String internalUserId, public User(String internalUserId,
String externalUserId, String externalUserId,
@ -90,6 +91,22 @@ public class User {
return this.guestStatus; 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() { public String getFullname() {
return fullname; return fullname;
} }

View File

@ -1,231 +1,224 @@
/** /**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ * BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
* *
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below). * 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 * 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 * 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 * Foundation; either version 3.0 of the License, or (at your option) any later
* version. * version.
* *
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY * 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 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. * 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 * You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. * with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package org.bigbluebutton.presentation.imp;
package org.bigbluebutton.presentation.imp; import java.io.File;
import java.lang.reflect.Method;
import java.io.File; import java.util.ArrayList;
import java.lang.reflect.Method; import java.util.HashMap;
import java.util.ArrayList; import java.util.Map;
import java.util.HashMap; import org.bigbluebutton.presentation.ConversionMessageConstants;
import java.util.Map; import org.bigbluebutton.presentation.SupportedFileTypes;
import org.bigbluebutton.presentation.UploadedPresentation;
import org.bigbluebutton.presentation.ConversionMessageConstants; import org.jodconverter.core.office.OfficeException;
import org.bigbluebutton.presentation.SupportedFileTypes; import org.jodconverter.core.office.OfficeUtils;
import org.bigbluebutton.presentation.UploadedPresentation; import org.jodconverter.local.LocalConverter;
import org.jodconverter.core.office.OfficeException; import org.jodconverter.local.office.ExternalOfficeManager;
import org.jodconverter.core.office.OfficeUtils; import org.slf4j.Logger;
import org.jodconverter.local.LocalConverter; import org.slf4j.LoggerFactory;
import org.jodconverter.local.office.ExternalOfficeManager; import com.sun.star.document.UpdateDocMode;
import org.slf4j.Logger; import com.google.gson.Gson;
import org.slf4j.LoggerFactory; public class OfficeToPdfConversionService {
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
import com.google.gson.Gson; private OfficeDocumentValidator2 officeDocumentValidator;
private final ArrayList<ExternalOfficeManager> officeManagers;
public class OfficeToPdfConversionService { private ExternalOfficeManager currentManager = null;
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class); private boolean skipOfficePrecheck = false;
private int sofficeBasePort = 0;
private OfficeDocumentValidator2 officeDocumentValidator; private int sofficeManagers = 0;
private final ArrayList<ExternalOfficeManager> officeManagers; private String sofficeWorkingDirBase = null;
private ExternalOfficeManager currentManager = null; public OfficeToPdfConversionService() throws OfficeException {
private boolean skipOfficePrecheck = false; officeManagers = new ArrayList<>();
private int sofficeBasePort = 0; }
private int sofficeManagers = 0; /*
private String sofficeWorkingDirBase = null; * Convert the Office document to PDF. If successful, update
* UploadPresentation.uploadedFile with the new PDF out and
public OfficeToPdfConversionService() throws OfficeException { * UploadPresentation.lastStepSuccessful to TRUE.
officeManagers = new ArrayList<>(); */
} public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
initialize(pres);
/* if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
* Convert the Office document to PDF. If successful, update // Check if we need to precheck office document
* UploadPresentation.uploadedFile with the new PDF out and if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
* UploadPresentation.lastStepSuccessful to TRUE. Map<String, Object> logData = new HashMap<>();
*/ logData.put("meetingId", pres.getMeetingId());
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) { logData.put("presId", pres.getId());
initialize(pres); logData.put("filename", pres.getName());
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) { logData.put("logCode", "problems_office_to_pdf_validation");
// Check if we need to precheck office document logData.put("message", "Problems detected prior to converting the file to PDF.");
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) { Gson gson = new Gson();
Map<String, Object> logData = new HashMap<>(); String logStr = gson.toJson(logData);
logData.put("meetingId", pres.getMeetingId()); log.warn(" --analytics-- data={}", logStr);
logData.put("presId", pres.getId()); pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
logData.put("filename", pres.getName()); return pres;
logData.put("logCode", "problems_office_to_pdf_validation"); }
logData.put("message", "Problems detected prior to converting the file to PDF."); File pdfOutput = setupOutputPdfFile(pres);
Gson gson = new Gson(); if (convertOfficeDocToPdf(pres, pdfOutput)) {
String logStr = gson.toJson(logData); Map<String, Object> logData = new HashMap<>();
log.warn(" --analytics-- data={}", logStr); logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY); logData.put("filename", pres.getName());
return pres; logData.put("logCode", "office_to_pdf_success");
} logData.put("message", "Successfully converted office file to pdf.");
File pdfOutput = setupOutputPdfFile(pres); Gson gson = new Gson();
if (convertOfficeDocToPdf(pres, pdfOutput)) { String logStr = gson.toJson(logData);
Map<String, Object> logData = new HashMap<>(); log.info(" --analytics-- data={}", logStr);
logData.put("meetingId", pres.getMeetingId()); makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
logData.put("presId", pres.getId()); } else {
logData.put("filename", pres.getName()); Map<String, Object> logData = new HashMap<>();
logData.put("logCode", "office_to_pdf_success"); logData.put("meetingId", pres.getMeetingId());
logData.put("message", "Successfully converted office file to pdf."); logData.put("presId", pres.getId());
Gson gson = new Gson(); logData.put("filename", pres.getName());
String logStr = gson.toJson(logData); logData.put("logCode", "office_to_pdf_failed");
log.info(" --analytics-- data={}", logStr); logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
Gson gson = new Gson();
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput); String logStr = gson.toJson(logData);
} else { log.warn(" --analytics-- data={}", logStr);
Map<String, Object> logData = new HashMap<>(); pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
logData.put("meetingId", pres.getMeetingId()); return pres;
logData.put("presId", pres.getId()); }
logData.put("filename", pres.getName()); }
logData.put("logCode", "office_to_pdf_failed"); return pres;
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf."); }
Gson gson = new Gson(); public void initialize(UploadedPresentation pres) {
String logStr = gson.toJson(logData); pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
log.warn(" --analytics-- data={}", logStr); }
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY); private File setupOutputPdfFile(UploadedPresentation pres) {
return pres; File presentationFile = pres.getUploadedFile();
} String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
} presentationFile.getAbsolutePath().lastIndexOf('.'));
return pres; return new File(filenameWithoutExt + ".pdf");
} }
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
public void initialize(UploadedPresentation pres) { File pdfOutput) {
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY); boolean success = false;
} int attempts = 0;
while(!success) {
private File setupOutputPdfFile(UploadedPresentation pres) { final Map<String, Object> loadProperties = new HashMap<>();
File presentationFile = pres.getUploadedFile(); loadProperties.put("Hidden", true);
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0, loadProperties.put("ReadOnly", true);
presentationFile.getAbsolutePath().lastIndexOf('.')); loadProperties.put("UpdateDocMode", UpdateDocMode.NO_UPDATE);
return new File(filenameWithoutExt + ".pdf"); LocalConverter documentConverter = LocalConverter
} .builder()
.officeManager(currentManager)
private boolean convertOfficeDocToPdf(UploadedPresentation pres, .loadProperties(loadProperties)
File pdfOutput) { .filterChain(new OfficeDocumentConversionFilter())
boolean success = false; .build();
int attempts = 0;
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
while(!success) {
LocalConverter documentConverter = LocalConverter if(!success) {
.builder() // In case of failure, try with other open Office Manager
.officeManager(currentManager)
.filterChain(new OfficeDocumentConversionFilter()) if(++attempts != officeManagers.size()) {
.build(); // Go to next Office Manager ( if the last retry with the first one )
int currentManagerIndex = officeManagers.indexOf(currentManager);
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
if(!success) { if(isLastManager) {
// In case of failure, try with other open Office Manager currentManager = officeManagers.get(0);
} else {
if(++attempts != officeManagers.size()) { currentManager = officeManagers.get(currentManagerIndex+1);
// Go to next Office Manager ( if the last retry with the first one ) }
int currentManagerIndex = officeManagers.indexOf(currentManager); } else {
// We tried to use all our office managers and it's still failing
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 ); break;
if(isLastManager) { }
currentManager = officeManagers.get(0); }
} else { }
currentManager = officeManagers.get(currentManagerIndex+1);
} return success;
} else { }
// We tried to use all our office managers and it's still failing
break; private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
} pres.setUploadedFile(pdf);
} pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
} }
return success; public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
} officeDocumentValidator = v;
}
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
pres.setUploadedFile(pdf); public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY); this.skipOfficePrecheck = skipOfficePrecheck;
} }
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) { public void setSofficeBasePort(int sofficeBasePort) {
officeDocumentValidator = v; this.sofficeBasePort = sofficeBasePort;
} }
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) { public void setSofficeManagers(int sofficeServiceManagers) {
this.skipOfficePrecheck = skipOfficePrecheck; this.sofficeManagers = sofficeServiceManagers;
} }
public void setSofficeBasePort(int sofficeBasePort) { public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
this.sofficeBasePort = sofficeBasePort; this.sofficeWorkingDirBase = sofficeWorkingDirBase;
} }
public void setSofficeManagers(int sofficeServiceManagers) { public void start() {
this.sofficeManagers = sofficeServiceManagers; log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
}
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) { Integer instanceNumber = managerIndex + 1; // starts at 1
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
} try {
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
public void start() {
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort); if(!workingDir.exists()) {
workingDir.mkdir();
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) { }
Integer instanceNumber = managerIndex + 1; // starts at 1
ExternalOfficeManager officeManager = ExternalOfficeManager
try { .builder()
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber)); .connectTimeout(2000L)
.retryInterval(500L)
if(!workingDir.exists()) { .portNumber(sofficeBasePort + managerIndex)
workingDir.mkdir(); .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();
ExternalOfficeManager officeManager = ExternalOfficeManager
.builder() // Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
.connectTimeout(2000L) Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
.retryInterval(500L) method.setAccessible(true);
.portNumber(sofficeBasePort + managerIndex) method.invoke(officeManager);
.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 ) // End of workaround for jodconverter not calling makeTempDir
.workingDir(workingDir)
.build(); officeManager.start();
officeManagers.add(officeManager);
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211) } catch (Exception e) {
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir"); log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
method.setAccessible(true); }
method.invoke(officeManager); }
// End of workaround for jodconverter not calling makeTempDir
if (officeManagers.size() == 0) {
officeManager.start(); log.error("No office managers could be started");
officeManagers.add(officeManager); return;
} catch (Exception e) { }
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
} currentManager = officeManagers.get(0);
} }
if (officeManagers.size() == 0) { public void stop() {
log.error("No office managers could be started"); try {
return; officeManagers.forEach(officeManager -> officeManager.stop() );
} } catch (Exception e) {
log.error("Could not stop Office Manager", e);
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/ * 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 * 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 * 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; import org.bigbluebutton.api.MeetingService;
public class RegisteredUserCleanupTimerTask { public class EnteredUserCleanupTimerTask {
private MeetingService service; private MeetingService service;
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1); private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
private long runEvery = 60000; private long runEvery = 30000;
public void setMeetingService(MeetingService svc) { public void setMeetingService(MeetingService svc) {
this.service = svc; this.service = svc;
@ -50,7 +50,7 @@ public class RegisteredUserCleanupTimerTask {
private class CleanupTask implements Runnable { private class CleanupTask implements Runnable {
@Override @Override
public void run() { 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. 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 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 # which (if exists) will be run by `bbb-conf --setip` and `bbb-conf --restart` before restarting
# BigBlueButton. # BigBlueButton.
# #
# The purpose of apply-config.sh is to make it easy for you apply defaults to BigBlueButton server that get applied after # The purpose of apply-config.sh is to make it easy to apply your configuration changes to a BigBlueButton server
# each package update (since the last step in doing an upate is to run `bbb-conf --setip`. # 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() { enableUFWRules() {
echo " - Enable Firewall and opening 22/tcp, 80/tcp, 443/tcp and 16384:32768/udp" 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() { notCalled() {
# #
# This function is not called. # This function is not called.
@ -112,6 +241,9 @@ source /etc/bigbluebutton/bbb-conf/apply-lib.sh
#enableHTML5ClientLog #enableHTML5ClientLog
#enableUFWRules #enableUFWRules
#enableHTML5CameraQualityThresholds
#enableHTML5WebcamPagination
HERE HERE
chmod +x /etc/bigbluebutton/bbb-conf/apply-config.sh chmod +x /etc/bigbluebutton/bbb-conf/apply-config.sh
## Stop Copying HERE ## Stop Copying HERE

View File

@ -1996,16 +1996,17 @@ if [ -n "$HOST" ]; then
#fi #fi
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 if [ "$ESL_PASSWORD" == "ClueCon" ]; then
ESL_PASSWORD=$(openssl rand -hex 8) 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 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 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 ..." echo "Restarting the BigBlueButton $BIGBLUEBUTTON_RELEASE ..."

View File

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

View File

@ -2,8 +2,6 @@ import { check } from 'meteor/check';
const ANNOTATION_TYPE_TEXT = 'text'; const ANNOTATION_TYPE_TEXT = 'text';
const ANNOTATION_TYPE_PENCIL = 'pencil'; const ANNOTATION_TYPE_PENCIL = 'pencil';
const DEFAULT_TEXT_WIDTH = 30;
const DEFAULT_TEXT_HEIGHT = 20;
// line, triangle, ellipse, rectangle // line, triangle, ellipse, rectangle
function handleCommonAnnotation(meetingId, whiteboardId, userId, annotation) { function handleCommonAnnotation(meetingId, whiteboardId, userId, annotation) {
@ -41,23 +39,6 @@ function handleTextUpdate(meetingId, whiteboardId, userId, annotation) {
id, status, annotationType, annotationInfo, wbId, position, id, status, annotationType, annotationInfo, wbId, position,
} = annotation; } = 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 = { const selector = {
meetingId, meetingId,
id, id,

View File

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

View File

@ -1,7 +1,10 @@
import browser from 'browser-detect'; import browser from 'browser-detect';
import BaseAudioBridge from './base'; import BaseAudioBridge from './base';
import logger from '/imports/startup/client/logger'; import logger from '/imports/startup/client/logger';
import { fetchStunTurnServers, getFallbackStun } from '/imports/utils/fetchStunTurnServers'; import {
fetchWebRTCMappedStunTurnServers,
getMappedFallbackStun,
} from '/imports/utils/fetchStunTurnServers';
import { import {
isUnifiedPlan, isUnifiedPlan,
toUnifiedPlan, toUnifiedPlan,
@ -20,13 +23,13 @@ const MEDIA_TAG = MEDIA.mediaTag;
const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout; const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout; const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries; const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
const RELAY_ONLY_ON_RECONNECT = MEDIA.relayOnlyOnReconnect;
const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain; const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain;
const ICE_NEGOTIATION_FAILED = ['iceConnectionFailed'];
const CALL_CONNECT_TIMEOUT = 20000; const CALL_CONNECT_TIMEOUT = 20000;
const ICE_NEGOTIATION_TIMEOUT = 20000; const ICE_NEGOTIATION_TIMEOUT = 20000;
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber'; 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 = () => { const getAudioSessionNumber = () => {
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10); let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
@ -39,6 +42,24 @@ const getAudioSessionNumber = () => {
return currItem; 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 { class SIPSession {
constructor(user, userData, protocol, hostname, constructor(user, userData, protocol, hostname,
baseCallStates, baseErrorCodes, reconnectAttempt) { baseCallStates, baseErrorCodes, reconnectAttempt) {
@ -49,9 +70,15 @@ class SIPSession {
this.baseCallStates = baseCallStates; this.baseCallStates = baseCallStates;
this.baseErrorCodes = baseErrorCodes; this.baseErrorCodes = baseErrorCodes;
this.reconnectAttempt = reconnectAttempt; 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) => { return new Promise((resolve, reject) => {
const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge; 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 // If there's an extension passed it means that we're joining the echo test first
this.inEchoTest = !!extension; this.inEchoTest = !!extension;
return this.doCall({ callExtension, isListenOnly, inputStream }) return this.doCall({ callExtension, isListenOnly, inputDeviceId })
.catch((reason) => { .catch((reason) => {
reject(reason); reject(reason);
}); });
@ -87,7 +114,7 @@ class SIPSession {
async getIceServers(sessionToken) { async getIceServers(sessionToken) {
try { try {
const iceServers = await fetchStunTurnServers(sessionToken); const iceServers = await fetchWebRTCMappedStunTurnServers(sessionToken);
return iceServers; return iceServers;
} catch (error) { } catch (error) {
logger.error({ logger.error({
@ -98,15 +125,18 @@ class SIPSession {
callerIdName: this.user.callerIdName, callerIdName: this.user.callerIdName,
}, },
}, 'Full audio bridge failed to fetch STUN/TURN info'); }, 'Full audio bridge failed to fetch STUN/TURN info');
return getFallbackStun(); return getMappedFallbackStun();
} }
} }
doCall(options) { doCall(options) {
const { const {
isListenOnly, isListenOnly,
inputDeviceId,
} = options; } = options;
this.inputDeviceId = inputDeviceId;
const { const {
userId, userId,
name, name,
@ -124,8 +154,7 @@ class SIPSession {
return this.getIceServers(sessionToken) return this.getIceServers(sessionToken)
.then(this.createUserAgent.bind(this)) .then(this.createUserAgent.bind(this))
.then(this.inviteUserAgent.bind(this)) .then(this.inviteUserAgent.bind(this));
.then(this.setupEventHandlers.bind(this));
} }
transferCall(onTransferSuccess) { transferCall(onTransferSuccess) {
@ -149,7 +178,18 @@ class SIPSession {
}, CALL_TRANSFER_TIMEOUT); }, CALL_TRANSFER_TIMEOUT);
// This is is the call transfer code ask @chadpilkey // 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) => { Tracker.autorun((c) => {
trackerControl = 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() { exitAudio() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let hangupRetries = 0; let hangupRetries = 0;
let hangup = false; this._hangupFlag = false;
this.userRequestedHangup = true; 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 = () => { const tryHangup = () => {
if ((this.currentSession && this.currentSession.endTime) if (this._hangupFlag) {
|| (this.userAgent && this.userAgent.status === SIP.UA.C.STATUS_USER_CLOSED)) { resolve();
hangup = true; }
if ((this.currentSession
&& (this.currentSession.state === SIP.SessionState.Terminated))
|| (this.userAgent && (!this.userAgent.isConnected()))) {
this._hangupFlag = true;
return resolve(); return resolve();
} }
if (this.currentSession) this.currentSession.bye(); if (this.currentSession
if (this.userAgent) this.userAgent.stop(); && ((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; hangupRetries += 1;
@ -206,23 +300,24 @@ class SIPSession {
return reject(this.baseErrorCodes.REQUEST_TIMEOUT); return reject(this.baseErrorCodes.REQUEST_TIMEOUT);
} }
if (!hangup) return tryHangup(); if (!this._hangupFlag) return tryHangup();
return resolve(); return resolve();
}, CALL_HANGUP_TIMEOUT); }, CALL_HANGUP_TIMEOUT);
}; };
if (this.currentSession) {
this.currentSession.on('bye', () => {
hangup = true;
resolve();
});
}
return tryHangup(); return tryHangup();
}); });
} }
createUserAgent({ stun, turn }) { onBeforeUnload() {
if (this.userAgent) {
return this.userAgent.stop();
}
return Promise.resolve();
}
createUserAgent(iceServers) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject(); if (this.userRequestedHangup === true) reject();
@ -236,17 +331,6 @@ class SIPSession {
sessionToken, sessionToken,
} = this.user; } = 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'); logger.debug({ logCode: 'sip_js_creating_user_agent', extraInfo: { callerIdName } }, 'Creating the user agent');
if (this.userAgent && this.userAgent.isConnected()) { if (this.userAgent && this.userAgent.isConnected()) {
@ -275,112 +359,261 @@ class SIPSession {
let userAgentConnected = false; let userAgentConnected = false;
const token = `sessionToken=${sessionToken}`; const token = `sessionToken=${sessionToken}`;
this.userAgent = new window.SIP.UA({ this.userAgent = new SIP.UserAgent({
uri: `sip:${encodeURIComponent(callerIdName)}@${hostname}`, uri: SIP.UserAgent.makeURI(`sip:${encodeURIComponent(callerIdName)}@${hostname}`),
wsServers: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`, transportOptions: {
server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS,
},
sessionDescriptionHandlerFactoryOptions: {
peerConnectionConfiguration: {
iceServers,
},
},
displayName: callerIdName, displayName: callerIdName,
register: false, register: false,
traceSip: true,
autostart: false,
userAgentString: 'BigBlueButton', userAgentString: 'BigBlueButton',
stunServers: stun,
turnServers: turn,
hackPlanBUnifiedPlanTranslation: isSafari,
hackAddAudioTransceiver: isSafariWebview,
relayOnlyOnReconnect: this.reconnectAttempt && RELAY_ONLY_ON_RECONNECT,
localSdpCallback,
remoteSdpCallback,
}); });
const handleUserAgentConnection = () => { const handleUserAgentConnection = () => {
userAgentConnected = true; if (!userAgentConnected) {
resolve(this.userAgent); userAgentConnected = true;
resolve(this.userAgent);
}
}; };
const handleUserAgentDisconnection = () => { const handleUserAgentDisconnection = () => {
if (this.userAgent) { if (this.userAgent) {
this.userAgent.removeAllListeners(); if (this.userRequestedHangup) {
this.userAgent.stop(); 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.transport.onConnect = handleUserAgentConnection;
this.userAgent.on('disconnected', handleUserAgentDisconnection); 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) { inviteUserAgent(userAgent) {
if (this.userRequestedHangup === true) Promise.reject(); return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
const {
hostname,
} = this;
const { const {
hostname, callExtension,
} = this; isListenOnly,
} = this.callOptions;
const {
inputStream,
callExtension,
} = this.callOptions;
const options = { const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`);
media: {
stream: inputStream, const audioDeviceConstraint = this.inputDeviceId
constraints: { ? { deviceId: { exact: this.inputDeviceId } }
audio: true, : true;
video: false,
const inviterOptions = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: isListenOnly
? false
: audioDeviceConstraint,
video: false,
},
}, },
render: { sessionDescriptionHandlerModifiersPostICEGathering:
remote: document.querySelector(MEDIA_TAG), [stripMDnsCandidates],
}, };
},
RTCConstraints: {
offerToReceiveAudio: true,
offerToReceiveVideo: false,
},
};
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) { setupEventHandlers(currentSession) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject(); if (this.userRequestedHangup === true) reject();
const { mediaHandler } = currentSession;
let iceCompleted = false; let iceCompleted = false;
let fsReady = false; let fsReady = false;
this.currentSession = currentSession; const setupRemoteMedia = () => {
const mediaElement = document.querySelector(MEDIA_TAG);
let connectionCompletedEvents = ['iceConnectionCompleted', 'iceConnectionConnected']; this.remoteStream = new MediaStream();
// 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 this.currentSession.sessionDescriptionHandler
// way to ignore one status is to not listen for it. .peerConnection.getReceivers().forEach((receiver) => {
if (browser().name === 'edge') { if (receiver.track) {
connectionCompletedEvents = ['iceConnectionCompleted']; 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 = () => { const checkIfCallReady = () => {
if (this.userRequestedHangup === true) { if (this.userRequestedHangup === true) {
@ -388,8 +621,28 @@ class SIPSession {
resolve(); 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) { if (iceCompleted && fsReady) {
this.webrtcConnected = true; 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 }); this.callback({ status: this.baseCallStates.started });
resolve(); resolve();
} }
@ -412,7 +665,6 @@ class SIPSession {
const handleSessionAccepted = () => { const handleSessionAccepted = () => {
logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted'); logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted');
clearTimeout(callTimeout); clearTimeout(callTimeout);
currentSession.off('accepted', handleSessionAccepted);
// If ICE isn't connected yet then start timeout waiting for ICE to finish // If ICE isn't connected yet then start timeout waiting for ICE to finish
if (!iceCompleted) { if (!iceCompleted) {
@ -420,45 +672,114 @@ class SIPSession {
this.callback({ this.callback({
status: this.baseCallStates.failed, status: this.baseCallStates.failed,
error: 1010, error: 1010,
bridgeError: `ICE negotiation timeout after ${ICE_NEGOTIATION_TIMEOUT / 1000}s`, bridgeError: 'ICE negotiation timeout after '
+ `${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
}); });
this.exitAudio(); this.exitAudio();
reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}, ICE_NEGOTIATION_TIMEOUT); }, 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(); 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) => { const handleSessionTerminated = (message, cause) => {
clearTimeout(callTimeout); clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout); clearTimeout(iceNegotiationTimeout);
currentSession.off('terminated', handleSessionTerminated);
if (!message && !cause && !!this.userRequestedHangup) { if (!message && !cause && !!this.userRequestedHangup) {
return this.callback({ 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({ logger.error({
logCode: 'sip_js_call_terminated', logCode: 'sip_js_call_terminated',
extraInfo: { cause, callerIdName: this.user.callerIdName }, extraInfo: { cause, callerIdName: this.user.callerIdName },
@ -484,39 +809,33 @@ class SIPSession {
bridgeError: cause, bridgeError: cause,
}); });
}; };
currentSession.on('terminated', handleSessionTerminated);
const handleIceNegotiationFailed = (peer) => { currentSession.stateChange.addListener((state) => {
if (iceCompleted) { switch (state) {
logger.error({ logCode: 'sipjs_ice_failed_after', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed after success'); case SIP.SessionState.Initial:
} else { break;
logger.error({ logCode: 'sipjs_ice_failed_before', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed before success'); 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); this._currentSessionState = state;
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));
Tracker.autorun((c) => { Tracker.autorun((c) => {
const selector = { meetingId: Auth.meetingID, userId: Auth.userID }; 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; 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 !== ''; const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== '';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -603,7 +928,12 @@ export default class SIPBridge extends BaseAudioBridge {
const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined; const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined;
this.activeSession = new SIPSession(this.user, this.userData, this.protocol, this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
hostname, this.baseCallStates, this.baseErrorCodes, true); 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) => { .then((value) => {
resolve(value); resolve(value);
}).catch((reason) => { }).catch((reason) => {
@ -615,7 +945,12 @@ export default class SIPBridge extends BaseAudioBridge {
return managerCallback(message); return managerCallback(message);
}; };
this.activeSession.joinAudio({ isListenOnly, extension, inputStream }, callback) const { inputDeviceId } = this.media.inputDevice;
this.activeSession.joinAudio({
isListenOnly,
extension,
inputDeviceId,
}, callback)
.then((value) => { .then((value) => {
resolve(value); resolve(value);
}).catch((reason) => { }).catch((reason) => {
@ -630,8 +965,8 @@ export default class SIPBridge extends BaseAudioBridge {
getPeerConnection() { getPeerConnection() {
const { currentSession } = this.activeSession; const { currentSession } = this.activeSession;
if (currentSession && currentSession.mediaHandler) { if (currentSession && currentSession.sessionDescriptionHandler) {
return currentSession.mediaHandler.peerConnection; return currentSession.sessionDescriptionHandler.peerConnection;
} }
return null; return null;
} }
@ -641,62 +976,16 @@ export default class SIPBridge extends BaseAudioBridge {
} }
setDefaultInputDevice() { setDefaultInputDevice() {
const handleMediaSuccess = (mediaStream) => { this.media.inputDevice.inputDeviceId = DEFAULT_INPUT_DEVICE_ID;
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);
} }
changeInputDevice(deviceId, deviceLabel) { async changeInputDeviceId(inputDeviceId) {
const { if (!inputDeviceId) {
media, throw new Error();
} = 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);
} }
if ('AudioContext' in window) { this.media.inputDevice.inputDeviceId = inputDeviceId;
media.inputDevice.audioContext = new window.AudioContext(); return inputDeviceId;
} 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);
} }
async changeOutputDevice(value) { 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) { export default function createBreakoutRoom(rooms, durationInMinutes, record = false) {
const REDIS_CONFIG = Meteor.settings.private.redis; const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; 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 { meetingId, requesterUserId } = extractCredentials(this.userId);
const eventName = 'CreateBreakoutRoomsCmdMsg'; 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 = { const payload = {
record, record,
durationInMinutes, durationInMinutes,

View File

@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check'; import { check } from 'meteor/check';
import Logger from '/imports/startup/server/logger'; import Logger from '/imports/startup/server/logger';
import Meetings from '/imports/api/meetings'; import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import RedisPubSub from '/imports/startup/server/redis'; import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers'; import { extractCredentials } from '/imports/api/common/server/helpers';
@ -10,16 +11,29 @@ export default function startWatchingExternalVideo(options) {
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'StartExternalVideoMsg'; const EVENT_NAME = 'StartExternalVideoMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId); const { meetingId, requesterUserId: userId } = extractCredentials(this.userId);
const { externalVideoUrl } = options; 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 { Meteor } from 'meteor/meteor';
import Logger from '/imports/startup/server/logger'; import Logger from '/imports/startup/server/logger';
import Meetings from '/imports/api/meetings'; import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import RedisPubSub from '/imports/startup/server/redis'; import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers'; import { extractCredentials } from '/imports/api/common/server/helpers';
@ -9,19 +10,33 @@ export default function stopWatchingExternalVideo(options) {
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'StopExternalVideoMsg'; const EVENT_NAME = 'StopExternalVideoMsg';
if (this.userId) { const { meetingId, requesterUserId } = this.userId ? extractCredentials(this.userId) : options;
options = extractCredentials(this.userId);
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 = { const payload = {
userId: requesterUserId, 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); 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 clearRecordMeeting from './clearRecordMeeting';
import clearVoiceCallStates from '/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates'; import clearVoiceCallStates from '/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates';
import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams'; import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams';
import BannedUsers from '/imports/api/users/server/store/bannedUsers';
export default function meetingHasEnded(meetingId) { export default function meetingHasEnded(meetingId) {
removeAnnotationsStreamer(meetingId); removeAnnotationsStreamer(meetingId);
@ -46,6 +47,7 @@ export default function meetingHasEnded(meetingId) {
clearRecordMeeting(meetingId); clearRecordMeeting(meetingId);
clearVoiceCallStates(meetingId); clearVoiceCallStates(meetingId);
clearVideoStreams(meetingId); clearVideoStreams(meetingId);
BannedUsers.delete(meetingId);
return Logger.info(`Cleared Meetings with id ${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 Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers'; 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 REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'RespondToPollReqMsg'; const EVENT_NAME = 'RespondToPollReqMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId); 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(pollAnswerId, Number);
check(currentPoll, Object); check(pollId, String);
check(currentPoll.meetingId, String);
const payload = {
requesterId: requesterUserId,
pollId: currentPoll.id,
questionId: 0,
answerId: pollAnswerId,
};
const selector = { const selector = {
users: requesterUserId, users: requesterUserId,
@ -39,6 +20,18 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
'answers.id': pollAnswerId, '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 = { const modifier = {
$pull: { $pull: {
users: requesterUserId, users: requesterUserId,
@ -47,11 +40,11 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
const cb = (err) => { const cb = (err) => {
if (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}, ` return Logger.info(`Removed responded user=${requesterUserId} from poll (meetingId: ${meetingId}, `
+ `pollId: ${currentPoll.id}!)`); + `pollId: ${pollId}!)`);
}; };
Polls.update(selector, modifier, cb); Polls.update(selector, modifier, cb);

View File

@ -63,15 +63,80 @@ export default class KurentoScreenshareBridge {
return normalizedError; 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 iceServers = [];
let started = false; let started = false;
try { try {
iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken()); iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken());
} catch (error) { } catch (error) {
logger.error({ logCode: 'screenshare_viwer_fetchstunturninfo_error', extraInfo: { error } }, logger.error({
'Screenshare bridge failed to fetch STUN/TURN info, using default'); logCode: 'screenshare_viewer_fetchstunturninfo_error',
extraInfo: { error },
}, 'Screenshare bridge failed to fetch STUN/TURN info, using default');
iceServers = getMappedFallbackStun(); iceServers = getMappedFallbackStun();
} finally { } finally {
const options = { const options = {
@ -81,52 +146,6 @@ export default class KurentoScreenshareBridge {
userName: getUsername(), 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) => { const onFail = (error) => {
KurentoScreenshareBridge.handleViewerFailure(error, started); KurentoScreenshareBridge.handleViewerFailure(error, started);
}; };
@ -139,10 +158,11 @@ export default class KurentoScreenshareBridge {
const { webRtcPeer } = window.kurentoManager.kurentoVideo; const { webRtcPeer } = window.kurentoManager.kurentoVideo;
if (webRtcPeer) { if (webRtcPeer) {
const stream = webRtcPeer.getRemoteStream(); const stream = webRtcPeer.getRemoteStream();
screenshareTag.muted = true; KurentoScreenshareBridge.screenshareElementLoadAndPlay(
screenshareTag.pause(); stream,
screenshareTag.srcObject = stream; screenshareMediaElement,
playElement(); true,
);
} }
}; };

View File

@ -31,6 +31,7 @@ const oldParametersKeys = Object.keys(oldParameters);
const currentParameters = [ const currentParameters = [
// APP // APP
'bbb_ask_for_feedback_on_logout', 'bbb_ask_for_feedback_on_logout',
'bbb_override_default_locale',
'bbb_auto_join_audio', 'bbb_auto_join_audio',
'bbb_client_title', 'bbb_client_title',
'bbb_force_listen_only', 'bbb_force_listen_only',
@ -69,7 +70,7 @@ const currentParameters = [
function valueParser(val) { function valueParser(val) {
try { try {
const parsedValue = JSON.parse(val.toLowerCase()); const parsedValue = JSON.parse(val.toLowerCase().trim());
return parsedValue; return parsedValue;
} catch (error) { } catch (error) {
logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`); logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`);
@ -86,21 +87,22 @@ export default function addUserSettings(settings) {
settings.forEach((el) => { settings.forEach((el) => {
const settingKey = Object.keys(el).shift(); const settingKey = Object.keys(el).shift();
const normalizedKey = settingKey.trim();
if (currentParameters.includes(settingKey)) { if (currentParameters.includes(normalizedKey)) {
if (!Object.keys(parameters).includes(settingKey)) { if (!Object.keys(parameters).includes(normalizedKey)) {
parameters = { parameters = {
[settingKey]: valueParser(el[settingKey]), [normalizedKey]: valueParser(el[settingKey]),
...parameters, ...parameters,
}; };
} else { } else {
parameters[settingKey] = el[settingKey]; parameters[normalizedKey] = el[settingKey];
} }
return; return;
} }
if (oldParametersKeys.includes(settingKey)) { if (oldParametersKeys.includes(normalizedKey)) {
const matchingNewKey = oldParameters[settingKey]; const matchingNewKey = oldParameters[normalizedKey];
if (!Object.keys(parameters).includes(matchingNewKey)) { if (!Object.keys(parameters).includes(matchingNewKey)) {
parameters = { parameters = {
[matchingNewKey]: valueParser(el[settingKey]), [matchingNewKey]: valueParser(el[settingKey]),
@ -110,7 +112,7 @@ export default function addUserSettings(settings) {
return; return;
} }
logger.warn(`Parameter ${settingKey} not handled`); logger.warn(`Parameter ${normalizedKey} not handled`);
}); });
const settingsAdded = []; 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 */ /* Logic migrated from validateAuthToken method ( postponed to only run in case of success response ) - Begin */
const sessionId = `${meetingId}--${userId}`; const sessionId = `${meetingId}--${userId}`;
methodInvocationObject.setUserId(sessionId); methodInvocationObject.setUserId(sessionId);
const User = Users.findOne({ 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 the current user connection is not the same that triggered the leave we skip
if (User.connectionId !== connectionId) { if (User.connectionId !== connectionId) {
Logger.info(`Skipping userLeaving. User connectionId=${User.connectionId} is different from requester connectionId=${connectionId}`);
return false; return false;
} }

View File

@ -3,6 +3,7 @@ import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger'; import Logger from '/imports/startup/server/logger';
import pendingAuthenticationsStore from '../store/pendingAuthentications'; import pendingAuthenticationsStore from '../store/pendingAuthentications';
import BannedUsers from '../store/bannedUsers'; import BannedUsers from '../store/bannedUsers';
import Users from '/imports/api/users';
export default function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) { export default function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) {
const REDIS_CONFIG = Meteor.settings.private.redis; const REDIS_CONFIG = Meteor.settings.private.redis;
@ -13,10 +14,27 @@ export default function validateAuthToken(meetingId, requesterUserId, requesterT
if (externalId) { if (externalId) {
if (BannedUsers.has(meetingId, externalId)) { if (BannedUsers.has(meetingId, externalId)) {
Logger.warn(`A banned user with extId ${externalId} tried to enter in meeting ${meetingId}`); 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 ) // Store reference of methodInvocationObject ( to postpone the connection userId definition )
pendingAuthenticationsStore.add(meetingId, requesterUserId, requesterToken, this); pendingAuthenticationsStore.add(meetingId, requesterUserId, requesterToken, this);

View File

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

View File

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

View File

@ -1,109 +1,10 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { withTracker } from 'meteor/react-meteor-data'; import { withTracker } from 'meteor/react-meteor-data';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider } from 'react-intl';
import Settings from '/imports/ui/services/settings'; import Settings from '/imports/ui/services/settings';
import LoadingScreen from '/imports/ui/components/loading-screen/component'; import LoadingScreen from '/imports/ui/components/loading-screen/component';
import getFromUserSettings from '/imports/ui/services/users-settings';
// 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,
]);
const propTypes = { const propTypes = {
locale: PropTypes.string, locale: PropTypes.string,
@ -120,7 +21,12 @@ const defaultProps = {
class IntlStartup extends Component { class IntlStartup extends Component {
static saveLocale(localeName) { static saveLocale(localeName) {
if (Settings.application.locale !== localeName) {
Settings.application.changedLocale = localeName;
}
Settings.application.locale = localeName; Settings.application.locale = localeName;
if (RTL_LANGUAGES.includes(localeName.substring(0, 2))) { if (RTL_LANGUAGES.includes(localeName.substring(0, 2))) {
document.body.parentNode.setAttribute('dir', 'rtl'); document.body.parentNode.setAttribute('dir', 'rtl');
Settings.application.isRTL = true; Settings.application.isRTL = true;
@ -155,16 +61,32 @@ class IntlStartup extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { fetching, normalizedLocale, localeChanged } = this.state; const { fetching, normalizedLocale, localeChanged } = this.state;
const { locale } = this.props; const { locale, overrideLocale, changedLocale } = this.props;
if (prevProps.locale !== locale) { if (prevProps.locale !== locale) {
this.setState({ this.setState({
localeChanged: true, localeChanged: true,
}); });
} }
if (overrideLocale) {
if (!fetching
&& (overrideLocale !== normalizedLocale.toLowerCase())
&& !localeChanged
&& !changedLocale) {
this.fetchLocalizedMessages(overrideLocale);
}
if (!localeChanged) {
return;
}
}
if (!fetching if (!fetching
&& normalizedLocale && normalizedLocale
&& ((locale.toLowerCase() !== normalizedLocale.toLowerCase()))) { && ((locale.toLowerCase() !== normalizedLocale.toLowerCase()))) {
if (((DEFAULT_LANGUAGE === normalizedLocale.toLowerCase()) && !localeChanged)) return; if (((DEFAULT_LANGUAGE === normalizedLocale.toLowerCase()) && !localeChanged)) return;
this.fetchLocalizedMessages(locale); this.fetchLocalizedMessages(locale);
} }
} }
@ -209,10 +131,12 @@ class IntlStartup extends Component {
} }
const IntlStartupContainer = withTracker(() => { const IntlStartupContainer = withTracker(() => {
const { locale } = Settings.application; const { locale, changedLocale } = Settings.application;
const overrideLocale = getFromUserSettings('bbb_override_default_locale', null);
return { return {
locale, locale,
overrideLocale,
changedLocale,
}; };
})(IntlStartup); })(IntlStartup);

View File

@ -71,7 +71,8 @@ class MeteorStream {
'logClient', 'logClient',
nameFromLevel[this.rec.level], nameFromLevel[this.rec.level],
this.rec.msg, 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 { check } from 'meteor/check';
import Logger from './logger'; import Logger from './logger';
import Redis from './redis'; import Redis from './redis';
import setMinBrowserVersions from './minBrowserVersion'; import setMinBrowserVersions from './minBrowserVersion';
import userLeaving from '/imports/api/users/server/methods/userLeaving'; import userLeaving from '/imports/api/users/server/methods/userLeaving';
let guestWaitHtml = '';
const AVAILABLE_LOCALES = fs.readdirSync('assets/app/locales'); 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(() => { Meteor.startup(() => {
const APP_CONFIG = Meteor.settings.public.app; const APP_CONFIG = Meteor.settings.public.app;
@ -151,23 +178,13 @@ WebApp.connectHandlers.use('/locale', (req, res) => {
}); });
WebApp.connectHandlers.use('/locales', (req, res) => { WebApp.connectHandlers.use('/locales', (req, res) => {
if (!avaibleLocalesNames.length) { if (!avaibleLocalesNamesJSON) {
try { avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions());
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}`);
}
} }
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.writeHead(200); res.writeHead(200);
res.end(JSON.stringify(avaibleLocalesNames)); res.end(avaibleLocalesNamesJSON);
}); });
WebApp.connectHandlers.use('/feedback', (req, res) => { WebApp.connectHandlers.use('/feedback', (req, res) => {
@ -227,6 +244,21 @@ WebApp.connectHandlers.use('/useragent', (req, res) => {
res.end(response); 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 eventEmitter = Redis.emitter;
export const redisPubSub = Redis; export const redisPubSub = Redis;

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash'; import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
import browser from 'browser-detect'; 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 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 = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
isInvitation: PropTypes.bool.isRequired, isInvitation: PropTypes.bool.isRequired,
meetingName: PropTypes.string.isRequired, meetingName: PropTypes.string.isRequired,
users: PropTypes.arrayOf(PropTypes.object).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 { .boxContainer {
height: 50vh;
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: repeat(3, minmax(4rem, 16rem));
grid-template-rows: 33% 33% 33%; grid-template-rows: repeat(auto-fill, minmax(4rem, 8rem));
grid-gap: 1.5rem 1rem; grid-gap: 1.5rem 1rem;
box-sizing: border-box;
padding-bottom: 1rem;
} }
.changeToWarn { .changeToWarn {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; 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 Button from '/imports/ui/components/button/component';
import Modal from '/imports/ui/components/modal/simple/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'; import { styles } from './styles';
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
responseDelay: PropTypes.number.isRequired, responseDelay: PropTypes.number.isRequired,
}; };

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, defineMessages, intlShape } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import { styles } from './styles'; import { styles } from './styles';
const intlMessages = defineMessages({ const intlMessages = defineMessages({
@ -23,7 +23,7 @@ const intlMessages = defineMessages({
}); });
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
formattedDialNum: PropTypes.string.isRequired, formattedDialNum: PropTypes.string.isRequired,
telVoice: 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 Button from '/imports/ui/components/button/component';
import { Session } from 'meteor/session'; import { Session } from 'meteor/session';
import { import {
defineMessages, injectIntl, intlShape, FormattedMessage, defineMessages, injectIntl, FormattedMessage,
} from 'react-intl'; } from 'react-intl';
import { styles } from './styles'; import { styles } from './styles';
import PermissionsOverlay from '../permissions-overlay/component'; import PermissionsOverlay from '../permissions-overlay/component';
@ -16,7 +16,7 @@ import AudioDial from '../audio-dial/component';
import AudioAutoplayPrompt from '../autoplay/component'; import AudioAutoplayPrompt from '../autoplay/component';
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
closeModal: PropTypes.func.isRequired, closeModal: PropTypes.func.isRequired,
joinMicrophone: PropTypes.func.isRequired, joinMicrophone: PropTypes.func.isRequired,
joinListenOnly: PropTypes.func.isRequired, joinListenOnly: PropTypes.func.isRequired,
@ -245,8 +245,6 @@ class AudioModal extends Component {
} }
const { const {
inputDeviceId,
outputDeviceId,
joinEchoTest, joinEchoTest,
} = this.props; } = this.props;
@ -268,12 +266,27 @@ class AudioModal extends Component {
disableActions: false, disableActions: false,
}); });
}).catch((err) => { }).catch((err) => {
if (err.type === 'MEDIA_ERROR') { const { type } = err;
this.setState({ switch (type) {
content: 'help', case 'MEDIA_ERROR':
errCode: err.code, this.setState({
disableActions: false, 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 React from 'react';
import PropTypes from 'prop-types'; 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 Button from '/imports/ui/components/button/component';
import { withModalMounter } from '/imports/ui/components/modal/service'; import { withModalMounter } from '/imports/ui/components/modal/service';
import DeviceSelector from '/imports/ui/components/audio/device-selector/component'; import DeviceSelector from '/imports/ui/components/audio/device-selector/component';
@ -9,7 +9,7 @@ import cx from 'classnames';
import { styles } from './styles'; import { styles } from './styles';
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
changeInputDevice: PropTypes.func.isRequired, changeInputDevice: PropTypes.func.isRequired,
changeOutputDevice: PropTypes.func.isRequired, changeOutputDevice: PropTypes.func.isRequired,
handleBack: PropTypes.func.isRequired, handleBack: PropTypes.func.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { findDOMNode } from 'react-dom';
import { isMobile } from 'react-device-detect'; import { isMobile } from 'react-device-detect';
import TetherComponent from 'react-tether'; import TetherComponent from 'react-tether';
import cx from 'classnames'; 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 Button from '/imports/ui/components/button/component';
import screenreaderTrap from 'makeup-screenreader-trap'; import screenreaderTrap from 'makeup-screenreader-trap';
import { styles } from './styles'; import { styles } from './styles';
@ -52,7 +52,7 @@ const propTypes = {
onHide: PropTypes.func, onHide: PropTypes.func,
onShow: PropTypes.func, onShow: PropTypes.func,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
tethered: PropTypes.bool, tethered: PropTypes.bool,
}; };
@ -300,4 +300,4 @@ class Dropdown extends Component {
Dropdown.propTypes = propTypes; Dropdown.propTypes = propTypes;
Dropdown.defaultProps = defaultProps; 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 React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash'; import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
import Icon from '/imports/ui/components/icon/component'; import Icon from '/imports/ui/components/icon/component';
@ -18,7 +19,13 @@ const defaultProps = {
tabIndex: 0, tabIndex: 0,
}; };
export default class DropdownListItem extends Component { const messages = defineMessages({
activeAriaLabel: {
id: 'app.dropdown.list.item.activeLabel',
},
});
class DropdownListItem extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.labelID = _.uniqueId('dropdown-item-label-'); this.labelID = _.uniqueId('dropdown-item-label-');
@ -38,9 +45,12 @@ export default class DropdownListItem extends Component {
render() { render() {
const { const {
id, label, description, children, injectRef, tabIndex, onClick, onKeyDown, id, label, description, children, injectRef, tabIndex, onClick, onKeyDown,
className, style, className, style, intl,
} = this.props; } = this.props;
const isSelected = className && className.includes('emojiSelected');
const _label = isSelected ? `${label} (${intl.formatMessage(messages.activeAriaLabel)})` : label;
return ( return (
<li <li
id={id} id={id}
@ -59,8 +69,8 @@ export default class DropdownListItem extends Component {
children || this.renderDefault() children || this.renderDefault()
} }
{ {
label ? label
(<span id={this.labelID} key="labelledby" hidden>{label}</span>) ? (<span id={this.labelID} key="labelledby" hidden>{_label}</span>)
: null : null
} }
<span id={this.descID} key="describedby" hidden>{description}</span> <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.propTypes = propTypes;
DropdownListItem.defaultProps = defaultProps; DropdownListItem.defaultProps = defaultProps;

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; 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 Button from '/imports/ui/components/button/component';
import Modal from '/imports/ui/components/modal/simple/component'; import Modal from '/imports/ui/components/modal/simple/component';
import { styles } from './styles'; import { styles } from './styles';
@ -26,7 +26,7 @@ const intlMessages = defineMessages({
}); });
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
closeModal: PropTypes.func.isRequired, closeModal: PropTypes.func.isRequired,
endMeeting: 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 { withModalMounter } from '/imports/ui/components/modal/service';
import { makeCall } from '/imports/ui/services/api'; import { makeCall } from '/imports/ui/services/api';
import EndMeetingComponent from './component'; import EndMeetingComponent from './component';
import logger from '/imports/startup/client/logger';
const EndMeetingContainer = props => <EndMeetingComponent {...props} />; const EndMeetingContainer = props => <EndMeetingComponent {...props} />;
@ -12,6 +13,10 @@ export default withModalMounter(withTracker(({ mountModal }) => ({
}, },
endMeeting: () => { 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'); makeCall('endMeeting');
mountModal(null); mountModal(null);
}, },

View File

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

View File

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

View File

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

View File

@ -178,6 +178,7 @@ class JoinHandler extends Component {
const { response } = parseToJson; const { response } = parseToJson;
setLogoutURL(response); setLogoutURL(response);
logUserInfo();
if (response.returncode !== 'FAILED') { if (response.returncode !== 'FAILED') {
await setAuth(response); await setAuth(response);
@ -185,7 +186,6 @@ class JoinHandler extends Component {
setBannerProps(response); setBannerProps(response);
setLogoURL(response); setLogoURL(response);
setModOnlyMessage(response); setModOnlyMessage(response);
logUserInfo();
Tracker.autorun(async (cd) => { Tracker.autorun(async (cd) => {
const user = Users.findOne({ userId: Auth.userID, approved: true }, { fields: { _id: 1 } }); 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') { switch (openPanel) {
newChatSize = { case 'userlist': {
width: 0, newChatSize = {
}; width: 0,
newBreakoutRoomSize = { };
width: 0, newBreakoutRoomSize = {
}; width: 0,
} };
break;
if (openPanel === '') { }
newUserListSize = { case 'chat': {
width: 0, newBreakoutRoomSize = {
}; width: 0,
newChatSize = { };
width: 0, break;
}; }
newBreakoutRoomSize = { case 'breakoutroom': {
width: 0, newChatSize = {
}; width: 0,
};
break;
}
case '': {
newUserListSize = {
width: 0,
};
newChatSize = {
width: 0,
};
newBreakoutRoomSize = {
width: 0,
};
break;
}
default: {
throw new Error('Unexpected openPanel value');
}
} }
return { return {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import _ from 'lodash'; import _ from 'lodash';
import { defineMessages, injectIntl, intlShape } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles'; import { styles } from './styles';
const intlMessages = defineMessages({ const intlMessages = defineMessages({
@ -16,7 +16,7 @@ const intlMessages = defineMessages({
}); });
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
onRate: PropTypes.func.isRequired, onRate: PropTypes.func.isRequired,
total: PropTypes.string.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 humanizeSeconds from '/imports/utils/humanizeSeconds';
import Tooltip from '/imports/ui/components/tooltip/component'; import Tooltip from '/imports/ui/components/tooltip/component';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { styles } from './styles'; import { styles } from './styles';
const intlMessages = defineMessages({ const intlMessages = defineMessages({
@ -46,7 +46,7 @@ const intlMessages = defineMessages({
}); });
const propTypes = { const propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
amIModerator: PropTypes.bool, amIModerator: PropTypes.bool,
record: PropTypes.bool, record: PropTypes.bool,
recording: PropTypes.bool, recording: PropTypes.bool,

View File

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

View File

@ -98,27 +98,6 @@ class PanelManager extends Component {
this.setUserListWidth = this.setUserListWidth.bind(this); 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) { componentDidUpdate(prevProps) {
const { const {
userlistWidth, userlistWidth,

View File

@ -5,6 +5,7 @@ import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrap
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import cx from 'classnames'; import cx from 'classnames';
import { styles } from './styles.scss'; import { styles } from './styles.scss';
import AudioService from '/imports/ui/components/audio/service';
const MAX_INPUT_CHARS = 45; const MAX_INPUT_CHARS = 45;
@ -55,8 +56,9 @@ class Polling extends Component {
} }
play() { play() {
this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/Poll.mp3`); AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
this.alert.play(); + Meteor.settings.public.app.basename}`
+ '/resources/sounds/Poll.mp3');
} }
handleUpdateResponseInput(e) { handleUpdateResponseInput(e) {
@ -78,7 +80,10 @@ class Polling extends Component {
typedAns, typedAns,
} = this.state; } = this.state;
if (!poll) return null;
const { stackOptions, answers, question } = poll; const { stackOptions, answers, question } = poll;
const pollAnswerStyles = { const pollAnswerStyles = {
[styles.pollingAnswers]: true, [styles.pollingAnswers]: true,
[styles.removeColumns]: answers.length === 1, [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 WhiteboardOverlayContainer from '/imports/ui/components/whiteboard/whiteboard-overlay/container';
import WhiteboardToolbarContainer from '/imports/ui/components/whiteboard/whiteboard-toolbar/container'; import WhiteboardToolbarContainer from '/imports/ui/components/whiteboard/whiteboard-toolbar/container';
import { HUNDRED_PERCENT, MAX_PERCENT } from '/imports/utils/slideCalcUtils'; 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 { toast } from 'react-toastify';
import PresentationToolbarContainer from './presentation-toolbar/container'; import PresentationToolbarContainer from './presentation-toolbar/container';
import CursorWrapperContainer from './cursor/cursor-wrapper-container/container'; import CursorWrapperContainer from './cursor/cursor-wrapper-container/container';
@ -854,7 +854,7 @@ class PresentationArea extends PureComponent {
export default injectIntl(withDraggableConsumer(withLayoutConsumer(PresentationArea))); export default injectIntl(withDraggableConsumer(withLayoutConsumer(PresentationArea)));
PresentationArea.propTypes = { PresentationArea.propTypes = {
intl: intlShape.isRequired, intl: PropTypes.object.isRequired,
podId: PropTypes.string.isRequired, podId: PropTypes.string.isRequired,
// Defines a boolean value to detect whether a current user is a presenter // Defines a boolean value to detect whether a current user is a presenter
userIsPresenter: PropTypes.bool.isRequired, userIsPresenter: PropTypes.bool.isRequired,

View File

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

View File

@ -6,33 +6,15 @@ import Cursor from './component';
class CursorContainer extends 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() { render() {
const { cursorX, cursorY } = this.props; const { cursorX, cursorY } = this.props;
const { labelBoxWidth, labelBoxHeight } = this.state;
if (cursorX > 0 && cursorY > 0) { if (cursorX > 0 && cursorY > 0) {
return ( return (
<Cursor <Cursor
cursorX={cursorX} cursorX={cursorX}
cursorY={cursorY} cursorY={cursorY}
labelBoxWidth={labelBoxWidth}
labelBoxHeight={labelBoxHeight}
setLabelBoxDimensions={this.setLabelBoxDimensions} setLabelBoxDimensions={this.setLabelBoxDimensions}
{...this.props} {...this.props}
/> />

View File

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

View File

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

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