/* global __filename, $, Promise */ import EventEmitter from 'events'; import { getLogger } from 'jitsi-meet-logger'; import isEqual from 'lodash.isequal'; import { Strophe } from 'strophe.js'; import * as JitsiConferenceErrors from './JitsiConferenceErrors'; import JitsiConferenceEventManager from './JitsiConferenceEventManager'; import * as JitsiConferenceEvents from './JitsiConferenceEvents'; import JitsiParticipant from './JitsiParticipant'; import JitsiTrackError from './JitsiTrackError'; import * as JitsiTrackErrors from './JitsiTrackErrors'; import * as JitsiTrackEvents from './JitsiTrackEvents'; import authenticateAndUpgradeRole from './authenticateAndUpgradeRole'; import { CodecSelection } from './modules/RTC/CodecSelection'; import RTC from './modules/RTC/RTC'; import browser from './modules/browser'; import ConnectionQuality from './modules/connectivity/ConnectionQuality'; import IceFailedHandling from './modules/connectivity/IceFailedHandling'; import ParticipantConnectionStatusHandler from './modules/connectivity/ParticipantConnectionStatus'; import * as DetectionEvents from './modules/detection/DetectionEvents'; import NoAudioSignalDetection from './modules/detection/NoAudioSignalDetection'; import P2PDominantSpeakerDetection from './modules/detection/P2PDominantSpeakerDetection'; import VADAudioAnalyser from './modules/detection/VADAudioAnalyser'; import VADNoiseDetection from './modules/detection/VADNoiseDetection'; import VADTalkMutedDetection from './modules/detection/VADTalkMutedDetection'; import { E2EEncryption } from './modules/e2ee/E2EEncryption'; import E2ePing from './modules/e2eping/e2eping'; import Jvb121EventGenerator from './modules/event/Jvb121EventGenerator'; import { ReceiveVideoController } from './modules/qualitycontrol/ReceiveVideoController'; import { SendVideoController } from './modules/qualitycontrol/SendVideoController'; import RecordingManager from './modules/recording/RecordingManager'; import Settings from './modules/settings/Settings'; import AudioOutputProblemDetector from './modules/statistics/AudioOutputProblemDetector'; import AvgRTPStatsReporter from './modules/statistics/AvgRTPStatsReporter'; import SpeakerStatsCollector from './modules/statistics/SpeakerStatsCollector'; import Statistics from './modules/statistics/statistics'; import Transcriber from './modules/transcription/transcriber'; import GlobalOnErrorHandler from './modules/util/GlobalOnErrorHandler'; import RandomUtil from './modules/util/RandomUtil'; import ComponentsVersions from './modules/version/ComponentsVersions'; import VideoSIPGW from './modules/videosipgw/VideoSIPGW'; import * as VideoSIPGWConstants from './modules/videosipgw/VideoSIPGWConstants'; import { FEATURE_E2EE, FEATURE_JIGASI, JITSI_MEET_MUC_TYPE } from './modules/xmpp/xmpp'; import CodecMimeType from './service/RTC/CodecMimeType'; import * as MediaType from './service/RTC/MediaType'; import VideoType from './service/RTC/VideoType'; import { ACTION_JINGLE_RESTART, ACTION_JINGLE_SI_RECEIVED, ACTION_JINGLE_SI_TIMEOUT, ACTION_JINGLE_TERMINATE, ACTION_P2P_DECLINED, ACTION_P2P_ESTABLISHED, ACTION_P2P_FAILED, ACTION_P2P_SWITCH_TO_JVB, ICE_ESTABLISHMENT_DURATION_DIFF, createConferenceEvent, createJingleEvent, createP2PEvent } from './service/statistics/AnalyticsEvents'; import * as XMPPEvents from './service/xmpp/XMPPEvents'; const logger = getLogger(__filename); /** * How long since Jicofo is supposed to send a session-initiate, before * {@link ACTION_JINGLE_SI_TIMEOUT} analytics event is sent (in ms). * @type {number} */ const JINGLE_SI_TIMEOUT = 5000; /** * Creates a JitsiConference object with the given name and properties. * Note: this constructor is not a part of the public API (objects should be * created using JitsiConnection.createConference). * @param options.config properties / settings related to the conference that * will be created. * @param options.name the name of the conference * @param options.connection the JitsiConnection object for this * JitsiConference. * @param {number} [options.config.avgRtpStatsN=15] how many samples are to be * collected by {@link AvgRTPStatsReporter}, before arithmetic mean is * calculated and submitted to the analytics module. * @param {boolean} [options.config.enableIceRestart=false] - enables the ICE * restart logic. * @param {boolean} [options.config.p2p.enabled] when set to true * the peer to peer mode will be enabled. It means that when there are only 2 * participants in the conference an attempt to make direct connection will be * made. If the connection succeeds the conference will stop sending data * through the JVB connection and will use the direct one instead. * @param {number} [options.config.p2p.backToP2PDelay=5] a delay given in * seconds, before the conference switches back to P2P, after the 3rd * participant has left the room. * @param {number} [options.config.channelLastN=-1] The requested amount of * videos are going to be delivered after the value is in effect. Set to -1 for * unlimited or all available videos. * @param {number} [options.config.forceJVB121Ratio] * "Math.random() < forceJVB121Ratio" will determine whether a 2 people * conference should be moved to the JVB instead of P2P. The decision is made on * the responder side, after ICE succeeds on the P2P connection. * @constructor * * FIXME Make all methods which are called from lib-internal classes * to non-public (use _). To name a few: * {@link JitsiConference.onLocalRoleChanged} * {@link JitsiConference.onUserRoleChanged} * {@link JitsiConference.onMemberLeft} * and so on... */ export default function JitsiConference(options) { if (!options.name || options.name.toLowerCase() !== options.name) { const errmsg = 'Invalid conference name (no conference name passed or it ' + 'contains invalid characters like capital letters)!'; logger.error(errmsg); throw new Error(errmsg); } this.eventEmitter = new EventEmitter(); this.options = options; this.eventManager = new JitsiConferenceEventManager(this); this.participants = {}; this._init(options); this.componentsVersions = new ComponentsVersions(this); /** * Jingle session instance for the JVB connection. * @type {JingleSessionPC} */ this.jvbJingleSession = null; this.lastDominantSpeaker = null; this.dtmfManager = null; this.somebodySupportsDTMF = false; this.authEnabled = false; this.startAudioMuted = false; this.startVideoMuted = false; this.startMutedPolicy = { audio: false, video: false }; this.isMutedByFocus = false; // when muted by focus we receive the jid of the initiator of the mute this.mutedByFocusActor = null; this.isVideoMutedByFocus = false; // when video muted by focus we receive the jid of the initiator of the mute this.mutedVideoByFocusActor = null; // Flag indicates if the 'onCallEnded' method was ever called on this // instance. Used to log extra analytics event for debugging purpose. // We need to know if the potential issue happened before or after // the restart. this.wasStopped = false; // Conference properties, maintained by jicofo. this.properties = {}; /** * The object which monitors local and remote connection statistics (e.g. * sending bitrate) and calculates a number which represents the connection * quality. */ this.connectionQuality = new ConnectionQuality(this, this.eventEmitter, options); /** * Reports average RTP statistics to the analytics module. * @type {AvgRTPStatsReporter} */ this.avgRtpStatsReporter = new AvgRTPStatsReporter(this, options.config.avgRtpStatsN || 15); /** * Detects issues with the audio of remote participants. * @type {AudioOutputProblemDetector} */ this._audioOutputProblemDetector = new AudioOutputProblemDetector(this); /** * Indicates whether the connection is interrupted or not. */ this.isJvbConnectionInterrupted = false; /** * The object which tracks active speaker times */ this.speakerStatsCollector = new SpeakerStatsCollector(this); /* P2P related fields below: */ /** * Stores reference to deferred start P2P task. It's created when 3rd * participant leaves the room in order to avoid ping pong effect (it * could be just a page reload). * @type {number|null} */ this.deferredStartP2PTask = null; const delay = parseInt(options.config.p2p && options.config.p2p.backToP2PDelay, 10); /** * A delay given in seconds, before the conference switches back to P2P * after the 3rd participant has left. * @type {number} */ this.backToP2PDelay = isNaN(delay) ? 5 : delay; logger.info(`backToP2PDelay: ${this.backToP2PDelay}`); /** * If set to true it means the P2P ICE is no longer connected. * When false it means that P2P ICE (media) connection is up * and running. * @type {boolean} */ this.isP2PConnectionInterrupted = false; /** * Flag set to true when P2P session has been established * (ICE has been connected) and this conference is currently in the peer to * peer mode (P2P connection is the active one). * @type {boolean} */ this.p2p = false; /** * A JingleSession for the direct peer to peer connection. * @type {JingleSessionPC} */ this.p2pJingleSession = null; this.videoSIPGWHandler = new VideoSIPGW(this.room); this.recordingManager = new RecordingManager(this.room); /** * If the conference.joined event has been sent this will store the timestamp when it happened. * * @type {undefined|number} * @private */ this._conferenceJoinAnalyticsEventSent = undefined; /** * End-to-End Encryption. Make it available if supported. */ if (this.isE2EESupported()) { logger.info('End-to-End Encryprtion is supported'); this._e2eEncryption = new E2EEncryption(this); } } // FIXME convert JitsiConference to ES6 - ASAP ! JitsiConference.prototype.constructor = JitsiConference; /** * Create a resource for the a jid. We use the room nickname (the resource part * of the occupant JID, see XEP-0045) as the endpoint ID in colibri. We require * endpoint IDs to be 8 hex digits because in some cases they get serialized * into a 32bit field. * * @param {string} jid - The id set onto the XMPP connection. * @param {boolean} isAuthenticatedUser - Whether or not the user has connected * to the XMPP service with a password. * @returns {string} * @static */ JitsiConference.resourceCreator = function(jid, isAuthenticatedUser) { let mucNickname; if (isAuthenticatedUser) { // For authenticated users generate a random ID. mucNickname = RandomUtil.randomHexString(8).toLowerCase(); } else { // We try to use the first part of the node (which for anonymous users // on prosody is a UUID) to match the previous behavior (and maybe make // debugging easier). mucNickname = Strophe.getNodeFromJid(jid).substr(0, 8) .toLowerCase(); // But if this doesn't have the required format we just generate a new // random nickname. const re = /[0-9a-f]{8}/g; if (!re.test(mucNickname)) { mucNickname = RandomUtil.randomHexString(8).toLowerCase(); } } return mucNickname; }; /** * Initializes the conference object properties * @param options {object} * @param options.connection {JitsiConnection} overrides this.connection */ JitsiConference.prototype._init = function(options = {}) { // Override connection and xmpp properties (Useful if the connection // reloaded) if (options.connection) { this.connection = options.connection; this.xmpp = this.connection.xmpp; // Setup XMPP events only if we have new connection object. this.eventManager.setupXMPPListeners(); } const { config } = this.options; // Get the codec preference settings from config.js. // 'preferH264' and 'disableH264' settings have been deprecated for a while, // 'preferredCodec' and 'disabledCodec' will have precedence over them. const codecSettings = { disabledCodec: config.videoQuality ? config.videoQuality.disabledCodec : config.p2p && config.p2p.disableH264 && CodecMimeType.H264, enforcePreferredCodec: config.videoQuality && config.videoQuality.enforcePreferredCodec, jvbCodec: (config.videoQuality && config.videoQuality.preferredCodec) || (config.preferH264 && CodecMimeType.H264), p2pCodec: config.p2p ? config.p2p.preferredCodec || (config.p2p.preferH264 && CodecMimeType.H264) : CodecMimeType.VP8 }; this.codecSelection = new CodecSelection(this, codecSettings); this._statsCurrentId = config.statisticsId ? config.statisticsId : Settings.callStatsUserName; this.room = this.xmpp.createRoom( this.options.name, { ...config, statsId: this._statsCurrentId }, JitsiConference.resourceCreator ); // Connection interrupted/restored listeners this._onIceConnectionInterrupted = this._onIceConnectionInterrupted.bind(this); this.room.addListener( XMPPEvents.CONNECTION_INTERRUPTED, this._onIceConnectionInterrupted); this._onIceConnectionRestored = this._onIceConnectionRestored.bind(this); this.room.addListener( XMPPEvents.CONNECTION_RESTORED, this._onIceConnectionRestored); this._onIceConnectionEstablished = this._onIceConnectionEstablished.bind(this); this.room.addListener( XMPPEvents.CONNECTION_ESTABLISHED, this._onIceConnectionEstablished); this._updateProperties = this._updateProperties.bind(this); this.room.addListener(XMPPEvents.CONFERENCE_PROPERTIES_CHANGED, this._updateProperties); this._sendConferenceJoinAnalyticsEvent = this._sendConferenceJoinAnalyticsEvent.bind(this); this.room.addListener(XMPPEvents.MEETING_ID_SET, this._sendConferenceJoinAnalyticsEvent); this.e2eping = new E2ePing( this, config, (message, to) => { try { this.sendMessage( message, to, true /* sendThroughVideobridge */); } catch (error) { logger.warn('Failed to send E2E ping request or response.', error && error.msg); } }); if (!this.rtc) { this.rtc = new RTC(this, options); this.eventManager.setupRTCListeners(); } this.receiveVideoController = new ReceiveVideoController(this, this.rtc); this.sendVideoController = new SendVideoController(this, this.rtc); this.participantConnectionStatus = new ParticipantConnectionStatusHandler( this.rtc, this, { // Both these options are not public API, leaving it here only // as an entry point through config for tuning up purposes. // Default values should be adjusted as soon as optimal values // are discovered. rtcMuteTimeout: config._peerConnStatusRtcMuteTimeout, outOfLastNTimeout: config._peerConnStatusOutOfLastNTimeout }); this.participantConnectionStatus.init(); // Add the ability to enable callStats only on a percentage of users based on config.js settings. let enableCallStats = true; if (config.testing && config.testing.callStatsThreshold) { enableCallStats = (Math.random() * 100) <= config.testing.callStatsThreshold; } if (!this.statistics) { this.statistics = new Statistics(this.xmpp, { aliasName: this._statsCurrentId, userName: config.statisticsDisplayName ? config.statisticsDisplayName : this.myUserId(), confID: config.confID || `${this.connection.options.hosts.domain}/${this.options.name}`, siteID: config.siteID, customScriptUrl: config.callStatsCustomScriptUrl, callStatsID: config.callStatsID, callStatsSecret: config.callStatsSecret, callStatsApplicationLogsDisabled: config.callStatsApplicationLogsDisabled, enableCallStats, roomName: this.options.name, applicationName: config.applicationName, getWiFiStatsMethod: config.getWiFiStatsMethod }); Statistics.analytics.addPermanentProperties({ 'callstats_name': this._statsCurrentId }); // Start performance observer for monitoring long tasks if (config.longTasksStatsInterval) { this.statistics.attachLongTasksStats(this); } } this.eventManager.setupChatRoomListeners(); // Always add listeners because on reload we are executing leave and the // listeners are removed from statistics module. this.eventManager.setupStatisticsListeners(); // Disable VAD processing on Safari since it causes audio input to // fail on some of the mobile devices. if (config.enableTalkWhileMuted && browser.supportsVADDetection()) { // If VAD processor factory method is provided uses VAD based detection, otherwise fallback to audio level // based detection. if (config.createVADProcessor) { logger.info('Using VAD detection for generating talk while muted events'); if (!this._audioAnalyser) { this._audioAnalyser = new VADAudioAnalyser(this, config.createVADProcessor); } const vadTalkMutedDetection = new VADTalkMutedDetection(); vadTalkMutedDetection.on(DetectionEvents.VAD_TALK_WHILE_MUTED, () => this.eventEmitter.emit(JitsiConferenceEvents.TALK_WHILE_MUTED)); this._audioAnalyser.addVADDetectionService(vadTalkMutedDetection); } else { logger.warn('No VAD Processor was provided. Talk while muted detection service was not initialized!'); } } // Disable noisy mic detection on safari since it causes the audio input to // fail on Safari on iPadOS. if (config.enableNoisyMicDetection && browser.supportsVADDetection()) { if (config.createVADProcessor) { if (!this._audioAnalyser) { this._audioAnalyser = new VADAudioAnalyser(this, config.createVADProcessor); } const vadNoiseDetection = new VADNoiseDetection(); vadNoiseDetection.on(DetectionEvents.VAD_NOISY_DEVICE, () => this.eventEmitter.emit(JitsiConferenceEvents.NOISY_MIC)); this._audioAnalyser.addVADDetectionService(vadNoiseDetection); } else { logger.warn('No VAD Processor was provided. Noisy microphone detection service was not initialized!'); } } // Generates events based on no audio input detector. if (config.enableNoAudioDetection) { this._noAudioSignalDetection = new NoAudioSignalDetection(this); this._noAudioSignalDetection.on(DetectionEvents.NO_AUDIO_INPUT, () => { this.eventEmitter.emit(JitsiConferenceEvents.NO_AUDIO_INPUT); }); this._noAudioSignalDetection.on(DetectionEvents.AUDIO_INPUT_STATE_CHANGE, hasAudioSignal => { this.eventEmitter.emit(JitsiConferenceEvents.AUDIO_INPUT_STATE_CHANGE, hasAudioSignal); }); } if ('channelLastN' in config) { this.setLastN(config.channelLastN); } /** * Emits {@link JitsiConferenceEvents.JVB121_STATUS}. * @type {Jvb121EventGenerator} */ this.jvb121Status = new Jvb121EventGenerator(this); // creates dominant speaker detection that works only in p2p mode this.p2pDominantSpeakerDetection = new P2PDominantSpeakerDetection(this); if (config && config.deploymentInfo && config.deploymentInfo.userRegion) { this.setLocalParticipantProperty( 'region', config.deploymentInfo.userRegion); } // Publish the codec type to presence. this.setLocalParticipantProperty('codecType', this.codecSelection.getPreferredCodec()); }; /** * Joins the conference. * @param password {string} the password * @param replaceParticipant {boolean} whether the current join replaces * an existing participant with same jwt from the meeting. */ JitsiConference.prototype.join = function(password, replaceParticipant = false) { if (this.room) { this.room.join(password, replaceParticipant).then(() => this._maybeSetSITimeout()); } }; /** * Authenticates and upgrades the role of the local participant/user. * * @returns {Object} A thenable which (1) settles when the process of * authenticating and upgrading the role of the local participant/user finishes * and (2) has a cancel method that allows the caller to interrupt the * process. */ JitsiConference.prototype.authenticateAndUpgradeRole = function(options) { return authenticateAndUpgradeRole.call(this, { ...options, onCreateResource: JitsiConference.resourceCreator }); }; /** * Check if joined to the conference. */ JitsiConference.prototype.isJoined = function() { return this.room && this.room.joined; }; /** * Tells whether or not the P2P mode is enabled in the configuration. * @return {boolean} */ JitsiConference.prototype.isP2PEnabled = function() { return Boolean(this.options.config.p2p && this.options.config.p2p.enabled) // FIXME: remove once we have a default config template. -saghul || typeof this.options.config.p2p === 'undefined'; }; /** * When in P2P test mode, the conference will not automatically switch to P2P * when there 2 participants. * @return {boolean} */ JitsiConference.prototype.isP2PTestModeEnabled = function() { return Boolean(this.options.config.testing && this.options.config.testing.p2pTestMode); }; /** * Leaves the conference. * @returns {Promise} */ JitsiConference.prototype.leave = function() { if (this.participantConnectionStatus) { this.participantConnectionStatus.dispose(); this.participantConnectionStatus = null; } if (this.avgRtpStatsReporter) { this.avgRtpStatsReporter.dispose(); this.avgRtpStatsReporter = null; } if (this._audioOutputProblemDetector) { this._audioOutputProblemDetector.dispose(); this._audioOutputProblemDetector = null; } if (this.e2eping) { this.e2eping.stop(); this.e2eping = null; } this.getLocalTracks().forEach(track => this.onLocalTrackRemoved(track)); this.rtc.closeBridgeChannel(); this._sendConferenceLeftAnalyticsEvent(); if (this.statistics) { this.statistics.dispose(); } this._delayedIceFailed && this._delayedIceFailed.cancel(); // Close both JVb and P2P JingleSessions if (this.jvbJingleSession) { this.jvbJingleSession.close(); this.jvbJingleSession = null; } if (this.p2pJingleSession) { this.p2pJingleSession.close(); this.p2pJingleSession = null; } // leave the conference if (this.room) { const room = this.room; // Unregister connection state listeners room.removeListener( XMPPEvents.CONNECTION_INTERRUPTED, this._onIceConnectionInterrupted); room.removeListener( XMPPEvents.CONNECTION_RESTORED, this._onIceConnectionRestored); room.removeListener( XMPPEvents.CONNECTION_ESTABLISHED, this._onIceConnectionEstablished); room.removeListener( XMPPEvents.CONFERENCE_PROPERTIES_CHANGED, this._updateProperties); room.removeListener(XMPPEvents.MEETING_ID_SET, this._sendConferenceJoinAnalyticsEvent); this.eventManager.removeXMPPListeners(); this.room = null; return room.leave() .then(() => { if (this.rtc) { this.rtc.destroy(); } }) .catch(error => { // remove all participants because currently the conference // won't be usable anyway. This is done on success automatically // by the ChatRoom instance. this.getParticipants().forEach( participant => this.onMemberLeft(participant.getJid())); throw error; }); } // If this.room == null we are calling second time leave(). return Promise.reject( new Error('The conference is has been already left')); }; /** * Returns the currently active media session if any. * * @returns {JingleSessionPC|undefined} * @private */ JitsiConference.prototype._getActiveMediaSession = function() { return this.isP2PActive() ? this.p2pJingleSession : this.jvbJingleSession; }; /** * Returns an array containing all media sessions existing in this conference. * * @returns {Array} * @private */ JitsiConference.prototype._getMediaSessions = function() { const sessions = []; this.jvbJingleSession && sessions.push(this.jvbJingleSession); this.p2pJingleSession && sessions.push(this.p2pJingleSession); return sessions; }; /** * Returns name of this conference. */ JitsiConference.prototype.getName = function() { return this.options.name; }; /** * Returns the {@link JitsiConnection} used by this this conference. */ JitsiConference.prototype.getConnection = function() { return this.connection; }; /** * Check if authentication is enabled for this conference. */ JitsiConference.prototype.isAuthEnabled = function() { return this.authEnabled; }; /** * Check if user is logged in. */ JitsiConference.prototype.isLoggedIn = function() { return Boolean(this.authIdentity); }; /** * Get authorized login. */ JitsiConference.prototype.getAuthLogin = function() { return this.authIdentity; }; /** * Check if external authentication is enabled for this conference. */ JitsiConference.prototype.isExternalAuthEnabled = function() { return this.room && this.room.moderator.isExternalAuthEnabled(); }; /** * Get url for external authentication. * @param {boolean} [urlForPopup] if true then return url for login popup, * else url of login page. * @returns {Promise} */ JitsiConference.prototype.getExternalAuthUrl = function(urlForPopup) { return new Promise((resolve, reject) => { if (!this.isExternalAuthEnabled()) { reject(); return; } if (urlForPopup) { this.room.moderator.getPopupLoginUrl(resolve, reject); } else { this.room.moderator.getLoginUrl(resolve, reject); } }); }; /** * Returns the local tracks of the given media type, or all local tracks if no * specific type is given. * @param {MediaType} [mediaType] Optional media type (audio or video). */ JitsiConference.prototype.getLocalTracks = function(mediaType) { let tracks = []; if (this.rtc) { tracks = this.rtc.getLocalTracks(mediaType); } return tracks; }; /** * Obtains local audio track. * @return {JitsiLocalTrack|null} */ JitsiConference.prototype.getLocalAudioTrack = function() { return this.rtc ? this.rtc.getLocalAudioTrack() : null; }; /** * Obtains local video track. * @return {JitsiLocalTrack|null} */ JitsiConference.prototype.getLocalVideoTrack = function() { return this.rtc ? this.rtc.getLocalVideoTrack() : null; }; /** * Obtains the performance statistics. * @returns {Object|null} */ JitsiConference.prototype.getPerformanceStats = function() { return { longTasksStats: this.statistics.getLongTasksStats() }; }; /** * Attaches a handler for events(For example - "participant joined".) in the * conference. All possible event are defined in JitsiConferenceEvents. * @param eventId the event ID. * @param handler handler for the event. * * Note: consider adding eventing functionality by extending an EventEmitter * impl, instead of rolling ourselves */ JitsiConference.prototype.on = function(eventId, handler) { if (this.eventEmitter) { this.eventEmitter.on(eventId, handler); } }; /** * Removes event listener * @param eventId the event ID. * @param [handler] optional, the specific handler to unbind * * Note: consider adding eventing functionality by extending an EventEmitter * impl, instead of rolling ourselves */ JitsiConference.prototype.off = function(eventId, handler) { if (this.eventEmitter) { this.eventEmitter.removeListener(eventId, handler); } }; // Common aliases for event emitter JitsiConference.prototype.addEventListener = JitsiConference.prototype.on; JitsiConference.prototype.removeEventListener = JitsiConference.prototype.off; /** * Receives notifications from other participants about commands / custom events * (sent by sendCommand or sendCommandOnce methods). * @param command {String} the name of the command * @param handler {Function} handler for the command */ JitsiConference.prototype.addCommandListener = function(command, handler) { if (this.room) { this.room.addPresenceListener(command, handler); } }; /** * Removes command listener * @param command {String} the name of the command * @param handler {Function} handler to remove for the command */ JitsiConference.prototype.removeCommandListener = function(command, handler) { if (this.room) { this.room.removePresenceListener(command, handler); } }; /** * Sends text message to the other participants in the conference * @param message the text message. * @param elementName the element name to encapsulate the message. * @deprecated Use 'sendMessage' instead. TODO: this should be private. */ JitsiConference.prototype.sendTextMessage = function( message, elementName = 'body') { if (this.room) { this.room.sendMessage(message, elementName); } }; /** * Send private text message to another participant of the conference * @param id the id of the participant to send a private message. * @param message the text message. * @param elementName the element name to encapsulate the message. * @deprecated Use 'sendMessage' instead. TODO: this should be private. */ JitsiConference.prototype.sendPrivateTextMessage = function( id, message, elementName = 'body') { if (this.room) { this.room.sendPrivateMessage(id, message, elementName); } }; /** * Send presence command. * @param name {String} the name of the command. * @param values {Object} with keys and values that will be sent. **/ JitsiConference.prototype.sendCommand = function(name, values) { if (this.room) { this.room.addOrReplaceInPresence(name, values) && this.room.sendPresence(); } else { logger.warn('Not sending a command, room not initialized.'); } }; /** * Send presence command one time. * @param name {String} the name of the command. * @param values {Object} with keys and values that will be sent. **/ JitsiConference.prototype.sendCommandOnce = function(name, values) { this.sendCommand(name, values); this.removeCommand(name); }; /** * Removes presence command. * @param name {String} the name of the command. **/ JitsiConference.prototype.removeCommand = function(name) { if (this.room) { this.room.removeFromPresence(name); } }; /** * Sets the display name for this conference. * @param name the display name to set */ JitsiConference.prototype.setDisplayName = function(name) { if (this.room) { this.room.addOrReplaceInPresence('nick', { attributes: { xmlns: 'http://jabber.org/protocol/nick' }, value: name }) && this.room.sendPresence(); } }; /** * Set new subject for this conference. (available only for moderator) * @param {string} subject new subject */ JitsiConference.prototype.setSubject = function(subject) { if (this.room && this.isModerator()) { this.room.setSubject(subject); } else { logger.warn(`Failed to set subject, ${this.room ? '' : 'not in a room, '}${ this.isModerator() ? '' : 'participant is not a moderator'}`); } }; /** * Get a transcriber object for all current participants in this conference * @return {Transcriber} the transcriber object */ JitsiConference.prototype.getTranscriber = function() { if (this.transcriber === undefined) { this.transcriber = new Transcriber(); // add all existing local audio tracks to the transcriber const localAudioTracks = this.getLocalTracks(MediaType.AUDIO); for (const localAudio of localAudioTracks) { this.transcriber.addTrack(localAudio); } // and all remote audio tracks const remoteAudioTracks = this.rtc.getRemoteTracks(MediaType.AUDIO); for (const remoteTrack of remoteAudioTracks) { this.transcriber.addTrack(remoteTrack); } } return this.transcriber; }; /** * Returns the transcription status. * * @returns {String} "on" or "off". */ JitsiConference.prototype.getTranscriptionStatus = function() { return this.room.transcriptionStatus; }; /** * Adds JitsiLocalTrack object to the conference. * @param {JitsiLocalTrack} track the JitsiLocalTrack object. * @returns {Promise} * @throws {Error} if the specified track is a video track and there is already * another video track in the conference. */ JitsiConference.prototype.addTrack = function(track) { const mediaType = track.getType(); const localTracks = this.rtc.getLocalTracks(mediaType); // Ensure there's exactly 1 local track of each media type in the conference. if (localTracks.length > 0) { // Don't be excessively harsh and severe if the API client happens to attempt to add the same local track twice. if (track === localTracks[0]) { return Promise.resolve(track); } return Promise.reject(new Error(`Cannot add second ${mediaType} track to the conference`)); } return this.replaceTrack(null, track); }; /** * Fires TRACK_AUDIO_LEVEL_CHANGED change conference event (for local tracks). * @param {number} audioLevel the audio level * @param {TraceablePeerConnection} [tpc] */ JitsiConference.prototype._fireAudioLevelChangeEvent = function( audioLevel, tpc) { const activeTpc = this.getActivePeerConnection(); // There will be no TraceablePeerConnection if audio levels do not come from // a peerconnection. LocalStatsCollector.js measures audio levels using Web // Audio Analyser API and emits local audio levels events through // JitsiTrack.setAudioLevel, but does not provide TPC instance which is // optional. if (!tpc || activeTpc === tpc) { this.eventEmitter.emit( JitsiConferenceEvents.TRACK_AUDIO_LEVEL_CHANGED, this.myUserId(), audioLevel); } }; /** * Fires TRACK_MUTE_CHANGED change conference event. * @param track the JitsiTrack object related to the event. */ JitsiConference.prototype._fireMuteChangeEvent = function(track) { // check if track was muted by focus and now is unmuted by user if (this.isMutedByFocus && track.isAudioTrack() && !track.isMuted()) { this.isMutedByFocus = false; // unmute local user on server this.room.muteParticipant(this.room.myroomjid, false, MediaType.AUDIO); } else if (this.isVideoMutedByFocus && track.isVideoTrack() && !track.isMuted()) { this.isVideoMutedByFocus = false; // unmute local user on server this.room.muteParticipant(this.room.myroomjid, false, MediaType.VIDEO); } let actorParticipant; if (this.mutedByFocusActor && track.isAudioTrack()) { const actorId = Strophe.getResourceFromJid(this.mutedByFocusActor); actorParticipant = this.participants[actorId]; } else if (this.mutedVideoByFocusActor && track.isVideoTrack()) { const actorId = Strophe.getResourceFromJid(this.mutedVideoByFocusActor); actorParticipant = this.participants[actorId]; } this.eventEmitter.emit(JitsiConferenceEvents.TRACK_MUTE_CHANGED, track, actorParticipant); }; /** * Returns the list of local tracks that need to be added to the peerconnection on join. * This takes the startAudioMuted/startVideoMuted flags into consideration since we do not * want to add the tracks if the user joins the call audio/video muted. The tracks will be * added when the user unmutes for the first time. * @returns {Array} - list of local tracks that are unmuted. */ JitsiConference.prototype._getInitialLocalTracks = function() { return this.getLocalTracks() .filter(track => (track.getType() === MediaType.AUDIO && !this.isStartAudioMuted()) || (track.getType() === MediaType.VIDEO && !this.isStartVideoMuted())); }; /** * Clear JitsiLocalTrack properties and listeners. * @param track the JitsiLocalTrack object. */ JitsiConference.prototype.onLocalTrackRemoved = function(track) { track._setConference(null); this.rtc.removeLocalTrack(track); track.removeEventListener(JitsiTrackEvents.TRACK_MUTE_CHANGED, track.muteHandler); track.removeEventListener(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, track.audioLevelHandler); // send event for stopping screen sharing // FIXME: we assume we have only one screen sharing track // if we change this we need to fix this check if (track.isVideoTrack() && track.videoType === VideoType.DESKTOP) { this.statistics.sendScreenSharingEvent(false); } this.eventEmitter.emit(JitsiConferenceEvents.TRACK_REMOVED, track); }; /** * Removes JitsiLocalTrack from the conference and performs * a new offer/answer cycle. * @param {JitsiLocalTrack} track * @returns {Promise} */ JitsiConference.prototype.removeTrack = function(track) { return this.replaceTrack(track, null); }; /** * Replaces oldTrack with newTrack and performs a single offer/answer * cycle after both operations are done. Either oldTrack or newTrack * can be null; replacing a valid 'oldTrack' with a null 'newTrack' * effectively just removes 'oldTrack' * @param {JitsiLocalTrack} oldTrack the current stream in use to be replaced * @param {JitsiLocalTrack} newTrack the new stream to use * @returns {Promise} resolves when the replacement is finished */ JitsiConference.prototype.replaceTrack = function(oldTrack, newTrack) { // First do the removal of the oldTrack at the JitsiConference level if (oldTrack) { if (oldTrack.disposed) { return Promise.reject( new JitsiTrackError(JitsiTrackErrors.TRACK_IS_DISPOSED)); } } if (newTrack) { if (newTrack.disposed) { return Promise.reject( new JitsiTrackError(JitsiTrackErrors.TRACK_IS_DISPOSED)); } } // Now replace the stream at the lower levels return this._doReplaceTrack(oldTrack, newTrack) .then(() => { if (oldTrack) { this.onLocalTrackRemoved(oldTrack); } // Send 'VideoTypeMessage' on the bridge channel for the new track. if (newTrack) { // Now handle the addition of the newTrack at the JitsiConference level this._setupNewTrack(newTrack); newTrack.isVideoTrack() && this.rtc.setVideoType(newTrack.videoType); } else { oldTrack && oldTrack.isVideoTrack() && this.rtc.setVideoType(VideoType.NONE); } if (this.isMutedByFocus || this.isVideoMutedByFocus) { this._fireMuteChangeEvent(newTrack); } return Promise.resolve(); }) .catch(error => Promise.reject(new Error(error))); }; /** * Replaces the tracks at the lower level by going through the Jingle session * and WebRTC peer connection. The method will resolve immediately if there is * currently no JingleSession started. * @param {JitsiLocalTrack|null} oldTrack the track to be removed during * the process or null if the method should act as "add track" * @param {JitsiLocalTrack|null} newTrack the new track to be added or * null if the method should act as "remove track" * @return {Promise} resolved when the process is done or rejected with a string * which describes the error. * @private */ JitsiConference.prototype._doReplaceTrack = function(oldTrack, newTrack) { const replaceTrackPromises = []; if (this.jvbJingleSession) { replaceTrackPromises.push( this.jvbJingleSession.replaceTrack(oldTrack, newTrack)); } else { logger.info('_doReplaceTrack - no JVB JingleSession'); } if (this.p2pJingleSession) { replaceTrackPromises.push( this.p2pJingleSession.replaceTrack(oldTrack, newTrack)); } else { logger.info('_doReplaceTrack - no P2P JingleSession'); } return Promise.all(replaceTrackPromises); }; /** * Operations related to creating a new track * @param {JitsiLocalTrack} newTrack the new track being created */ JitsiConference.prototype._setupNewTrack = function(newTrack) { if (newTrack.isAudioTrack() || (newTrack.isVideoTrack() && newTrack.videoType !== VideoType.DESKTOP)) { // Report active device to statistics const devices = RTC.getCurrentlyAvailableMediaDevices(); const device = devices.find( d => d.kind === `${newTrack.getTrack().kind}input` && d.label === newTrack.getTrack().label); if (device) { Statistics.sendActiveDeviceListEvent( RTC.getEventDataForActiveDevice(device)); } } if (newTrack.isVideoTrack()) { const videoTypeTagName = 'videoType'; // if video type is camera and there is no videoType in presence, we skip adding it, as this is the default one if (newTrack.videoType !== VideoType.CAMERA || this.room.getFromPresence(videoTypeTagName)) { this.sendCommand(videoTypeTagName, { value: newTrack.videoType }); } } this.rtc.addLocalTrack(newTrack); // ensure that we're sharing proper "is muted" state if (newTrack.isAudioTrack()) { this.room.setAudioMute(newTrack.isMuted()); } else { this.room.setVideoMute(newTrack.isMuted()); } newTrack.muteHandler = this._fireMuteChangeEvent.bind(this, newTrack); newTrack.audioLevelHandler = this._fireAudioLevelChangeEvent.bind(this); newTrack.addEventListener( JitsiTrackEvents.TRACK_MUTE_CHANGED, newTrack.muteHandler); newTrack.addEventListener( JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, newTrack.audioLevelHandler); newTrack._setConference(this); this.eventEmitter.emit(JitsiConferenceEvents.TRACK_ADDED, newTrack); }; /** * Method called by the {@link JitsiLocalTrack} (a video one) in order to add * back the underlying WebRTC MediaStream to the PeerConnection (which has * removed on video mute). * @param {JitsiLocalTrack} track the local track that will be added as part of * the unmute operation. * @return {Promise} resolved when the process is done or rejected with a string * which describes the error. */ JitsiConference.prototype._addLocalTrackAsUnmute = function(track) { const addAsUnmutePromises = []; if (this.jvbJingleSession) { addAsUnmutePromises.push(this.jvbJingleSession.addTrackAsUnmute(track)); } else { logger.debug('Add local MediaStream as unmute - no JVB Jingle session started yet'); } if (this.p2pJingleSession) { addAsUnmutePromises.push(this.p2pJingleSession.addTrackAsUnmute(track)); } else { logger.debug('Add local MediaStream as unmute - no P2P Jingle session started yet'); } return Promise.allSettled(addAsUnmutePromises) .then(() => { // Signal the video type to the bridge. track.isVideoTrack() && this.rtc.setVideoType(track.videoType); }); }; /** * Method called by the {@link JitsiLocalTrack} (a video one) in order to remove * the underlying WebRTC MediaStream from the PeerConnection. The purpose of * that is to stop sending any data and turn off the HW camera device. * @param {JitsiLocalTrack} track the local track that will be removed. * @return {Promise} */ JitsiConference.prototype._removeLocalTrackAsMute = function(track) { const removeAsMutePromises = []; if (this.jvbJingleSession) { removeAsMutePromises.push(this.jvbJingleSession.removeTrackAsMute(track)); } else { logger.debug('Remove local MediaStream - no JVB JingleSession started yet'); } if (this.p2pJingleSession) { removeAsMutePromises.push(this.p2pJingleSession.removeTrackAsMute(track)); } else { logger.debug('Remove local MediaStream - no P2P JingleSession started yet'); } return Promise.allSettled(removeAsMutePromises) .then(() => { // Signal the video type to the bridge. track.isVideoTrack() && this.rtc.setVideoType(VideoType.NONE); }); }; /** * Get role of the local user. * @returns {string} user role: 'moderator' or 'none' */ JitsiConference.prototype.getRole = function() { return this.room.role; }; /** * Returns whether or not the current conference has been joined as a hidden * user. * * @returns {boolean|null} True if hidden, false otherwise. Will return null if * no connection is active. */ JitsiConference.prototype.isHidden = function() { if (!this.connection) { return null; } return Strophe.getDomainFromJid(this.connection.getJid()) === this.options.config.hiddenDomain; }; /** * Check if local user is moderator. * @returns {boolean|null} true if local user is moderator, false otherwise. If * we're no longer in the conference room then null is returned. */ JitsiConference.prototype.isModerator = function() { return this.room ? this.room.isModerator() : null; }; /** * Set password for the room. * @param {string} password new password for the room. * @returns {Promise} */ JitsiConference.prototype.lock = function(password) { if (!this.isModerator()) { return Promise.reject(new Error('You are not moderator.')); } return new Promise((resolve, reject) => { this.room.lockRoom( password || '', () => resolve(), err => reject(err), () => reject(JitsiConferenceErrors.PASSWORD_NOT_SUPPORTED)); }); }; /** * Remove password from the room. * @returns {Promise} */ JitsiConference.prototype.unlock = function() { return this.lock(); }; /** * Elects the participant with the given id to be the selected participant in * order to receive higher video quality (if simulcast is enabled). * Or cache it if channel is not created and send it once channel is available. * @param participantId the identifier of the participant * @throws NetworkError or InvalidStateError or Error if the operation fails. * @returns {void} */ JitsiConference.prototype.selectParticipant = function(participantId) { this.selectParticipants([ participantId ]); }; /* * Elects participants with given ids to be the selected participants in order * to receive higher video quality (if simulcast is enabled). The argument * should be an array of participant id strings or an empty array; an error will * be thrown if a non-array is passed in. The error is thrown as a layer of * protection against passing an invalid argument, as the error will happen in * the bridge and may not be visible in the client. * * @param {Array} participantIds - An array of identifiers for * participants. * @returns {void} */ JitsiConference.prototype.selectParticipants = function(participantIds) { if (!Array.isArray(participantIds)) { throw new Error('Invalid argument; participantIds must be an array.'); } this.receiveVideoController.selectEndpoints(participantIds); }; /** * Obtains the current value for "lastN". See {@link setLastN} for more info. * @returns {number} */ JitsiConference.prototype.getLastN = function() { return this.receiveVideoController.getLastN(); }; /** * Selects a new value for "lastN". The requested amount of videos are going * to be delivered after the value is in effect. Set to -1 for unlimited or * all available videos. * @param lastN the new number of videos the user would like to receive. * @throws Error or RangeError if the given value is not a number or is smaller * than -1. */ JitsiConference.prototype.setLastN = function(lastN) { if (!Number.isInteger(lastN) && !Number.parseInt(lastN, 10)) { throw new Error(`Invalid value for lastN: ${lastN}`); } const n = Number(lastN); if (n < -1) { throw new RangeError('lastN cannot be smaller than -1'); } this.receiveVideoController.setLastN(n); // If the P2P session is not fully established yet, we wait until it gets // established. if (this.p2pJingleSession) { const isVideoActive = n !== 0; this.p2pJingleSession .setMediaTransferActive(true, isVideoActive) .catch(error => { logger.error( `Failed to adjust video transfer status (${isVideoActive})`, error); }); } }; /** * Checks if the participant given by participantId is currently included in * the last N. * @param {string} participantId the identifier of the participant we would * like to check. * @return {boolean} true if the participant with id is in the last N set or * if there's no last N set, false otherwise. * @deprecated this method should never be used to figure out the UI, but * {@link ParticipantConnectionStatus} should be used instead. */ JitsiConference.prototype.isInLastN = function(participantId) { return this.rtc.isInLastN(participantId); }; /** * @return Array an array of all participants in this * conference. */ JitsiConference.prototype.getParticipants = function() { return Object.values(this.participants); }; /** * Returns the number of participants in the conference, including the local * participant. * @param countHidden {boolean} Whether or not to include hidden participants * in the count. Default: false. **/ JitsiConference.prototype.getParticipantCount = function(countHidden = false) { let participants = this.getParticipants(); if (!countHidden) { participants = participants.filter(p => !p.isHidden()); } // Add one for the local participant. return participants.length + 1; }; /** * @returns {JitsiParticipant} the participant in this conference with the * specified id (or undefined if there isn't one). * @param id the id of the participant. */ JitsiConference.prototype.getParticipantById = function(id) { return this.participants[id]; }; /** * Grant owner rights to the participant. * @param {string} id id of the participant to grant owner rights to. */ JitsiConference.prototype.grantOwner = function(id) { const participant = this.getParticipantById(id); if (!participant) { return; } this.room.setAffiliation(participant.getJid(), 'owner'); }; /** * Revoke owner rights to the participant or local Participant as * the user might want to refuse to be a moderator. * @param {string} id id of the participant to revoke owner rights to. */ JitsiConference.prototype.revokeOwner = function(id) { const participant = this.getParticipantById(id); const isMyself = this.myUserId() === id; const role = this.isMembersOnly() ? 'member' : 'none'; if (isMyself) { this.room.setAffiliation(this.room.myroomjid, role); } else if (participant) { this.room.setAffiliation(participant.getJid(), role); } }; /** * Kick participant from this conference. * @param {string} id id of the participant to kick * @param {string} reason reason of the participant to kick */ JitsiConference.prototype.kickParticipant = function(id, reason) { const participant = this.getParticipantById(id); if (!participant) { return; } this.room.kick(participant.getJid(), reason); }; /** * Maybe clears the timeout which emits {@link ACTION_JINGLE_SI_TIMEOUT} * analytics event. * @private */ JitsiConference.prototype._maybeClearSITimeout = function() { if (this._sessionInitiateTimeout && (this.jvbJingleSession || this.getParticipantCount() < 2)) { window.clearTimeout(this._sessionInitiateTimeout); this._sessionInitiateTimeout = null; } }; /** * Sets a timeout which will emit {@link ACTION_JINGLE_SI_TIMEOUT} analytics * event. * @private */ JitsiConference.prototype._maybeSetSITimeout = function() { // Jicofo is supposed to invite if there are at least 2 participants if (!this.jvbJingleSession && this.getParticipantCount() >= 2 && !this._sessionInitiateTimeout) { this._sessionInitiateTimeout = window.setTimeout(() => { this._sessionInitiateTimeout = null; Statistics.sendAnalytics(createJingleEvent( ACTION_JINGLE_SI_TIMEOUT, { p2p: false, value: JINGLE_SI_TIMEOUT })); }, JINGLE_SI_TIMEOUT); } }; /** * Mutes a participant. * @param {string} id The id of the participant to mute. */ JitsiConference.prototype.muteParticipant = function(id, mediaType) { const muteMediaType = mediaType ? mediaType : MediaType.AUDIO; if (muteMediaType !== MediaType.AUDIO && muteMediaType !== MediaType.VIDEO) { logger.error(`Unsupported media type: ${muteMediaType}`); return; } const participant = this.getParticipantById(id); if (!participant) { return; } this.room.muteParticipant(participant.getJid(), true, muteMediaType); }; /* eslint-disable max-params */ /** * Notifies this JitsiConference that a new member has joined its chat room. * * FIXME This should NOT be exposed! * * @param jid the jid of the participant in the MUC * @param nick the display name of the participant * @param role the role of the participant in the MUC * @param isHidden indicates if this is a hidden participant (system * participant for example a recorder). * @param statsID the participant statsID (optional) * @param status the initial status if any * @param identity the member identity, if any * @param botType the member botType, if any * @param fullJid the member full jid, if any * @param features the member botType, if any * @param isReplaceParticipant whether this join replaces a participant with * the same jwt. */ JitsiConference.prototype.onMemberJoined = function( jid, nick, role, isHidden, statsID, status, identity, botType, fullJid, features, isReplaceParticipant) { const id = Strophe.getResourceFromJid(jid); if (id === 'focus' || this.myUserId() === id) { return; } const participant = new JitsiParticipant(jid, this, nick, isHidden, statsID, status, identity); participant.setRole(role); participant.setBotType(botType); participant.setFeatures(features); participant.setIsReplacing(isReplaceParticipant); this.participants[id] = participant; this.eventEmitter.emit( JitsiConferenceEvents.USER_JOINED, id, participant); this._updateFeatures(participant); // maybeStart only if we had finished joining as then we will have information for the number of participants if (this.isJoined()) { this._maybeStartOrStopP2P(); } this._maybeSetSITimeout(); }; /* eslint-enable max-params */ /** * Get notified when we joined the room. * * FIXME This should NOT be exposed! * * @private */ JitsiConference.prototype._onMucJoined = function() { this._maybeStartOrStopP2P(); }; /** * Updates features for a participant. * @param {JitsiParticipant} participant - The participant to query for features. * @returns {void} * @private */ JitsiConference.prototype._updateFeatures = function(participant) { participant.getFeatures() .then(features => { participant._supportsDTMF = features.has('urn:xmpp:jingle:dtmf:0'); this.updateDTMFSupport(); if (features.has(FEATURE_JIGASI)) { participant.setProperty('features_jigasi', true); } if (features.has(FEATURE_E2EE)) { participant.setProperty('features_e2ee', true); } }) .catch(() => false); }; /** * Get notified when member bot type had changed. * @param jid the member jid * @param botType the new botType value * @private */ JitsiConference.prototype._onMemberBotTypeChanged = function(jid, botType) { // find the participant and mark it as non bot, as the real one will join // in a moment const peers = this.getParticipants(); const botParticipant = peers.find(p => p.getJid() === jid); if (botParticipant) { botParticipant.setBotType(botType); const id = Strophe.getResourceFromJid(jid); this.eventEmitter.emit( JitsiConferenceEvents.BOT_TYPE_CHANGED, id, botType); } // if botType changed to undefined, botType was removed, in case of // poltergeist mode this is the moment when the poltergeist had exited and // the real participant had already replaced it. // In this case we can check and try p2p if (!botParticipant.getBotType()) { this._maybeStartOrStopP2P(); } }; JitsiConference.prototype.onMemberLeft = function(jid) { const id = Strophe.getResourceFromJid(jid); if (id === 'focus' || this.myUserId() === id) { return; } const participant = this.participants[id]; delete this.participants[id]; // Remove the ssrcs from the remote description. const mediaSessions = this._getMediaSessions(); const removePromises = []; for (const session of mediaSessions) { removePromises.push(session.removeRemoteStreamsOnLeave(id)); } Promise.allSettled(removePromises) .then(results => { let removedTracks = []; results.map(result => result.value).forEach(value => { if (value) { removedTracks = removedTracks.concat(value); } }); removedTracks.forEach(track => { this.eventEmitter.emit(JitsiConferenceEvents.TRACK_REMOVED, track); }); // There can be no participant in case the member that left is focus. if (participant) { this.eventEmitter.emit(JitsiConferenceEvents.USER_LEFT, id, participant); } this._maybeStartOrStopP2P(true /* triggered by user left event */); this._maybeClearSITimeout(); }); }; /* eslint-disable max-params */ /** * Designates an event indicating that we were kicked from the XMPP MUC. * @param {boolean} isSelfPresence - whether it is for local participant * or another participant. * @param {string} actorId - the id of the participant who was initiator * of the kick. * @param {string?} kickedParticipantId - when it is not a kick for local participant, * this is the id of the participant which was kicked. * @param {string} reason - reason of the participant to kick * @param {boolean?} isReplaceParticipant - whether this is a server initiated kick in order * to replace it with a participant with same jwt. */ JitsiConference.prototype.onMemberKicked = function( isSelfPresence, actorId, kickedParticipantId, reason, isReplaceParticipant) { // This check which be true when we kick someone else. With the introduction of lobby // the ChatRoom KICKED event is now also emitted for ourselves (the kicker) so we want to // avoid emitting an event where `undefined` kicked someone. if (actorId === this.myUserId()) { return; } const actorParticipant = this.participants[actorId]; if (isSelfPresence) { this.eventEmitter.emit( JitsiConferenceEvents.KICKED, actorParticipant, reason, isReplaceParticipant); this.leave(); return; } const kickedParticipant = this.participants[kickedParticipantId]; kickedParticipant.setIsReplaced(isReplaceParticipant); this.eventEmitter.emit( JitsiConferenceEvents.PARTICIPANT_KICKED, actorParticipant, kickedParticipant, reason); }; /** * Method called on local MUC role change. * @param {string} role the name of new user's role as defined by XMPP MUC. */ JitsiConference.prototype.onLocalRoleChanged = function(role) { // Emit role changed for local JID this.eventEmitter.emit( JitsiConferenceEvents.USER_ROLE_CHANGED, this.myUserId(), role); }; JitsiConference.prototype.onUserRoleChanged = function(jid, role) { const id = Strophe.getResourceFromJid(jid); const participant = this.getParticipantById(id); if (!participant) { return; } participant.setRole(role); this.eventEmitter.emit(JitsiConferenceEvents.USER_ROLE_CHANGED, id, role); }; JitsiConference.prototype.onDisplayNameChanged = function(jid, displayName) { const id = Strophe.getResourceFromJid(jid); const participant = this.getParticipantById(id); if (!participant) { return; } if (participant._displayName === displayName) { return; } participant._displayName = displayName; this.eventEmitter.emit( JitsiConferenceEvents.DISPLAY_NAME_CHANGED, id, displayName); }; /** * Notifies this JitsiConference that a JitsiRemoteTrack was added into * the conference. * * @param {JitsiRemoteTrack} track the JitsiRemoteTrack which was added to this * JitsiConference */ JitsiConference.prototype.onRemoteTrackAdded = function(track) { if (track.isP2P && !this.isP2PActive()) { logger.info( 'Trying to add remote P2P track, when not in P2P - IGNORED'); return; } else if (!track.isP2P && this.isP2PActive()) { logger.info( 'Trying to add remote JVB track, when in P2P - IGNORED'); return; } const id = track.getParticipantId(); const participant = this.getParticipantById(id); if (!participant) { logger.error(`No participant found for id: ${id}`); return; } // Add track to JitsiParticipant. participant._tracks.push(track); if (this.transcriber) { this.transcriber.addTrack(track); } const emitter = this.eventEmitter; track.addEventListener( JitsiTrackEvents.TRACK_MUTE_CHANGED, () => emitter.emit(JitsiConferenceEvents.TRACK_MUTE_CHANGED, track)); track.addEventListener( JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, (audioLevel, tpc) => { const activeTPC = this.getActivePeerConnection(); if (activeTPC === tpc) { emitter.emit( JitsiConferenceEvents.TRACK_AUDIO_LEVEL_CHANGED, id, audioLevel); } } ); emitter.emit(JitsiConferenceEvents.TRACK_ADDED, track); }; /** * Callback called by the Jingle plugin when 'session-answer' is received. * @param {JingleSessionPC} session the Jingle session for which an answer was * received. * @param {jQuery} answer a jQuery selector pointing to 'jingle' IQ element */ // eslint-disable-next-line no-unused-vars JitsiConference.prototype.onCallAccepted = function(session, answer) { if (this.p2pJingleSession === session) { logger.info('P2P setAnswer'); this.p2pJingleSession.setAnswer(answer); this.eventEmitter.emit(JitsiConferenceEvents._MEDIA_SESSION_STARTED, this.p2pJingleSession); } }; /** * Callback called by the Jingle plugin when 'transport-info' is received. * @param {JingleSessionPC} session the Jingle session for which the IQ was * received * @param {jQuery} transportInfo a jQuery selector pointing to 'jingle' IQ * element */ // eslint-disable-next-line no-unused-vars JitsiConference.prototype.onTransportInfo = function(session, transportInfo) { if (this.p2pJingleSession === session) { logger.info('P2P addIceCandidates'); this.p2pJingleSession.addIceCandidates(transportInfo); } }; /** * Notifies this JitsiConference that a JitsiRemoteTrack was removed from * the conference. * * @param {JitsiRemoteTrack} removedTrack */ JitsiConference.prototype.onRemoteTrackRemoved = function(removedTrack) { this.getParticipants().forEach(participant => { const tracks = participant.getTracks(); for (let i = 0; i < tracks.length; i++) { if (tracks[i] === removedTrack) { // Since the tracks have been compared and are // considered equal the result of splice can be ignored. participant._tracks.splice(i, 1); this.eventEmitter.emit( JitsiConferenceEvents.TRACK_REMOVED, removedTrack); if (this.transcriber) { this.transcriber.removeTrack(removedTrack); } break; } } }, this); }; /** * Handles an incoming call event for the P2P jingle session. */ JitsiConference.prototype._onIncomingCallP2P = function( jingleSession, jingleOffer) { let rejectReason; if ((!this.isP2PEnabled() && !this.isP2PTestModeEnabled()) || browser.isFirefox() || browser.isWebKitBased()) { rejectReason = { reason: 'decline', reasonDescription: 'P2P disabled', errorMsg: 'P2P mode disabled in the configuration' }; } else if (this.p2pJingleSession) { // Reject incoming P2P call (already in progress) rejectReason = { reason: 'busy', reasonDescription: 'P2P already in progress', errorMsg: 'Duplicated P2P "session-initiate"' }; } else if (!this._shouldBeInP2PMode()) { rejectReason = { reason: 'decline', reasonDescription: 'P2P requirements not met', errorMsg: 'Received P2P "session-initiate" when should not be in P2P mode' }; Statistics.sendAnalytics(createJingleEvent(ACTION_P2P_DECLINED)); } if (rejectReason) { this._rejectIncomingCall(jingleSession, rejectReason); } else { this._acceptP2PIncomingCall(jingleSession, jingleOffer); } }; /** * Handles an incoming call event. */ JitsiConference.prototype.onIncomingCall = function( jingleSession, jingleOffer, now) { // Handle incoming P2P call if (jingleSession.isP2P) { this._onIncomingCallP2P(jingleSession, jingleOffer); } else { if (!this.room.isFocus(jingleSession.remoteJid)) { const description = 'Rejecting session-initiate from non-focus.'; this._rejectIncomingCall( jingleSession, { reason: 'security-error', reasonDescription: description, errorMsg: description }); return; } this._acceptJvbIncomingCall(jingleSession, jingleOffer, now); } }; /** * Accepts an incoming call event for the JVB jingle session. */ JitsiConference.prototype._acceptJvbIncomingCall = function( jingleSession, jingleOffer, now) { // Accept incoming call this.jvbJingleSession = jingleSession; this.room.connectionTimes['session.initiate'] = now; this._sendConferenceJoinAnalyticsEvent(); if (this.wasStopped) { Statistics.sendAnalyticsAndLog( createJingleEvent(ACTION_JINGLE_RESTART, { p2p: false })); } const serverRegion = $(jingleOffer) .find('>bridge-session[xmlns="http://jitsi.org/protocol/focus"]') .attr('region'); this.eventEmitter.emit( JitsiConferenceEvents.SERVER_REGION_CHANGED, serverRegion); this._maybeClearSITimeout(); Statistics.sendAnalytics(createJingleEvent( ACTION_JINGLE_SI_RECEIVED, { p2p: false, value: now })); try { jingleSession.initialize(this.room, this.rtc, { ...this.options.config, enableInsertableStreams: this.isE2EEEnabled() }); } catch (error) { GlobalOnErrorHandler.callErrorHandler(error); logger.error(error); return; } // Open a channel with the videobridge. this._setBridgeChannel(jingleOffer, jingleSession.peerconnection); const localTracks = this._getInitialLocalTracks(); try { jingleSession.acceptOffer( jingleOffer, () => { // If for any reason invite for the JVB session arrived after // the P2P has been established already the media transfer needs // to be turned off here. if (this.isP2PActive() && this.jvbJingleSession) { this._suspendMediaTransferForJvbConnection(); } this.eventEmitter.emit( JitsiConferenceEvents._MEDIA_SESSION_STARTED, jingleSession); if (!this.isP2PActive()) { this.eventEmitter.emit( JitsiConferenceEvents._MEDIA_SESSION_ACTIVE_CHANGED, jingleSession); } }, error => { GlobalOnErrorHandler.callErrorHandler(error); logger.error( 'Failed to accept incoming Jingle session', error); }, localTracks ); // Start callstats as soon as peerconnection is initialized, // do not wait for XMPPEvents.PEERCONNECTION_READY, as it may never // happen in case if user doesn't have or denied permission to // both camera and microphone. logger.info('Starting CallStats for JVB connection...'); this.statistics.startCallStats( this.jvbJingleSession.peerconnection, 'jitsi' /* Remote user ID for JVB is 'jitsi' */); this.statistics.startRemoteStats(this.jvbJingleSession.peerconnection); } catch (e) { GlobalOnErrorHandler.callErrorHandler(e); logger.error(e); } }; /** * Sets the BridgeChannel. * * @param {jQuery} offerIq a jQuery selector pointing to the jingle element of * the offer IQ which may carry the WebSocket URL for the 'websocket' * BridgeChannel mode. * @param {TraceablePeerConnection} pc the peer connection which will be used * to listen for new WebRTC Data Channels (in the 'datachannel' mode). */ JitsiConference.prototype._setBridgeChannel = function(offerIq, pc) { let wsUrl = null; const webSocket = $(offerIq) .find('>content>transport>web-socket') .first(); if (webSocket.length === 1) { wsUrl = webSocket[0].getAttribute('url'); } if (wsUrl) { // If the offer contains a websocket use it. this.rtc.initializeBridgeChannel(null, wsUrl); } else { // Otherwise, fall back to an attempt to use SCTP. this.rtc.initializeBridgeChannel(pc, null); } }; /** * Rejects incoming Jingle call. * @param {JingleSessionPC} jingleSession the session instance to be rejected. * @param {object} [options] * @param {string} options.reason the name of the reason element as defined * by Jingle * @param {string} options.reasonDescription the reason description which will * be included in Jingle 'session-terminate' message. * @param {string} options.errorMsg an error message to be logged on global * error handler * @private */ JitsiConference.prototype._rejectIncomingCall = function( jingleSession, options) { if (options && options.errorMsg) { GlobalOnErrorHandler.callErrorHandler(new Error(options.errorMsg)); } // Terminate the jingle session with a reason jingleSession.terminate( null /* success callback => we don't care */, error => { logger.warn( 'An error occurred while trying to terminate' + ' invalid Jingle session', error); }, { reason: options && options.reason, reasonDescription: options && options.reasonDescription, sendSessionTerminate: true }); }; /** * Handles the call ended event. * XXX is this due to the remote side terminating the Jingle session? * * @param {JingleSessionPC} jingleSession the jingle session which has been * terminated. * @param {String} reasonCondition the Jingle reason condition. * @param {String|null} reasonText human readable reason text which may provide * more details about why the call has been terminated. */ JitsiConference.prototype.onCallEnded = function( jingleSession, reasonCondition, reasonText) { logger.info( `Call ended: ${reasonCondition} - ${reasonText} P2P ?${ jingleSession.isP2P}`); if (jingleSession === this.jvbJingleSession) { this.wasStopped = true; Statistics.sendAnalytics( createJingleEvent(ACTION_JINGLE_TERMINATE, { p2p: false })); // Stop the stats if (this.statistics) { this.statistics.stopRemoteStats( this.jvbJingleSession.peerconnection); logger.info('Stopping JVB CallStats'); this.statistics.stopCallStats( this.jvbJingleSession.peerconnection); } // Current JVB JingleSession is no longer valid, so set it to null this.jvbJingleSession = null; // Let the RTC service do any cleanups this.rtc.onCallEnded(); } else if (jingleSession === this.p2pJingleSession) { // It's the responder who decides to enforce JVB mode, so that both // initiator and responder are aware if it was intentional. if (reasonCondition === 'decline' && reasonText === 'force JVB121') { logger.info('In forced JVB 121 mode...'); Statistics.analytics.addPermanentProperties({ forceJvb121: true }); } else if (reasonCondition === 'connectivity-error' && reasonText === 'ICE FAILED') { // It can happen that the other peer detects ICE failed and // terminates the session, before we get the event on our side. // But we are able to parse the reason and mark it here. Statistics.analytics.addPermanentProperties({ p2pFailed: true }); } this._stopP2PSession(); } else { logger.error( 'Received onCallEnded for invalid session', jingleSession.sid, jingleSession.remoteJid, reasonCondition, reasonText); } }; /** * Handles the suspend detected event. Leaves the room and fires suspended. * @param {JingleSessionPC} jingleSession */ JitsiConference.prototype.onSuspendDetected = function(jingleSession) { if (!jingleSession.isP2P) { this.leave(); this.eventEmitter.emit(JitsiConferenceEvents.SUSPEND_DETECTED); } }; JitsiConference.prototype.updateDTMFSupport = function() { let somebodySupportsDTMF = false; const participants = this.getParticipants(); // check if at least 1 participant supports DTMF for (let i = 0; i < participants.length; i += 1) { if (participants[i].supportsDTMF()) { somebodySupportsDTMF = true; break; } } if (somebodySupportsDTMF !== this.somebodySupportsDTMF) { this.somebodySupportsDTMF = somebodySupportsDTMF; this.eventEmitter.emit( JitsiConferenceEvents.DTMF_SUPPORT_CHANGED, somebodySupportsDTMF); } }; /** * Allows to check if there is at least one user in the conference * that supports DTMF. * @returns {boolean} true if somebody supports DTMF, false otherwise */ JitsiConference.prototype.isDTMFSupported = function() { return this.somebodySupportsDTMF; }; /** * Returns the local user's ID * @return {string} local user's ID */ JitsiConference.prototype.myUserId = function() { return ( this.room && this.room.myroomjid ? Strophe.getResourceFromJid(this.room.myroomjid) : null); }; JitsiConference.prototype.sendTones = function(tones, duration, pause) { const peerConnection = this.getActivePeerConnection(); if (peerConnection) { peerConnection.sendTones(tones, duration, pause); } else { logger.warn('cannot sendTones: no peer connection'); } }; /** * Starts recording the current conference. * * @param {Object} options - Configuration for the recording. See * {@link Chatroom#startRecording} for more info. * @returns {Promise} See {@link Chatroom#startRecording} for more info. */ JitsiConference.prototype.startRecording = function(options) { if (this.room) { return this.recordingManager.startRecording(options); } return Promise.reject(new Error('The conference is not created yet!')); }; /** * Stop a recording session. * * @param {string} sessionID - The ID of the recording session that * should be stopped. * @returns {Promise} See {@link Chatroom#stopRecording} for more info. */ JitsiConference.prototype.stopRecording = function(sessionID) { if (this.room) { return this.recordingManager.stopRecording(sessionID); } return Promise.reject(new Error('The conference is not created yet!')); }; /** * Returns true if the SIP calls are supported and false otherwise */ JitsiConference.prototype.isSIPCallingSupported = function() { if (this.room) { return this.room.isSIPCallingSupported(); } return false; }; /** * Dials a number. * @param number the number */ JitsiConference.prototype.dial = function(number) { if (this.room) { return this.room.dial(number); } return new Promise((resolve, reject) => { reject(new Error('The conference is not created yet!')); }); }; /** * Hangup an existing call */ JitsiConference.prototype.hangup = function() { if (this.room) { return this.room.hangup(); } return new Promise((resolve, reject) => { reject(new Error('The conference is not created yet!')); }); }; /** * Starts the transcription service. */ JitsiConference.prototype.startTranscriber = function() { return this.dial('jitsi_meet_transcribe'); }; /** * Stops the transcription service. */ JitsiConference.prototype.stopTranscriber = JitsiConference.prototype.hangup; /** * Returns the phone number for joining the conference. */ JitsiConference.prototype.getPhoneNumber = function() { if (this.room) { return this.room.getPhoneNumber(); } return null; }; /** * Returns the pin for joining the conference with phone. */ JitsiConference.prototype.getPhonePin = function() { if (this.room) { return this.room.getPhonePin(); } return null; }; /** * Returns the meeting unique ID if any. * * @returns {string|undefined} */ JitsiConference.prototype.getMeetingUniqueId = function() { if (this.room) { return this.room.getMeetingId(); } }; /** * Will return P2P or JVB TraceablePeerConnection depending on * which connection is currently active. * * @return {TraceablePeerConnection|null} null if there isn't any active * TraceablePeerConnection currently available. * @public (FIXME how to make package local ?) */ JitsiConference.prototype.getActivePeerConnection = function() { const session = this.isP2PActive() ? this.p2pJingleSession : this.jvbJingleSession; return session ? session.peerconnection : null; }; /** * Returns the connection state for the current room. Its ice connection state * for its session. * NOTE that "completed" ICE state which can appear on the P2P connection will * be converted to "connected". * @return {string|null} ICE state name or null if there is no active * peer connection at this time. */ JitsiConference.prototype.getConnectionState = function() { const peerConnection = this.getActivePeerConnection(); return peerConnection ? peerConnection.getConnectionState() : null; }; /** * Make all new participants mute their audio/video on join. * @param policy {Object} object with 2 boolean properties for video and audio: * @param {boolean} audio if audio should be muted. * @param {boolean} video if video should be muted. */ JitsiConference.prototype.setStartMutedPolicy = function(policy) { if (!this.isModerator()) { logger.warn(`Failed to set start muted policy, ${this.room ? '' : 'not in a room, '}${ this.isModerator() ? '' : 'participant is not a moderator'}`); return; } this.startMutedPolicy = policy; this.room.addOrReplaceInPresence('startmuted', { attributes: { audio: policy.audio, video: policy.video, xmlns: 'http://jitsi.org/jitmeet/start-muted' } }) && this.room.sendPresence(); }; /** * Returns current start muted policy * @returns {Object} with 2 properties - audio and video. */ JitsiConference.prototype.getStartMutedPolicy = function() { return this.startMutedPolicy; }; /** * Check if audio is muted on join. */ JitsiConference.prototype.isStartAudioMuted = function() { return this.startAudioMuted; }; /** * Check if video is muted on join. */ JitsiConference.prototype.isStartVideoMuted = function() { return this.startVideoMuted; }; /** * Returns measured connectionTimes. */ JitsiConference.prototype.getConnectionTimes = function() { return this.room.connectionTimes; }; /** * Sets a property for the local participant. */ JitsiConference.prototype.setLocalParticipantProperty = function(name, value) { this.sendCommand(`jitsi_participant_${name}`, { value }); }; /** * Removes a property for the local participant and sends the updated presence. */ JitsiConference.prototype.removeLocalParticipantProperty = function(name) { this.removeCommand(`jitsi_participant_${name}`); this.room.sendPresence(); }; /** * Gets a local participant property. * * @return value of the local participant property if the tagName exists in the * list of properties, otherwise returns undefined. */ JitsiConference.prototype.getLocalParticipantProperty = function(name) { const property = this.room.presMap.nodes.find(prop => prop.tagName === `jitsi_participant_${name}` ); return property ? property.value : undefined; }; /** * Sends the given feedback through CallStats if enabled. * * @param overallFeedback an integer between 1 and 5 indicating the * user feedback * @param detailedFeedback detailed feedback from the user. Not yet used * @returns {Promise} Resolves if feedback is submitted successfully. */ JitsiConference.prototype.sendFeedback = function( overallFeedback, detailedFeedback) { return this.statistics.sendFeedback(overallFeedback, detailedFeedback); }; /** * Returns true if the callstats integration is enabled, otherwise returns * false. * * @returns true if the callstats integration is enabled, otherwise returns * false. */ JitsiConference.prototype.isCallstatsEnabled = function() { return this.statistics.isCallstatsEnabled(); }; /** * Finds the SSRC of a given track * * @param track * @returns {number|undefined} the SSRC of the specificed track, otherwise undefined. */ JitsiConference.prototype.getSsrcByTrack = function(track) { return track.isLocal() ? this.getActivePeerConnection()?.getLocalSSRC(track) : track.getSSRC(); }; /** * Handles track attached to container (Calls associateStreamWithVideoTag method * from statistics module) * @param {JitsiLocalTrack|JitsiRemoteTrack} track the track * @param container the container */ JitsiConference.prototype._onTrackAttach = function(track, container) { const isLocal = track.isLocal(); let ssrc = null; const isP2P = track.isP2P; const remoteUserId = isP2P ? track.getParticipantId() : 'jitsi'; const peerConnection = isP2P ? this.p2pJingleSession && this.p2pJingleSession.peerconnection : this.jvbJingleSession && this.jvbJingleSession.peerconnection; if (isLocal) { // Local tracks have SSRC stored on per peer connection basis. if (peerConnection) { ssrc = peerConnection.getLocalSSRC(track); } } else { ssrc = track.getSSRC(); } if (!container.id || !ssrc || !peerConnection) { return; } this.statistics.associateStreamWithVideoTag( peerConnection, ssrc, isLocal, remoteUserId, track.getUsageLabel(), container.id); }; /** * Logs an "application log" message. * @param message {string} The message to log. Note that while this can be a * generic string, the convention used by lib-jitsi-meet and jitsi-meet is to * log valid JSON strings, with an "id" field used for distinguishing between * message types. E.g.: {id: "recorder_status", status: "off"} */ JitsiConference.prototype.sendApplicationLog = function(message) { Statistics.sendLog(message); }; /** * Checks if the user identified by given mucJid is the conference * focus. * @param mucJid the full MUC address of the user to be checked. * @returns {boolean|null} true if MUC user is the conference focus, * false when is not. null if we're not in the MUC anymore and * are unable to figure out the status or if given mucJid is invalid. */ JitsiConference.prototype._isFocus = function(mucJid) { return this.room ? this.room.isFocus(mucJid) : null; }; /** * Fires CONFERENCE_FAILED event with INCOMPATIBLE_SERVER_VERSIONS parameter */ JitsiConference.prototype._fireIncompatibleVersionsEvent = function() { this.eventEmitter.emit(JitsiConferenceEvents.CONFERENCE_FAILED, JitsiConferenceErrors.INCOMPATIBLE_SERVER_VERSIONS); }; /** * Sends a message via the data channel. * @param to {string} the id of the endpoint that should receive the message. * If "" the message will be sent to all participants. * @param payload {object} the payload of the message. * @throws NetworkError or InvalidStateError or Error if the operation fails. * @deprecated Use 'sendMessage' instead. TODO: this should be private. */ JitsiConference.prototype.sendEndpointMessage = function(to, payload) { this.rtc.sendChannelMessage(to, payload); }; /** * Sends local stats via the bridge channel which then forwards to other endpoints selectively. * @param {Object} payload The payload of the message. * @throws NetworkError/InvalidStateError/Error if the operation fails or if there is no data channel created. */ JitsiConference.prototype.sendEndpointStatsMessage = function(payload) { this.rtc.sendEndpointStatsMessage(payload); }; /** * Sends a broadcast message via the data channel. * @param payload {object} the payload of the message. * @throws NetworkError or InvalidStateError or Error if the operation fails. * @deprecated Use 'sendMessage' instead. TODO: this should be private. */ JitsiConference.prototype.broadcastEndpointMessage = function(payload) { this.sendEndpointMessage('', payload); }; /** * Sends a message to a given endpoint (if 'to' is a non-empty string), or * broadcasts it to all endpoints in the conference. * @param {string} to The ID of the endpoint/participant which is to receive * the message, or '' to broadcast the message to all endpoints in the * conference. * @param {string|object} message the message to send. If this is of type * 'string' it will be sent as a chat message. If it is of type 'object', it * will be encapsulated in a format recognized by jitsi-meet and converted to * JSON before being sent. * @param {boolean} sendThroughVideobridge Whether to send the message through * jitsi-videobridge (via the COLIBRI data channel or web socket), or through * the XMPP MUC. Currently only objects can be sent through jitsi-videobridge. */ JitsiConference.prototype.sendMessage = function( message, to = '', sendThroughVideobridge = false) { const messageType = typeof message; // Through videobridge we support only objects. Through XMPP we support // objects (encapsulated in a specific JSON format) and strings (i.e. // regular chat messages). if (messageType !== 'object' && (sendThroughVideobridge || messageType !== 'string')) { logger.error(`Can not send a message of type ${messageType}`); return; } if (sendThroughVideobridge) { this.sendEndpointMessage(to, message); } else { let messageToSend = message; // Name of packet extension of message stanza to send the required // message in. let elementName = 'body'; if (messageType === 'object') { elementName = 'json-message'; // Mark as valid JSON message if not already if (!messageToSend.hasOwnProperty(JITSI_MEET_MUC_TYPE)) { messageToSend[JITSI_MEET_MUC_TYPE] = ''; } try { messageToSend = JSON.stringify(messageToSend); } catch (e) { logger.error('Can not send a message, stringify failed: ', e); return; } } if (to) { this.sendPrivateTextMessage(to, messageToSend, elementName); } else { // Broadcast this.sendTextMessage(messageToSend, elementName); } } }; JitsiConference.prototype.isConnectionInterrupted = function() { return this.isP2PActive() ? this.isP2PConnectionInterrupted : this.isJvbConnectionInterrupted; }; /** * Handles {@link XMPPEvents.CONNECTION_RESTARTED} event. This happens when the bridge goes down * and Jicofo moves conferences away to a different bridge. * @param {JingleSessionPC} session * @private */ JitsiConference.prototype._onConferenceRestarted = function(session) { if (!session.isP2P && this.options.config.enableForcedReload) { this.restartInProgress = true; this.eventEmitter.emit(JitsiConferenceEvents.CONFERENCE_FAILED, JitsiConferenceErrors.CONFERENCE_RESTARTED); } }; /** * Handles {@link XMPPEvents.CONNECTION_INTERRUPTED} * @param {JingleSessionPC} session * @private */ JitsiConference.prototype._onIceConnectionInterrupted = function(session) { if (session.isP2P) { this.isP2PConnectionInterrupted = true; } else { this.isJvbConnectionInterrupted = true; } if (session.isP2P === this.isP2PActive()) { this.eventEmitter.emit(JitsiConferenceEvents.CONNECTION_INTERRUPTED); } }; /** * Handles {@link XMPPEvents.CONNECTION_ICE_FAILED} * @param {JingleSessionPC} session * @private */ JitsiConference.prototype._onIceConnectionFailed = function(session) { // We do nothing for the JVB connection, because it's up to the Jicofo to // eventually come up with the new offer (at least for the time being). if (session.isP2P) { // Add p2pFailed property to analytics to distinguish, between "good" // and "bad" connection Statistics.analytics.addPermanentProperties({ p2pFailed: true }); if (this.p2pJingleSession) { Statistics.sendAnalyticsAndLog( createP2PEvent( ACTION_P2P_FAILED, { initiator: this.p2pJingleSession.isInitiator })); } this._stopP2PSession('connectivity-error', 'ICE FAILED'); } else if (session && this.jvbJingleSession === session) { this._delayedIceFailed = new IceFailedHandling(this); this._delayedIceFailed.start(session); } }; /** * Handles {@link XMPPEvents.CONNECTION_RESTORED} * @param {JingleSessionPC} session * @private */ JitsiConference.prototype._onIceConnectionRestored = function(session) { if (session.isP2P) { this.isP2PConnectionInterrupted = false; } else { this.isJvbConnectionInterrupted = false; this._delayedIceFailed && this._delayedIceFailed.cancel(); } if (session.isP2P === this.isP2PActive()) { this.eventEmitter.emit(JitsiConferenceEvents.CONNECTION_RESTORED); } }; /** * Accept incoming P2P Jingle call. * @param {JingleSessionPC} jingleSession the session instance * @param {jQuery} jingleOffer a jQuery selector pointing to 'jingle' IQ element * @private */ JitsiConference.prototype._acceptP2PIncomingCall = function( jingleSession, jingleOffer) { this.isP2PConnectionInterrupted = false; // Accept the offer this.p2pJingleSession = jingleSession; this._sendConferenceJoinAnalyticsEvent(); this.p2pJingleSession.initialize( this.room, this.rtc, { ...this.options.config, enableInsertableStreams: this.isE2EEEnabled() }); logger.info('Starting CallStats for P2P connection...'); let remoteID = Strophe.getResourceFromJid(this.p2pJingleSession.remoteJid); const participant = this.participants[remoteID]; if (participant) { remoteID = participant.getStatsID() || remoteID; } this.statistics.startCallStats( this.p2pJingleSession.peerconnection, remoteID); const localTracks = this.getLocalTracks(); this.p2pJingleSession.acceptOffer( jingleOffer, () => { logger.debug('Got RESULT for P2P "session-accept"'); this.eventEmitter.emit( JitsiConferenceEvents._MEDIA_SESSION_STARTED, this.p2pJingleSession); }, error => { logger.error( 'Failed to accept incoming P2P Jingle session', error); }, localTracks); }; /** * Adds remote tracks to the conference associated with the JVB session. * @private */ JitsiConference.prototype._addRemoteJVBTracks = function() { this._addRemoteTracks( 'JVB', this.jvbJingleSession.peerconnection.getRemoteTracks()); }; /** * Adds remote tracks to the conference associated with the P2P session. * @private */ JitsiConference.prototype._addRemoteP2PTracks = function() { this._addRemoteTracks( 'P2P', this.p2pJingleSession.peerconnection.getRemoteTracks()); }; /** * Generates fake "remote track added" events for given Jingle session. * @param {string} logName the session's nickname which will appear in log * messages. * @param {Array} remoteTracks the tracks that will be added * @private */ JitsiConference.prototype._addRemoteTracks = function(logName, remoteTracks) { for (const track of remoteTracks) { logger.info(`Adding remote ${logName} track: ${track}`); this.onRemoteTrackAdded(track); } }; /** * Called when {@link XMPPEvents.CONNECTION_ESTABLISHED} event is * triggered for a {@link JingleSessionPC}. Switches the conference to use * the P2P connection if the event comes from the P2P session. * @param {JingleSessionPC} jingleSession the session instance. * @private */ JitsiConference.prototype._onIceConnectionEstablished = function( jingleSession) { if (this.p2pJingleSession !== null) { // store the establishment time of the p2p session as a field of the // JitsiConference because the p2pJingleSession might get disposed (thus // the value is lost). this.p2pEstablishmentDuration = this.p2pJingleSession.establishmentDuration; } if (this.jvbJingleSession !== null) { this.jvbEstablishmentDuration = this.jvbJingleSession.establishmentDuration; } let done = false; const forceJVB121Ratio = this.options.config.forceJVB121Ratio; // We don't care about the JVB case, there's nothing to be done if (!jingleSession.isP2P) { done = true; } else if (this.p2pJingleSession !== jingleSession) { logger.error('CONNECTION_ESTABLISHED - wrong P2P session instance ?!'); done = true; } else if (!jingleSession.isInitiator && typeof forceJVB121Ratio === 'number' && Math.random() < forceJVB121Ratio) { logger.info(`Forcing JVB 121 mode (ratio=${forceJVB121Ratio})...`); Statistics.analytics.addPermanentProperties({ forceJvb121: true }); this._stopP2PSession('decline', 'force JVB121'); done = true; } if (!isNaN(this.p2pEstablishmentDuration) && !isNaN(this.jvbEstablishmentDuration)) { const establishmentDurationDiff = this.p2pEstablishmentDuration - this.jvbEstablishmentDuration; Statistics.sendAnalytics( ICE_ESTABLISHMENT_DURATION_DIFF, { value: establishmentDurationDiff }); } if (jingleSession.isP2P === this.isP2PActive()) { this.eventEmitter.emit(JitsiConferenceEvents.CONNECTION_ESTABLISHED); } if (done) { return; } // Update P2P status and emit events this._setP2PStatus(true); // Remove remote tracks if (this.jvbJingleSession) { this._removeRemoteJVBTracks(); } else { logger.info('Not removing remote JVB tracks - no session yet'); } this._addRemoteP2PTracks(); // Stop media transfer over the JVB connection if (this.jvbJingleSession) { this._suspendMediaTransferForJvbConnection(); } logger.info('Starting remote stats with p2p connection'); this.statistics.startRemoteStats(this.p2pJingleSession.peerconnection); Statistics.sendAnalyticsAndLog( createP2PEvent( ACTION_P2P_ESTABLISHED, { initiator: this.p2pJingleSession.isInitiator })); }; /** * Called when the chat room reads a new list of properties from jicofo's * presence. The properties may have changed, but they don't have to. * * @param {Object} properties - The properties keyed by the property name * ('key'). * @private */ JitsiConference.prototype._updateProperties = function(properties = {}) { const changed = !isEqual(properties, this.properties); this.properties = properties; if (changed) { this.eventEmitter.emit( JitsiConferenceEvents.PROPERTIES_CHANGED, this.properties); // Some of the properties need to be added to analytics events. const analyticsKeys = [ // The number of jitsi-videobridge instances currently used for the // conference. 'bridge-count', // The conference creation time (set by jicofo). 'created-ms' ]; analyticsKeys.forEach(key => { if (properties[key] !== undefined) { Statistics.analytics.addPermanentProperties({ [key.replace('-', '_')]: properties[key] }); } }); } }; /** * Gets a conference property with a given key. * * @param {string} key - The key. * @returns {*} The value */ JitsiConference.prototype.getProperty = function(key) { return this.properties[key]; }; /** * Clears the deferred start P2P task if it has been scheduled. * @private */ JitsiConference.prototype._maybeClearDeferredStartP2P = function() { if (this.deferredStartP2PTask) { logger.info('Cleared deferred start P2P task'); clearTimeout(this.deferredStartP2PTask); this.deferredStartP2PTask = null; } }; /** * Removes from the conference remote tracks associated with the JVB * connection. * @private */ JitsiConference.prototype._removeRemoteJVBTracks = function() { this._removeRemoteTracks( 'JVB', this.jvbJingleSession.peerconnection.getRemoteTracks()); }; /** * Removes from the conference remote tracks associated with the P2P * connection. * @private */ JitsiConference.prototype._removeRemoteP2PTracks = function() { this._removeRemoteTracks( 'P2P', this.p2pJingleSession.peerconnection.getRemoteTracks()); }; /** * Generates fake "remote track removed" events for given Jingle session. * @param {string} sessionNickname the session's nickname which will appear in * log messages. * @param {Array} remoteTracks the tracks that will be removed * @private */ JitsiConference.prototype._removeRemoteTracks = function( sessionNickname, remoteTracks) { for (const track of remoteTracks) { logger.info(`Removing remote ${sessionNickname} track: ${track}`); this.onRemoteTrackRemoved(track); } }; /** * Resumes media transfer over the JVB connection. * @private */ JitsiConference.prototype._resumeMediaTransferForJvbConnection = function() { logger.info('Resuming media transfer over the JVB connection...'); this.jvbJingleSession.setMediaTransferActive(true, true).then( () => { logger.info('Resumed media transfer over the JVB connection!'); }, error => { logger.error( 'Failed to resume media transfer over the JVB connection:', error); }); }; /** * Sets new P2P status and updates some events/states hijacked from * the JitsiConference. * @param {boolean} newStatus the new P2P status value, true means that * P2P is now in use, false means that the JVB connection is now in use * @private */ JitsiConference.prototype._setP2PStatus = function(newStatus) { if (this.p2p === newStatus) { logger.debug(`Called _setP2PStatus with the same status: ${newStatus}`); return; } this.p2p = newStatus; if (newStatus) { logger.info('Peer to peer connection established!'); // When we end up in a valid P2P session need to reset the properties // in case they have persisted, after session with another peer. Statistics.analytics.addPermanentProperties({ p2pFailed: false, forceJvb121: false }); // Sync up video transfer active in case p2pJingleSession not existed // when the lastN value was being adjusted. const isVideoActive = this.getLastN() !== 0; this.p2pJingleSession .setMediaTransferActive(true, isVideoActive) .catch(error => { logger.error( 'Failed to sync up P2P video transfer status' + `(${isVideoActive})`, error); }); } else { logger.info('Peer to peer connection closed!'); } // Put the JVB connection on hold/resume if (this.jvbJingleSession) { this.statistics.sendConnectionResumeOrHoldEvent( this.jvbJingleSession.peerconnection, !newStatus); } // Clear dtmfManager, so that it can be recreated with new connection this.dtmfManager = null; // Update P2P status this.eventEmitter.emit( JitsiConferenceEvents.P2P_STATUS, this, this.p2p); this.eventEmitter.emit( JitsiConferenceEvents._MEDIA_SESSION_ACTIVE_CHANGED, this._getActiveMediaSession()); // Refresh connection interrupted/restored this.eventEmitter.emit( this.isConnectionInterrupted() ? JitsiConferenceEvents.CONNECTION_INTERRUPTED : JitsiConferenceEvents.CONNECTION_RESTORED); }; /** * Starts new P2P session. * @param {string} remoteJid the JID of the remote participant * @private */ JitsiConference.prototype._startP2PSession = function(remoteJid) { this._maybeClearDeferredStartP2P(); if (this.p2pJingleSession) { logger.error('P2P session already started!'); return; } this.isP2PConnectionInterrupted = false; this.p2pJingleSession = this.xmpp.connection.jingle.newP2PJingleSession( this.room.myroomjid, remoteJid); logger.info( 'Created new P2P JingleSession', this.room.myroomjid, remoteJid); this._sendConferenceJoinAnalyticsEvent(); this.p2pJingleSession.initialize( this.room, this.rtc, { ...this.options.config, enableInsertableStreams: this.isE2EEEnabled() }); logger.info('Starting CallStats for P2P connection...'); let remoteID = Strophe.getResourceFromJid(this.p2pJingleSession.remoteJid); const participant = this.participants[remoteID]; if (participant) { remoteID = participant.getStatsID() || remoteID; } this.statistics.startCallStats( this.p2pJingleSession.peerconnection, remoteID); const localTracks = this.getLocalTracks(); this.p2pJingleSession.invite(localTracks); }; /** * Suspends media transfer over the JVB connection. * @private */ JitsiConference.prototype._suspendMediaTransferForJvbConnection = function() { logger.info('Suspending media transfer over the JVB connection...'); this.jvbJingleSession.setMediaTransferActive(false, false).then( () => { logger.info('Suspended media transfer over the JVB connection !'); }, error => { logger.error( 'Failed to suspend media transfer over the JVB connection:', error); }); }; /** * Method when called will decide whether it's the time to start or stop * the P2P session. * @param {boolean} userLeftEvent if true it means that the call * originates from the user left event. * @private */ JitsiConference.prototype._maybeStartOrStopP2P = function(userLeftEvent) { if (!this.isP2PEnabled() || this.isP2PTestModeEnabled() || browser.isFirefox() || browser.isWebKitBased()) { logger.info('Auto P2P disabled'); return; } const peers = this.getParticipants(); const peerCount = peers.length; // FIXME 1 peer and it must *support* P2P switching const shouldBeInP2P = this._shouldBeInP2PMode(); // Clear deferred "start P2P" task if (!shouldBeInP2P && this.deferredStartP2PTask) { this._maybeClearDeferredStartP2P(); } // Start peer to peer session if (!this.p2pJingleSession && shouldBeInP2P) { const peer = peerCount && peers[0]; const myId = this.myUserId(); const peersId = peer.getId(); if (myId > peersId) { logger.debug( 'I\'m the bigger peersId - ' + 'the other peer should start P2P', myId, peersId); return; } else if (myId === peersId) { logger.error('The same IDs ? ', myId, peersId); return; } const jid = peer.getJid(); if (userLeftEvent) { if (this.deferredStartP2PTask) { logger.error('Deferred start P2P task\'s been set already!'); return; } logger.info( `Will start P2P with: ${jid} after ${ this.backToP2PDelay} seconds...`); this.deferredStartP2PTask = setTimeout( this._startP2PSession.bind(this, jid), this.backToP2PDelay * 1000); } else { logger.info(`Will start P2P with: ${jid}`); this._startP2PSession(jid); } } else if (this.p2pJingleSession && !shouldBeInP2P) { logger.info(`Will stop P2P with: ${this.p2pJingleSession.remoteJid}`); // Log that there will be a switch back to the JVB connection if (this.p2pJingleSession.isInitiator && peerCount > 1) { Statistics.sendAnalyticsAndLog( createP2PEvent(ACTION_P2P_SWITCH_TO_JVB)); } this._stopP2PSession(); } }; /** * Tells whether or not this conference should be currently in the P2P mode. * * @private * @returns {boolean} */ JitsiConference.prototype._shouldBeInP2PMode = function() { const peers = this.getParticipants(); const peerCount = peers.length; const hasBotPeer = peers.find(p => p.getBotType() === 'poltergeist' || p.hasFeature(FEATURE_JIGASI)) !== undefined; const shouldBeInP2P = peerCount === 1 && !hasBotPeer; logger.debug(`P2P? peerCount: ${peerCount}, hasBotPeer: ${hasBotPeer} => ${shouldBeInP2P}`); return shouldBeInP2P; }; /** * Stops the current P2P session. * @param {string} [reason="success"] one of the Jingle "reason" element * names as defined by https://xmpp.org/extensions/xep-0166.html#def-reason * @param {string} [reasonDescription="Turing off P2P session"] text * description that will be included in the session terminate message * @private */ JitsiConference.prototype._stopP2PSession = function( reason, reasonDescription) { if (!this.p2pJingleSession) { logger.error('No P2P session to be stopped!'); return; } const wasP2PEstablished = this.isP2PActive(); // Swap remote tracks, but only if the P2P has been fully established if (wasP2PEstablished) { if (this.jvbJingleSession) { this._resumeMediaTransferForJvbConnection(); } // Remove remote P2P tracks this._removeRemoteP2PTracks(); } // Stop P2P stats logger.info('Stopping remote stats for P2P connection'); this.statistics.stopRemoteStats(this.p2pJingleSession.peerconnection); logger.info('Stopping CallStats for P2P connection'); this.statistics.stopCallStats(this.p2pJingleSession.peerconnection); this.p2pJingleSession.terminate( () => { logger.info('P2P session terminate RESULT'); }, error => { // Because both initiator and responder are simultaneously // terminating their JingleSessions in case of the 'to JVB switch' // when 3rd participant joins, both will dispose their sessions and // reply with 'item-not-found' (see strophe.jingle.js). We don't // want to log this as an error since it's expected behaviour. // // We want them both to terminate, because in case of initiator's // crash the responder would stay in P2P mode until ICE fails which // could take up to 20 seconds. // // NOTE lack of 'reason' is considered as graceful session terminate // where both initiator and responder terminate their sessions // simultaneously. if (reason) { logger.error( 'An error occurred while trying to terminate' + ' P2P Jingle session', error); } }, { reason: reason ? reason : 'success', reasonDescription: reasonDescription ? reasonDescription : 'Turing off P2P session', sendSessionTerminate: this.room && this.getParticipantById( Strophe.getResourceFromJid(this.p2pJingleSession.remoteJid)) }); this.p2pJingleSession = null; // Update P2P status and other affected events/states this._setP2PStatus(false); if (wasP2PEstablished) { // Add back remote JVB tracks if (this.jvbJingleSession) { this._addRemoteJVBTracks(); } else { logger.info('Not adding remote JVB tracks - no session yet'); } } }; /** * Checks whether or not the conference is currently in the peer to peer mode. * Being in peer to peer mode means that the direct connection has been * established and the P2P connection is being used for media transmission. * @return {boolean} true if in P2P mode or false otherwise. */ JitsiConference.prototype.isP2PActive = function() { return this.p2p; }; /** * Returns the current ICE state of the P2P connection. * NOTE: method is used by the jitsi-meet-torture tests. * @return {string|null} an ICE state or null if there's currently * no P2P connection. */ JitsiConference.prototype.getP2PConnectionState = function() { if (this.isP2PActive()) { return this.p2pJingleSession.peerconnection.getConnectionState(); } return null; }; /** * Manually starts new P2P session (should be used only in the tests). */ JitsiConference.prototype.startP2PSession = function() { const peers = this.getParticipants(); // Start peer to peer session if (peers.length === 1) { const peerJid = peers[0].getJid(); this._startP2PSession(peerJid); } else { throw new Error( 'There must be exactly 1 participant to start the P2P session !'); } }; /** * Manually stops the current P2P session (should be used only in the tests) */ JitsiConference.prototype.stopP2PSession = function() { this._stopP2PSession(); }; /** * Get a summary of how long current participants have been the dominant speaker * @returns {object} */ JitsiConference.prototype.getSpeakerStats = function() { return this.speakerStatsCollector.getStats(); }; /** * Sets the constraints for the video that is requested from the bridge. * * @param {Object} videoConstraints The constraints which are specified in the * following format. The message updates the fields that are present and leaves the * rest unchanged on the bridge. Therefore, any field that is not applicable anymore * should be cleared by passing an empty object or list (whatever is applicable). * { * 'lastN': 20, * 'selectedEndpoints': ['A', 'B', 'C'], * 'onStageEndpoints': ['A'], * 'defaultConstraints': { 'maxHeight': 180 }, * 'constraints': { * 'A': { 'maxHeight': 720 } * } * } */ JitsiConference.prototype.setReceiverConstraints = function(videoConstraints) { this.receiveVideoController.setReceiverConstraints(videoConstraints); }; /** * Sets the maximum video size the local participant should receive from remote * participants. * * @param {number} maxFrameHeight - the maximum frame height, in pixels, * this receiver is willing to receive. * @returns {void} */ JitsiConference.prototype.setReceiverVideoConstraint = function(maxFrameHeight) { this.receiveVideoController.setPreferredReceiveMaxFrameHeight(maxFrameHeight); }; /** * Sets the maximum video size the local participant should send to remote * participants. * @param {number} maxFrameHeight - The user preferred max frame height. * @returns {Promise} promise that will be resolved when the operation is * successful and rejected otherwise. */ JitsiConference.prototype.setSenderVideoConstraint = function(maxFrameHeight) { return this.sendVideoController.setPreferredSendMaxFrameHeight(maxFrameHeight); }; /** * Creates a video SIP GW session and returns it if service is enabled. Before * creating a session one need to check whether video SIP GW service is * available in the system {@link JitsiConference.isVideoSIPGWAvailable}. Even * if there are available nodes to serve this request, after creating the * session those nodes can be taken and the request about using the * created session can fail. * * @param {string} sipAddress - The sip address to be used. * @param {string} displayName - The display name to be used for this session. * @returns {JitsiVideoSIPGWSession|Error} Returns null if conference is not * initialised and there is no room. */ JitsiConference.prototype.createVideoSIPGWSession = function(sipAddress, displayName) { if (!this.room) { return new Error(VideoSIPGWConstants.ERROR_NO_CONNECTION); } return this.videoSIPGWHandler .createVideoSIPGWSession(sipAddress, displayName); }; /** * Sends a conference.join analytics event. * * @returns {void} */ JitsiConference.prototype._sendConferenceJoinAnalyticsEvent = function() { const meetingId = this.getMeetingUniqueId(); if (this._conferenceJoinAnalyticsEventSent || !meetingId || this.getActivePeerConnection() === null) { return; } Statistics.sendAnalytics(createConferenceEvent('joined', { meetingId, participantId: `${meetingId}.${this._statsCurrentId}` })); this._conferenceJoinAnalyticsEventSent = Date.now(); }; /** * Sends conference.left analytics event. * @private */ JitsiConference.prototype._sendConferenceLeftAnalyticsEvent = function() { const meetingId = this.getMeetingUniqueId(); if (!meetingId || !this._conferenceJoinAnalyticsEventSent) { return; } Statistics.sendAnalytics(createConferenceEvent('left', { meetingId, participantId: `${meetingId}.${this._statsCurrentId}`, stats: { duration: Math.floor((Date.now() - this._conferenceJoinAnalyticsEventSent) / 1000), perf: this.getPerformanceStats() } })); }; /** * Restarts all active media sessions. * * @returns {void} */ JitsiConference.prototype._restartMediaSessions = function() { if (this.p2pJingleSession) { this.stopP2PSession(); } if (this.jvbJingleSession) { this.jvbJingleSession.terminate( null /* success callback => we don't care */, error => { logger.warn('An error occurred while trying to terminate the JVB session', error); }, { reason: 'success', reasonDescription: 'restart required', requestRestart: true, sendSessionTerminate: true }); } this._maybeStartOrStopP2P(false); }; /** * Returns whether End-To-End encryption is enabled. * * @returns {boolean} */ JitsiConference.prototype.isE2EEEnabled = function() { return this._e2eEncryption && this._e2eEncryption.isEnabled(); }; /** * Returns whether End-To-End encryption is supported. Note that not all participants * in the conference may support it. * * @returns {boolean} */ JitsiConference.prototype.isE2EESupported = function() { return E2EEncryption.isSupported(this.options.config); }; /** * Enables / disables End-to-End encryption. * * @param {boolean} enabled whether to enable E2EE or not. * @returns {void} */ JitsiConference.prototype.toggleE2EE = function(enabled) { if (!this.isE2EESupported()) { logger.warn('Cannot enable / disable E2EE: platform is not supported.'); return; } this._e2eEncryption.setEnabled(enabled); }; /** * Returns true if lobby support is enabled in the backend. * * @returns {boolean} whether lobby is supported in the backend. */ JitsiConference.prototype.isLobbySupported = function() { return Boolean(this.room && this.room.getLobby().isSupported()); }; /** * Returns true if the room has members only enabled. * * @returns {boolean} whether conference room is members only. */ JitsiConference.prototype.isMembersOnly = function() { return Boolean(this.room && this.room.membersOnlyEnabled); }; /** * Enables lobby by moderators * * @returns {Promise} resolves when lobby room is joined or rejects with the error. */ JitsiConference.prototype.enableLobby = function() { if (this.room && this.isModerator()) { return this.room.getLobby().enable(); } return Promise.reject( new Error('The conference not started or user is not moderator')); }; /** * Disabled lobby by moderators * * @returns {void} */ JitsiConference.prototype.disableLobby = function() { if (this.room && this.isModerator()) { this.room.getLobby().disable(); } else { logger.warn(`Failed to disable lobby, ${this.room ? '' : 'not in a room, '}${ this.isModerator() ? '' : 'participant is not a moderator'}`); } }; /** * Joins the lobby room with display name and optional email or with a shared password to skip waiting. * * @param {string} displayName Display name should be set to show it to moderators. * @param {string} email Optional email is used to present avatar to the moderator. * @returns {Promise} */ JitsiConference.prototype.joinLobby = function(displayName, email) { if (this.room) { return this.room.getLobby().join(displayName, email); } return Promise.reject(new Error('The conference not started')); }; /** * Denies an occupant in the lobby room access to the conference. * @param {string} id The participant id. */ JitsiConference.prototype.lobbyDenyAccess = function(id) { if (this.room) { this.room.getLobby().denyAccess(id); } }; /** * Approves the request to join the conference to a participant waiting in the lobby. * * @param {string} id The participant id. */ JitsiConference.prototype.lobbyApproveAccess = function(id) { if (this.room) { this.room.getLobby().approveAccess(id); } }; /** * Returns true if AV Moderation support is enabled in the backend. * * @returns {boolean} whether AV Moderation is supported in the backend. */ JitsiConference.prototype.isAVModerationSupported = function() { return Boolean(this.room && this.room.getAVModeration().isSupported()); }; /** * Enables AV Moderation. * @param {MediaType} mediaType "audio" or "video" */ JitsiConference.prototype.enableAVModeration = function(mediaType) { if (this.room && this.isModerator() && (mediaType === MediaType.AUDIO || mediaType === MediaType.VIDEO)) { this.room.getAVModeration().enable(true, mediaType); } else { logger.warn(`Failed to enable AV moderation, ${this.room ? '' : 'not in a room, '}${ this.isModerator() ? '' : 'participant is not a moderator, '}${ this.room && this.isModerator() ? 'wrong media type passed' : ''}`); } }; /** * Disables AV Moderation. * @param {MediaType} mediaType "audio" or "video" */ JitsiConference.prototype.disableAVModeration = function(mediaType) { if (this.room && this.isModerator() && (mediaType === MediaType.AUDIO || mediaType === MediaType.VIDEO)) { this.room.getAVModeration().enable(false, mediaType); } else { logger.warn(`Failed to disable AV moderation, ${this.room ? '' : 'not in a room, '}${ this.isModerator() ? '' : 'participant is not a moderator, '}${ this.room && this.isModerator() ? 'wrong media type passed' : ''}`); } }; /** * Approve participant access to certain media, allows unmuting audio or video. * * @param {MediaType} mediaType "audio" or "video" * @param id the id of the participant. */ JitsiConference.prototype.avModerationApprove = function(mediaType, id) { if (this.room && this.isModerator() && (mediaType === MediaType.AUDIO || mediaType === MediaType.VIDEO)) { const participant = this.getParticipantById(id); if (!participant) { return; } this.room.getAVModeration().approve(mediaType, participant.getJid()); } else { logger.warn(`AV moderation skipped , ${this.room ? '' : 'not in a room, '}${ this.isModerator() ? '' : 'participant is not a moderator, '}${ this.room && this.isModerator() ? 'wrong media type passed' : ''}`); } };