summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorenahum <nahumhbl@gmail.com>2016-09-16 15:35:13 -0300
committerGitHub <noreply@github.com>2016-09-16 15:35:13 -0300
commit781ff323db4c70e4ca476f9ef13a04e5aa063585 (patch)
treea9dae870d4e750ad87ee0624d8ea859995b0dbf7
parentdf2d61d94175369bff5a16242f35cb6d7b62d7fb (diff)
downloadchat-781ff323db4c70e4ca476f9ef13a04e5aa063585.tar.gz
chat-781ff323db4c70e4ca476f9ef13a04e5aa063585.tar.bz2
chat-781ff323db4c70e4ca476f9ef13a04e5aa063585.zip
Webrtc client side (#4026)
* WebRTC Server side * WebRTC client side * Bug fixes and improvements * Pushing UI improvements for webrtc (#3728) * Pushing UI improvements for webrtc * Updating webrtc css * PLT-3943 WebRTC P1: bug fixes and improvements * Video resolution set to std, reduce volume of ringtone and flip video horizontally * Fix calling a user B while WebRTC RHS is still opened * Leave RHS opened when call ends, Fix isBusy on popover and channel_header * Fix pre-release feature, RHS & System Console * PLT-3945 - Updating UI for webrtc (#3908) * PLT-3943 Webrtc p1 * Add ongoing call indicator when RHS is opened * UI updates to to webrtc notifcation (#3959)
-rw-r--r--model/license.go2
-rw-r--r--webapp/actions/webrtc_actions.jsx20
-rw-r--r--webapp/actions/websocket_actions.jsx14
-rw-r--r--webapp/client/client.jsx12
-rw-r--r--webapp/client/webrtc_client.jsx145
-rw-r--r--webapp/client/webrtc_session.jsx1966
-rw-r--r--webapp/components/admin_console/admin_sidebar.jsx2
-rw-r--r--webapp/components/admin_console/webrtc_settings.jsx46
-rw-r--r--webapp/components/channel_header.jsx98
-rw-r--r--webapp/components/leave_team_modal.jsx16
-rw-r--r--webapp/components/loading_screen.jsx19
-rw-r--r--webapp/components/navbar_dropdown.jsx24
-rw-r--r--webapp/components/needs_team.jsx5
-rw-r--r--webapp/components/post_view/components/post.jsx9
-rw-r--r--webapp/components/post_view/components/post_header.jsx6
-rw-r--r--webapp/components/post_view/components/post_list.jsx4
-rw-r--r--webapp/components/post_view/post_focus_view_controller.jsx14
-rw-r--r--webapp/components/post_view/post_view_controller.jsx14
-rw-r--r--webapp/components/rhs_header_post.jsx14
-rw-r--r--webapp/components/rhs_thread.jsx2
-rw-r--r--webapp/components/sidebar_right.jsx5
-rw-r--r--webapp/components/sidebar_right_menu.jsx12
-rw-r--r--webapp/components/user_profile.jsx105
-rw-r--r--webapp/components/user_settings/user_settings_advanced.jsx16
-rw-r--r--webapp/components/webrtc/components/webrtc_header.jsx100
-rw-r--r--webapp/components/webrtc/components/webrtc_notification.jsx320
-rw-r--r--webapp/components/webrtc/components/webrtc_sidebar.jsx133
-rw-r--r--webapp/components/webrtc/webrtc_controller.jsx1214
-rw-r--r--webapp/i18n/en.json37
-rw-r--r--webapp/images/ring.mp3bin0 -> 229995 bytes
-rw-r--r--webapp/package.json1
-rw-r--r--webapp/sass/components/_module.scss1
-rw-r--r--webapp/sass/components/_webrtc.scss297
-rw-r--r--webapp/sass/layout/_content.scss3
-rw-r--r--webapp/sass/layout/_headers.scss3
-rw-r--r--webapp/sass/layout/_sidebar-right.scss9
-rw-r--r--webapp/sass/responsive/_desktop.scss9
-rw-r--r--webapp/sass/responsive/_mobile.scss6
-rw-r--r--webapp/sass/responsive/_tablet.scss12
-rw-r--r--webapp/sass/routes/_print.scss3
-rw-r--r--webapp/sass/utils/_mixins.scss72
-rw-r--r--webapp/sass/utils/_variables.scss10
-rw-r--r--webapp/stores/webrtc_store.jsx110
-rw-r--r--webapp/utils/channel_intro_messages.jsx5
-rw-r--r--webapp/utils/constants.jsx77
45 files changed, 4902 insertions, 90 deletions
diff --git a/model/license.go b/model/license.go
index 465cca128..ed38ea438 100644
--- a/model/license.go
+++ b/model/license.go
@@ -43,7 +43,7 @@ type Features struct {
MHPNS *bool `json:"mhpns"`
SAML *bool `json:"saml"`
PasswordRequirements *bool `json:"password_requirements"`
- // after we enabled more features for web rtc we'll need to control them with this
+ // after we enabled more features for webrtc we'll need to control them with this
Webrtc *bool `json:"webrtc"`
FutureFeatures *bool `json:"future_features"`
}
diff --git a/webapp/actions/webrtc_actions.jsx b/webapp/actions/webrtc_actions.jsx
new file mode 100644
index 000000000..444eee241
--- /dev/null
+++ b/webapp/actions/webrtc_actions.jsx
@@ -0,0 +1,20 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
+import {WebrtcActionTypes} from 'utils/constants.jsx';
+
+export function initWebrtc(userId, isCalling) {
+ AppDispatcher.handleServerAction({
+ type: WebrtcActionTypes.INITIALIZE,
+ user_id: userId,
+ is_calling: isCalling
+ });
+}
+
+export function handle(message) {
+ AppDispatcher.handleServerAction({
+ type: message.action,
+ message
+ });
+}
diff --git a/webapp/actions/websocket_actions.jsx b/webapp/actions/websocket_actions.jsx
index 68853c229..c69c8e6d2 100644
--- a/webapp/actions/websocket_actions.jsx
+++ b/webapp/actions/websocket_actions.jsx
@@ -15,6 +15,7 @@ import NotificationStore from 'stores/notification_store.jsx'; //eslint-disable-
import Client from 'client/web_client.jsx';
import WebSocketClient from 'client/web_websocket_client.jsx';
+import * as WebrtcActions from './webrtc_actions.jsx';
import * as Utils from 'utils/utils.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
@@ -22,9 +23,7 @@ import * as GlobalActions from 'actions/global_actions.jsx';
import * as UserActions from 'actions/user_actions.jsx';
import {handleNewPost} from 'actions/post_actions.jsx';
-import Constants from 'utils/constants.jsx';
-const SocketEvents = Constants.SocketEvents;
-const ActionTypes = Constants.ActionTypes;
+import {Constants, SocketEvents, ActionTypes} from 'utils/constants.jsx';
import {browserHistory} from 'react-router/es6';
@@ -160,6 +159,10 @@ function handleEvent(msg) {
handleHelloEvent(msg);
break;
+ case SocketEvents.WEBRTC:
+ handleWebrtc(msg);
+ break;
+
default:
}
}
@@ -293,3 +296,8 @@ function handleHelloEvent(msg) {
Client.serverVersion = msg.data.server_version;
AsyncClient.checkVersion();
}
+
+function handleWebrtc(msg) {
+ const data = msg.data;
+ return WebrtcActions.handle(data);
+} \ No newline at end of file
diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx
index a5d179a0d..544eb4980 100644
--- a/webapp/client/client.jsx
+++ b/webapp/client/client.jsx
@@ -121,6 +121,10 @@ export default class Client {
return `${this.url}${this.urlVersion}/users/${userId}`;
}
+ getWebrtcRoute() {
+ return `${this.url}${this.urlVersion}/webrtc`;
+ }
+
setTranslations(messages) {
this.translations = messages;
}
@@ -1803,4 +1807,12 @@ export default class Client {
return success(res.body);
});
}
+
+ webrtcToken(success, error) {
+ request.post(`${this.getWebrtcRoute()}/token`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ end(this.handleResponse.bind(this, 'webrtcToken', success, error));
+ }
}
diff --git a/webapp/client/webrtc_client.jsx b/webapp/client/webrtc_client.jsx
new file mode 100644
index 000000000..85204c25b
--- /dev/null
+++ b/webapp/client/webrtc_client.jsx
@@ -0,0 +1,145 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+export default class WebrtcClient {
+ constructor() {
+ this.init = this.init.bind(this);
+ this.listDevices = this.listDevices.bind(this);
+ this.isExtensionEnabled = this.isExtensionEnabled.bind(this);
+ this.isWebrtcSupported = this.isWebrtcSupported.bind(this);
+
+ this.initDone = false;
+ this.sessions = {};
+ }
+
+ noop() {} //eslint-disable-line no-empty-function
+
+ // this function is going to be needed when we enable screen sharing
+ isExtensionEnabled() {
+ if (window.navigator.userAgent.match('Chrome')) {
+ const chromever = parseInt(window.navigator.userAgent.match(/Chrome\/(.*) /)[1], 10);
+ let maxver = 33;
+ if (window.navigator.userAgent.match('Linux')) {
+ maxver = 35; // "known" crash in chrome 34 and 35 on linux
+ }
+ if (chromever >= 26 && chromever <= maxver) {
+ // Older versions of Chrome don't support this extension-based approach, so lie
+ return true;
+ }
+ return (document.getElementById('mattermost-extension-installed') !== null);
+ }
+
+ // Firefox of others, no need for the extension (but this doesn't mean it will work)
+ return true;
+ }
+
+ init(opts) {
+ const options = opts || {};
+
+ if (this.initDone === true) {
+ // Already initialized
+ return;
+ }
+
+ this.trace = this.noop;
+ this.debug = this.noop;
+ this.log = this.noop;
+ this.warn = this.noop;
+ this.error = this.noop;
+
+ /* eslint-disable */
+ if (options.debug === true || options.debug === 'all') {
+ // Enable all debugging levels
+ this.trace = console.trace.bind(console);
+ this.debug = console.debug.bind(console);
+ this.log = console.log.bind(console);
+ this.warn = console.warn.bind(console);
+ this.error = console.error.bind(console);
+ } else if (Array.isArray(options.debug)) {
+ for (const i in options.debug) {
+ if (options.debug.hasOwnProperty(i)) {
+ const d = options.debug[i];
+ switch (d) {
+ case 'trace':
+ this.trace = console.trace.bind(console);
+ break;
+ case 'debug':
+ this.debug = console.debug.bind(console);
+ break;
+ case 'log':
+ this.log = console.log.bind(console);
+ break;
+ case 'warn':
+ this.warn = console.warn.bind(console);
+ break;
+ case 'error':
+ this.error = console.error.bind(console);
+ break;
+ default:
+ console.error("Unknown debugging option '" + d + "' (supported: 'trace', 'debug', 'log', warn', 'error')");
+ break;
+ }
+ }
+ }
+ }
+ /* eslint-enable */
+
+ this.log('Initializing WebRTC Client library');
+
+ // Detect tab close
+ window.onbeforeunload = () => {
+ this.log('Closing window');
+ for (const s in this.sessions) {
+ if (this.sessions.hasOwnProperty(s)) {
+ if (this.sessions[s] && this.sessions[s].destroyOnUnload) {
+ this.log('Destroying session ' + s);
+ this.sessions[s].destroy();
+ }
+ }
+ }
+ };
+
+ this.initDone = true;
+ }
+
+ // Helper method to enumerate devices
+ listDevices(cb) {
+ const callback = (typeof cb == 'function') ? cb : this.noop;
+
+ if (navigator.mediaDevices) {
+ navigator.getUserMedia({audio: true, video: true}, (stream) => {
+ navigator.mediaDevices.enumerateDevices().then((devices) => {
+ this.debug(devices);
+ callback(devices);
+
+ // Get rid of the now useless stream
+ try {
+ stream.stop();
+ } catch (e) {
+ this.error(e);
+ }
+
+ this.stopMedia(stream);
+ });
+ }, (err) => {
+ this.error(err);
+ callback([]);
+ });
+ } else {
+ this.warn('navigator.mediaDevices unavailable');
+ callback([]);
+ }
+ }
+
+ // Helper method to check whether WebRTC is supported by this browser
+ isWebrtcSupported() {
+ return window.RTCPeerConnection && navigator.getUserMedia;
+ }
+
+ stopMedia(stream) {
+ const tracks = stream.getTracks();
+ tracks.forEach((track) => {
+ track.stop();
+ });
+ }
+} \ No newline at end of file
diff --git a/webapp/client/webrtc_session.jsx b/webapp/client/webrtc_session.jsx
new file mode 100644
index 000000000..1fccddff3
--- /dev/null
+++ b/webapp/client/webrtc_session.jsx
@@ -0,0 +1,1966 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import adapter from 'webrtc-adapter';
+import WebrtcClient from './webrtc_client.jsx';
+const transationLength = 12;
+
+export default class WebrtcSession {
+ static randomString(len) {
+ const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+ let randomString = '';
+ for (let i = 0; i < len; i++) {
+ const randomPoz = Math.floor(Math.random() * charSet.length);
+ randomString += charSet.substring(randomPoz, randomPoz + 1);
+ }
+ return randomString;
+ }
+
+ static getLocalMedia(constraints, element, callback) {
+ const media = constraints || {audio: true, video: true};
+ navigator.mediaDevices.getUserMedia(media).
+ then((stream) => {
+ if (element) {
+ adapter.browserShim.attachMediaStream(element, stream);
+ }
+
+ if (callback && typeof callback === 'function') {
+ callback(null, stream);
+ }
+ }).
+ catch((error) => {
+ callback(error);
+ });
+ }
+
+ static stopMediaStream(stream) {
+ const tracks = stream.getTracks();
+ tracks.forEach((track) => {
+ track.stop();
+ });
+ }
+
+ constructor(opts) {
+ // super();
+ const options = opts || {};
+ this.getServer = this.getServer.bind(this);
+ this.isConnected = this.isConnected.bind(this);
+ this.getSessionId = this.getSessionId.bind(this);
+ this.handleEvent = this.handleEvent.bind(this);
+ this.keepAlive = this.keepAlive.bind(this);
+ this.createSession = this.createSession.bind(this);
+ this.attach = this.attach.bind(this);
+ this.sendMessage = this.sendMessage.bind(this);
+ this.sendTrickleCandidate = this.sendTrickleCandidate.bind(this);
+ this.sendData = this.sendData.bind(this);
+ this.sendDtmf = this.sendDtmf.bind(this);
+ this.destroy = this.destroy.bind(this);
+ this.destroyHandle = this.destroyHandle.bind(this);
+ this.streamsDone = this.streamsDone.bind(this);
+ this.prepareWebrtc = this.prepareWebrtc.bind(this);
+ this.prepareWebrtcPeer = this.prepareWebrtcPeer.bind(this);
+ this.createOffer = this.createOffer.bind(this);
+ this.createAnswer = this.createAnswer.bind(this);
+ this.sendSDP = this.sendSDP.bind(this);
+ this.getVolume = this.getVolume.bind(this);
+ this.isMuted = this.isMuted.bind(this);
+ this.mute = this.mute.bind(this);
+ this.getBitrate = this.getBitrate.bind(this);
+ this.webrtcError = this.webrtcError.bind(this);
+ this.cleanupWebrtc = this.cleanupWebrtc.bind(this);
+ this.isAudioSendEnabled = this.isAudioSendEnabled.bind(this);
+ this.isAudioRecvEnabled = this.isAudioRecvEnabled.bind(this);
+ this.isVideoSendEnabled = this.isVideoSendEnabled.bind(this);
+ this.isVideoRecvEnabled = this.isVideoRecvEnabled.bind(this);
+ this.isDataEnabled = this.isDataEnabled.bind(this);
+ this.isTrickleEnabled = this.isTrickleEnabled.bind(this);
+ this.unbindWebSocket = this.unbindWebSocket.bind(this);
+
+ this.websockets = false;
+ this.ws = null;
+ this.wsHandlers = {};
+ this.wsKeepaliveTimeoutId = null;
+ this.servers = null;
+ this.server = null;
+ this.serversIndex = 0;
+ this.connected = false;
+ this.sessionId = null;
+ this.pluginHandles = {};
+ this.retries = 0;
+ this.transactions = {};
+
+ this.client = new WebrtcClient();
+ this.client.init({debug: options.debug});
+
+ this.gatewayCallbacks = options || {};
+ this.gatewayCallbacks.success = (typeof options.success == 'function') ? options.success : this.client.noop;
+ this.gatewayCallbacks.error = (typeof options.error == 'function') ? options.error : this.client.noop;
+ this.gatewayCallbacks.destroyed = (typeof options.destroyed == 'function') ? options.destroyed : this.client.noop;
+
+ if (!this.client.initDone) {
+ this.gatewayCallbacks.error('webrtc_client.not_initialize', 'Library not initialized');
+ return {};
+ }
+
+ if (!this.client.isWebrtcSupported()) {
+ this.gatewayCallbacks.error('webrtc_client.browser.not_supported', 'WebRTC not supported by this browser');
+ return {};
+ }
+
+ this.client.log('Library initialized: ' + this.client.initDone);
+
+ if (!options.server) {
+ this.gatewayCallbacks.error('webrtc_client.invalid_gateway', 'Invalid gateway url');
+ return {};
+ }
+
+ if (Array.isArray(options.server)) {
+ this.client.log('Multiple servers provided (' + options.server.length + '), will use the first that works');
+ for (let i = 0; i < options.server; i++) {
+ const server = options.server[i];
+ if (server.indexOf('ws') !== 0) {
+ this.gatewayCallbacks.error('webrtc_client.must_be_websocket', 'every server provided must be a websocket');
+ return {};
+ }
+ }
+ this.servers = options.server;
+ this.client.debug(this.servers);
+ } else if (options.server.indexOf('ws') === 0) {
+ this.websockets = true;
+ this.servers = [options.server];
+ this.client.log('Using WebSockets to contact Janus: ' + options.server);
+ } else {
+ this.gatewayCallbacks.error('webrtc_client.invalid_websocket', 'This library must connect to a websocket');
+ return {};
+ }
+
+ this.iceServers = options.iceServers;
+ if (!this.iceServers || this.iceServers.length === 0) {
+ this.iceServers = [{url: 'stun:stun.l.google.com:19302'}];
+ }
+
+ // Optional max events
+ this.maxev = null;
+ if (options.max_poll_events) {
+ this.maxev = options.max_poll_events;
+ }
+ if (this.maxev < 1) {
+ this.maxev = 1;
+ }
+
+ // Token to use (only if the token based authentication mechanism is enabled)
+ this.token = null;
+ if (options.token) {
+ this.token = options.token;
+ }
+
+ // API secret to use (only if the shared API secret is enabled)
+ this.apisecret = null;
+ if (options.apisecret) {
+ this.apisecret = options.apisecret;
+ }
+
+ // Whether we should destroy this session when onbeforeunload is called
+ this.destroyOnUnload = options.destroyOnUnload !== false;
+ this.createSession();
+
+ return this;
+ }
+
+ getServer() {
+ return this.server;
+ }
+
+ isConnected() {
+ return this.connected;
+ }
+
+ getSessionId() {
+ return this.sessionId;
+ }
+
+ handleEvent(json) {
+ this.retries = 0;
+ this.client.debug('Got event on session ' + this.sessionId);
+ this.client.debug(json);
+ const transaction = json.transaction;
+ const sender = json.sender;
+ const plugindata = json.plugindata;
+ const jsep = json.jsep;
+ switch (json.janus) {
+ case 'keepalive':
+ // Nothing happened
+ break;
+ case 'ack':
+ case 'success':
+ // Success or just an ack, we can probably ignore
+ if (transaction) {
+ const reportSuccess = this.transactions[transaction];
+ if (reportSuccess) {
+ reportSuccess(json);
+ }
+ Reflect.deleteProperty(this.transactions, transaction);
+ }
+ break;
+ case 'webrtcup':
+ // The PeerConnection with the gateway is up! Notify this
+ if (sender) {
+ const pluginHandle = this.pluginHandles[sender];
+ if (pluginHandle) {
+ pluginHandle.webrtcState(true);
+ } else {
+ this.client.warn('This handle is not attached to this session');
+ }
+ } else {
+ this.client.warn('Missing sender...');
+ }
+ break;
+ case 'hangup':
+ // A plugin asked the core to hangup a PeerConnection on one of our handles
+ if (sender) {
+ const pluginHandle = this.pluginHandles[sender];
+ if (pluginHandle) {
+ pluginHandle.webrtcState(false);
+ pluginHandle.hangup();
+ } else {
+ this.client.warn('This handle is not attached to this session');
+ }
+ } else {
+ this.client.warn('Missing sender...');
+ }
+ break;
+ case 'detached':
+ // A plugin asked the core to detach one of our handles
+ if (sender) {
+ const pluginHandle = this.pluginHandles[sender];
+ if (pluginHandle) {
+ pluginHandle.ondetached();
+ pluginHandle.detach();
+ } else {
+ this.client.warn('This handle is not attached to this session');
+ }
+ } else {
+ this.client.warn('Missing sender...');
+ }
+ break;
+ case 'media':
+ // Media started/stopped flowing
+ if (sender) {
+ const pluginHandle = this.pluginHandles[sender];
+ if (pluginHandle) {
+ pluginHandle.mediaState(json.type, json.receiving);
+ } else {
+ this.client.warn('This handle is not attached to this session');
+ }
+ } else {
+ this.client.warn('Missing sender...');
+ }
+ break;
+ case 'error':
+ // Oops, something wrong happened
+ this.client.error('Ooops: ' + json.error.code + ' ' + json.error.reason);
+ if (transaction) {
+ const reportSuccess = this.transactions[transaction];
+ if (reportSuccess) {
+ reportSuccess(json);
+ }
+ Reflect.deleteProperty(this.transactions, transaction);
+ }
+ break;
+ case 'event':
+ if (sender) {
+ if (plugindata) {
+ this.client.debug(` -- Event is coming from ${sender} ( ${plugindata.plugin} )`);
+ const data = plugindata.data;
+ this.client.debug(data);
+ const pluginHandle = this.pluginHandles[sender];
+ if (pluginHandle) {
+ pluginHandle.mediaState(json.type, json.receiving);
+ if (jsep) {
+ this.client.debug('Handling SDP as well...');
+ this.client.debug(jsep);
+ }
+ const callback = pluginHandle.onmessage;
+ if (callback) {
+ this.client.debug('Notifying application...');
+
+ // Send to callback specified when attaching plugin handle
+ callback(data, jsep);
+ } else {
+ // Send to generic callback (?)
+ this.client.debug('No provided notification callback');
+ }
+ } else {
+ this.client.warn('This handle is not attached to this session');
+ }
+ } else {
+ this.client.warn('Missing plugindata...');
+ }
+ } else {
+ this.client.warn('Missing sender...');
+ }
+ break;
+ default:
+ this.client.warn(`Unknown message "${json.janus}"`);
+ break;
+ }
+ }
+
+ keepAlive() {
+ if (this.server === null || !this.websockets || !this.connected) {
+ return;
+ }
+ this.wsKeepaliveTimeoutId = setTimeout(this.keepAlive, 30000);
+
+ const request = {
+ janus: 'keepalive',
+ session_id: this.sessionId,
+ transaction: WebrtcSession.randomString(transationLength)
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+
+ this.ws.send(JSON.stringify(request));
+ }
+
+ createSession() {
+ const transaction = WebrtcSession.randomString(transationLength);
+ const request = {
+ janus: 'create',
+ transaction
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+
+ if (this.server === null && Array.isArray(this.servers)) {
+ // We still need to find a working server from the list we were given
+ this.server = this.servers[this.serversIndex];
+ if (this.server.indexOf('ws') === 0) {
+ this.websockets = true;
+ this.client.log('Server #' + (this.serversIndex + 1) + ': trying WebSockets to contact Janus (' + this.server + ')');
+ }
+ }
+
+ if (this.websockets) {
+ this.ws = new WebSocket(this.server, 'janus-protocol');
+ this.wsHandlers = {
+ error: () => {
+ this.client.error('Error connecting to the Janus WebSockets server... ' + this.server);
+ if (Array.isArray(this.servers)) {
+ this.serversIndex++;
+ if (this.serversIndex === this.servers.length) {
+ // We tried all the servers the user gave us and they all failed
+ this.gatewayCallbacks.error('webrtc_client.cannot_connect_servers', 'Error connecting to any of the provided Janus servers: Is the gateway down?');
+ return;
+ }
+
+ // Let's try the next server
+ this.server = null;
+ setTimeout(() => {
+ this.createSession();
+ }, 200);
+ return;
+ }
+ this.gatewayCallbacks.error('webrtc_client.cannot_connect_server', 'Error connecting to the Janus WebSockets server: Is the gateway down?');
+ },
+
+ open: () => {
+ // We need to be notified about the success
+ this.transactions[transaction] = (json) => {
+ this.client.debug(json);
+ if (json.janus !== 'success') {
+ this.client.error('Ooops: ' + json.error.code + ' ' + json.error.reason); // FIXME
+ this.gatewayCallbacks.error(json.error.reason);
+ return;
+ }
+ this.wsKeepaliveTimeoutId = setTimeout(this.keepAlive, 30000);
+ this.connected = true;
+ this.sessionId = json.data.id;
+ this.client.log('Created session: ' + this.sessionId);
+ this.client.sessions[this.sessionId] = this;
+ this.gatewayCallbacks.success();
+ };
+ this.ws.send(JSON.stringify(request));
+ },
+
+ message: (event) => {
+ this.handleEvent(JSON.parse(event.data));
+ },
+
+ close: () => {
+ if (!this.connected) {
+ return;
+ }
+ this.connected = false;
+
+ // FIXME What if this is called when the page is closed?
+ this.gatewayCallbacks.error('Lost connection to the gateway (is it down?)');
+ }
+ };
+
+ for (var eventName in this.wsHandlers) {
+ if (this.wsHandlers.hasOwnProperty(eventName)) {
+ this.ws.addEventListener(eventName, this.wsHandlers[eventName]);
+ }
+ }
+ }
+ }
+
+ attach(cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+ callbacks.consentDialog = (typeof cbs.consentDialog == 'function') ? cbs.consentDialog : this.client.noop;
+ callbacks.mediaState = (typeof cbs.mediaState == 'function') ? cbs.mediaState : this.client.noop;
+ callbacks.webrtcState = (typeof cbs.webrtcState == 'function') ? cbs.webrtcState : this.client.noop;
+ callbacks.onmessage = (typeof cbs.onmessage == 'function') ? cbs.onmessage : this.client.noop;
+ callbacks.onlocalstream = (typeof cbs.onlocalstream == 'function') ? cbs.onlocalstream : this.client.noop;
+ callbacks.onremotestream = (typeof cbs.onremotestream == 'function') ? cbs.onremotestream : this.client.noop;
+ callbacks.ondata = (typeof cbs.ondata == 'function') ? cbs.ondata : this.client.noop;
+ callbacks.ondataopen = (typeof cbs.ondataopen == 'function') ? cbs.ondataopen : this.client.noop;
+ callbacks.oncleanup = (typeof cbs.oncleanup == 'function') ? cbs.oncleanup : this.client.noop;
+ callbacks.ondetached = (typeof cbs.ondetached == 'function') ? cbs.ondetached : this.client.noop;
+
+ if (!this.connected) {
+ this.client.warn('Is the gateway down? (connected=false)');
+ callbacks.error('Is the gateway down? (connected=false)');
+ return;
+ }
+
+ const plugin = callbacks.plugin;
+ if (!plugin) {
+ this.client.error('Invalid plugin');
+ callbacks.error('Invalid plugin');
+ return;
+ }
+
+ const transaction = WebrtcSession.randomString(transationLength);
+ const request = {
+ janus: 'attach',
+ plugin,
+ transaction
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+
+ if (this.websockets) {
+ this.transactions[transaction] = (json) => {
+ this.client.debug(json);
+
+ if (json.janus !== 'success') {
+ const error = `Ooops: ${json.error.code} ${json.error.reason}`;
+ this.client.error(error);
+ callbacks.error(error);
+ return;
+ }
+
+ const handleId = json.data.id;
+
+ this.client.log('Created handle: ' + handleId);
+ const pluginHandle = {
+ session: this,
+ plugin,
+ id: handleId,
+ webrtcStuff: {
+ started: false,
+ myStream: null,
+ streamExternal: false,
+ remoteStream: null,
+ mySdp: null,
+ pc: null,
+ dataChannel: null,
+ dtmfSender: null,
+ trickle: true,
+ iceDone: false,
+ sdpSent: false,
+ volume: {
+ value: null,
+ timer: null
+ },
+ bitrate: {
+ value: null,
+ bsnow: null,
+ bsbefore: null,
+ tsnow: null,
+ tsbefore: null,
+ timer: null
+ }
+ },
+ getId: () => {
+ return handleId;
+ },
+ getPlugin: () => {
+ return plugin;
+ },
+ getVolume: () => {
+ return this.getVolume(handleId);
+ },
+ isAudioMuted: () => {
+ return this.isMuted(handleId, false);
+ },
+ muteAudio: () => {
+ return this.mute(handleId, false, true);
+ },
+ unmuteAudio: () => {
+ return this.mute(handleId, false, false);
+ },
+ isVideoMuted: () => {
+ return this.isMuted(handleId, true);
+ },
+ muteVideo: () => {
+ return this.mute(handleId, true, true);
+ },
+ unmuteVideo: () => {
+ return this.mute(handleId, true, false);
+ },
+ getBitrate: () => {
+ return this.getBitrate(handleId);
+ },
+ send: (cb) => {
+ this.sendMessage(handleId, cb);
+ },
+ data: (cb) => {
+ this.sendData(handleId, cb);
+ },
+ dtmf: (cb) => {
+ this.sendDtmf(handleId, cb);
+ },
+ consentDialog: callbacks.consentDialog,
+ mediaState: callbacks.mediaState,
+ webrtcState: callbacks.webrtcState,
+ onmessage: callbacks.onmessage,
+ createOffer: (cb) => {
+ this.prepareWebrtc(handleId, cb);
+ },
+ createAnswer: (cb) => {
+ this.prepareWebrtc(handleId, cb);
+ },
+ handleRemoteJsep: (cb) => {
+ this.prepareWebrtcPeer(handleId, cb);
+ },
+ onlocalstream: callbacks.onlocalstream,
+ onremotestream: callbacks.onremotestream,
+ ondata: callbacks.ondata,
+ ondataopen: callbacks.ondataopen,
+ oncleanup: callbacks.oncleanup,
+ ondetached: callbacks.ondetached,
+ hangup: (sendRequest) => {
+ this.cleanupWebrtc(handleId, sendRequest === true);
+ },
+ detach: (cb) => {
+ this.destroyHandle(handleId, cb);
+ }
+ };
+ this.pluginHandles[handleId] = pluginHandle;
+ callbacks.success(pluginHandle);
+ };
+ request.session_id = this.sessionId;
+ this.ws.send(JSON.stringify(request));
+ }
+ }
+
+ sendMessage(handleId, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+
+ if (!this.connected) {
+ const error = 'Is the gateway down? (connected=false)';
+ this.client.warn(error);
+ callbacks.error(error);
+ return;
+ }
+
+ const message = callbacks.message;
+ const jsep = callbacks.jsep;
+ const transaction = WebrtcSession.randomString(transationLength);
+ const request = {
+ janus: 'message',
+ body: message,
+ transaction
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+
+ if (jsep) {
+ request.jsep = jsep;
+ }
+
+ this.client.debug('Sending message to plugin (handle=' + handleId + '):');
+ this.client.debug(request);
+
+ if (this.websockets) {
+ request.session_id = this.sessionId;
+ request.handle_id = handleId;
+ this.transactions[transaction] = (json) => {
+ this.client.debug('Message sent!');
+ this.client.debug(json);
+
+ if (json.janus === 'success') {
+ // We got a success, must have been a synchronous transaction
+ const plugindata = json.plugindata;
+ if (!plugindata) {
+ this.client.warn('Request succeeded, but missing plugindata...');
+ callbacks.success();
+ return;
+ }
+
+ this.client.log('Synchronous transaction successful (' + plugindata.plugin + ')');
+ const data = plugindata.data;
+ this.client.debug(data);
+ callbacks.success(data);
+ return;
+ } else if (json.janus !== 'ack') {
+ // Not a success and not an ack, must be an error
+ if (json.error) {
+ this.client.error('Ooops: ' + json.error.code + ' ' + json.error.reason);
+ callbacks.error(json.error.code + ' ' + json.error.reason);
+ } else {
+ this.client.error('Unknown error');
+ callbacks.error('Unknown error');
+ }
+ return;
+ }
+
+ // If we got here, the plugin decided to handle the request asynchronously
+ callbacks.success();
+ };
+ this.ws.send(JSON.stringify(request));
+ }
+ }
+
+ sendTrickleCandidate(handleId, candidate) {
+ if (!this.connected) {
+ this.client.warn('Is the gateway down? (connected=false)');
+ return;
+ }
+ var request = {
+ janus: 'trickle',
+ candidate,
+ transaction: WebrtcSession.randomString(transationLength)
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+ this.client.debug('Sending trickle candidate (handle=' + handleId + '):');
+ this.client.debug(request);
+
+ if (this.websockets) {
+ request.session_id = this.sessionId;
+ request.handle_id = handleId;
+ this.ws.send(JSON.stringify(request));
+ }
+ }
+
+ sendData(handleId, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ const text = callbacks.text;
+ if (!text) {
+ this.client.warn('Invalid text');
+ callbacks.error('Invalid text');
+ return;
+ }
+ this.client.log('Sending string on data channel: ' + text);
+ config.dataChannel.send(text);
+ callbacks.success();
+ }
+
+ sendDtmf(handleId, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ if (!config.dtmfSender) {
+ // Create the DTMF sender, if possible
+ if (config.myStream) {
+ const tracks = config.myStream.getAudioTracks();
+ if (tracks && tracks.length > 0) {
+ const localAudioTrack = tracks[0];
+ config.dtmfSender = config.pc.createDTMFSender(localAudioTrack);
+ this.client.log('Created DTMF Sender');
+ config.dtmfSender.ontonechange = (tone) => {
+ this.client.debug('Sent DTMF tone: ' + tone.tone);
+ };
+ }
+ }
+ if (!config.dtmfSender) {
+ this.client.warn('Invalid DTMF configuration');
+ callbacks.error('Invalid DTMF configuration');
+ return;
+ }
+ }
+
+ const dtmf = callbacks.dtmf;
+ if (!dtmf) {
+ this.client.warn('Invalid DTMF parameters');
+ callbacks.error('Invalid DTMF parameters');
+ return;
+ }
+
+ const tones = dtmf.tones;
+ if (!tones) {
+ this.client.warn('Invalid DTMF string');
+ callbacks.error('Invalid DTMF string');
+ return;
+ }
+
+ let duration = dtmf.duration;
+ if (!duration) {
+ duration = 500; // We choose 500ms as the default duration for a tone
+ }
+
+ let gap = dtmf.gap;
+ if (!gap) {
+ gap = 50; // We choose 50ms as the default gap between tones
+ }
+
+ this.client.debug('Sending DTMF string ' + tones + ' (duration ' + duration + 'ms, gap ' + gap + 'ms');
+ config.dtmfSender.insertDTMF(tones, duration, gap);
+ }
+
+ destroy(sync) {
+ const syncRequest = (sync === true);
+ this.client.log('Destroying session ' + this.sessionId);
+
+ if (!this.connected) {
+ this.client.warn('Is the gateway down? (connected=false)');
+ return;
+ }
+
+ if (!this.sessionId) {
+ this.client.warn('No session to destroy');
+ this.gatewayCallbacks.destroyed();
+ return;
+ }
+
+ Reflect.deleteProperty(this.client.sessions, this.sessionId);
+
+ // Destroy all handles first
+ for (const ph in this.pluginHandles) {
+ if (this.pluginHandles.hasOwnProperty(ph)) {
+ const phv = this.pluginHandles[ph];
+ this.client.log('Destroying handle ' + phv.id + ' (' + phv.plugin + ')');
+ this.destroyHandle(phv.id, null, syncRequest);
+ }
+ }
+
+ // Ok, go on
+ var request = {janus: 'destroy', transaction: WebrtcSession.randomString(transationLength)};
+
+ if (this.token) {
+ request.token = this.token;
+ }
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+ if (this.websockets) {
+ request.session_id = this.sessionId;
+
+ let onUnbindMessage = null;
+ let onUnbindError = null;
+ onUnbindMessage = (event) => {
+ var data = JSON.parse(event.data);
+ if (data.session_id === request.session_id && data.transaction === request.transaction) {
+ this.unbindWebSocket(onUnbindMessage, onUnbindError);
+ this.gatewayCallbacks.destroyed();
+ }
+ };
+ onUnbindError = () => {
+ this.unbindWebSocket(onUnbindMessage, onUnbindError);
+ this.gatewayCallbacks.destroyed();
+ };
+
+ this.ws.addEventListener('message', onUnbindMessage);
+ this.ws.addEventListener('error', onUnbindError);
+
+ this.ws.send(JSON.stringify(request));
+ }
+ }
+
+ disconnect() {
+ this.connected = false;
+ this.ws.close();
+ }
+
+ destroyHandle(handleId, cbs, sync) {
+ const syncRequest = (sync === true);
+ this.client.log('Destroying handle ' + handleId + ' (sync=' + syncRequest + ')');
+ const callbacks = cbs || {};
+ callbacks.success = (typeof callbacks.success == 'function') ? callbacks.success : this.client.noop;
+ callbacks.error = (typeof callbacks.error == 'function') ? callbacks.error : this.client.noop;
+ this.cleanupWebrtc(handleId);
+ if (!this.connected) {
+ this.client.warn('Is the gateway down? (connected=false)');
+ return;
+ }
+ const request = {
+ janus: 'detach',
+ transaction: WebrtcSession.randomString(transationLength)
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+
+ if (this.websockets) {
+ request.session_id = this.sessionId;
+ request.handle_id = handleId;
+ this.ws.send(JSON.stringify(request));
+
+ Reflect.deleteProperty(this.pluginHandles, handleId);
+
+ callbacks.success();
+ }
+ }
+
+ streamsDone(handleId, jsep, media, callbacks, stream) {
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ this.client.debug('streamsDone:', stream);
+ config.myStream = stream;
+
+ const pcConfig = {iceServers: this.iceServers};
+ const pcConstraints = {
+ optional: [{DtlsSrtpKeyAgreement: true}]
+ };
+
+ this.client.log('Creating PeerConnection');
+ this.client.debug(pcConstraints);
+ config.pc = new window.RTCPeerConnection(pcConfig, pcConstraints);
+ this.client.debug(config.pc);
+ if (config.pc.getStats) { // FIXME
+ config.volume.value = 0;
+ config.bitrate.value = '0 kbits/sec';
+ }
+ this.client.log('Preparing local SDP and gathering candidates (trickle=' + config.trickle + ')');
+
+ config.pc.onicecandidate = (event) => {
+ if (!event.candidate || (adapter.browserDetails.browser === 'edge' && event.candidate.candidate.indexOf('endOfCandidates') > 0)) {
+ this.client.log('End of candidates.');
+ config.iceDone = true;
+ if (config.trickle === true) {
+ // Notify end of candidates
+ this.sendTrickleCandidate(handleId, {completed: true});
+ } else {
+ // No trickle, time to send the complete SDP (including all candidates)
+ this.sendSDP(handleId, callbacks);
+ }
+ } else {
+ // JSON.stringify doesn't work on some WebRTC objects anymore
+ // See https://code.google.com/p/chromium/issues/detail?id=467366
+ const candidate = {
+ candidate: event.candidate.candidate,
+ sdpMid: event.candidate.sdpMid,
+ sdpMLineIndex: event.candidate.sdpMLineIndex
+ };
+
+ if (config.trickle === true) {
+ // Send candidate
+ this.sendTrickleCandidate(handleId, candidate);
+ }
+ }
+ };
+
+ if (stream) {
+ this.client.log('Adding local stream');
+ config.pc.addStream(stream);
+ pluginHandle.onlocalstream(stream);
+ }
+
+ config.pc.onaddstream = (remoteStream) => {
+ this.client.log('Handling Remote Stream');
+ this.client.debug(remoteStream);
+ config.remoteStream = remoteStream;
+ pluginHandle.onremotestream(remoteStream.stream);
+ };
+
+ // Any data channel to create?
+ if (this.isDataEnabled(media)) {
+ this.client.log('Creating data channel');
+ const onDataChannelMessage = (event) => {
+ this.client.log('Received message on data channel: ' + event.data);
+ pluginHandle.ondata(event.data); // FIXME
+ };
+
+ const onDataChannelStateChange = () => {
+ const dcState = config.dataChannel ? config.dataChannel.readyState : 'null';
+ this.client.log('State change on data channel: ' + dcState);
+ if (dcState === 'open') {
+ pluginHandle.ondataopen(); // FIXME
+ }
+ };
+
+ const onDataChannelError = (error) => {
+ this.client.error('Got error on data channel:', error);
+
+ // TODO
+ };
+
+ // Until we implement the proxying of open requests within the this.client core, we open a channel ourselves whatever the case
+ config.dataChannel = config.pc.createDataChannel('this.clientDataChannel', {ordered: false}); // FIXME Add options (ordered, maxRetransmits, etc.)
+ config.dataChannel.onmessage = onDataChannelMessage;
+ config.dataChannel.onopen = onDataChannelStateChange;
+ config.dataChannel.onclose = onDataChannelStateChange;
+ config.dataChannel.onerror = onDataChannelError;
+ }
+
+ // Create offer/answer now DO I WANT THIS??
+ if (jsep) {
+ config.pc.setRemoteDescription(
+ new RTCSessionDescription(jsep),
+ () => {
+ this.client.log('Remote description accepted!');
+ this.createAnswer(handleId, media, callbacks);
+ }, callbacks.error);
+ } else {
+ this.createOffer(handleId, media, callbacks);
+ }
+ }
+
+ prepareWebrtc(handleId, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.webrtcError;
+ const jsep = callbacks.jsep;
+ const media = callbacks.media;
+ const pluginHandle = this.pluginHandles[handleId];
+
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+
+ // Are we updating a session?
+ if (config.pc) {
+ this.client.log('Updating existing media session');
+
+ // Create offer/answer now
+ if (jsep) {
+ config.pc.setRemoteDescription(
+ new window.RTCSessionDescription(jsep),
+ () => {
+ this.client.log('Remote description accepted!');
+ this.createAnswer(handleId, media, callbacks);
+ }, callbacks.error);
+ } else {
+ this.createOffer(handleId, media, callbacks);
+ }
+ return;
+ }
+
+ // Was a MediaStream object passed, or do we need to take care of that?
+ if (callbacks.stream) {
+ const stream = callbacks.stream;
+ this.client.log('MediaStream provided by the application');
+ this.client.debug(stream);
+
+ // Skip the getUserMedia part
+ config.streamExternal = true;
+ this.streamsDone(handleId, jsep, media, callbacks, stream);
+ return;
+ }
+
+ config.trickle = this.isTrickleEnabled(callbacks.trickle);
+ if (this.isAudioSendEnabled(media) || this.isVideoSendEnabled(media)) {
+ let constraints = {mandatory: {}, optional: []};
+ pluginHandle.consentDialog(true);
+
+ let audioSupport = this.isAudioSendEnabled(media);
+ if (audioSupport === true && media) {
+ if (typeof media.audio === 'object') {
+ audioSupport = media.audio;
+ }
+ }
+
+ let videoSupport = this.isVideoSendEnabled(media);
+ if (videoSupport === true && media) {
+ if (media.video && media.video !== 'screen' && media.video !== 'window') {
+ let width = 0;
+ let height = 0;
+ let maxHeight = 0;
+
+ if (media.video === 'lowres') {
+ // Small resolution, 4:3
+ height = 240;
+ maxHeight = 240;
+ width = 320;
+ } else if (media.video === 'lowres-16:9') {
+ // Small resolution, 16:9
+ height = 180;
+ maxHeight = 180;
+ width = 320;
+ } else if (media.video === 'hires' || media.video === 'hires-16:9') {
+ // High resolution is only 16:9
+ height = 720;
+ maxHeight = 720;
+ width = 1280;
+ if (navigator.mozGetUserMedia) {
+ const firefoxVer = parseInt(window.navigator.userAgent.match(/Firefox\/(.*)/)[1], 10);
+ if (firefoxVer < 38) {
+ // Unless this is and old Firefox, which doesn't support it
+ this.client.warn(media.video + ' unsupported, falling back to stdres (old Firefox)');
+ height = 480;
+ maxHeight = 480;
+ width = 640;
+ }
+ }
+ } else if (media.video === 'stdres') {
+ // Normal resolution, 4:3
+ height = 480;
+ maxHeight = 480;
+ width = 640;
+ } else if (media.video === 'stdres-16:9') {
+ // Normal resolution, 16:9
+ height = 360;
+ maxHeight = 360;
+ width = 640;
+ } else {
+ this.client.log('Default video setting (' + media.video + ') is stdres 4:3');
+ height = 480;
+ maxHeight = 480;
+ width = 640;
+ }
+
+ this.client.log('Adding media constraint ' + media.video);
+
+ if (navigator.mozGetUserMedia) {
+ const firefoxVer = parseInt(window.navigator.userAgent.match(/Firefox\/(.*)/)[1], 10);
+ if (firefoxVer < 38) {
+ videoSupport = {
+ require: ['height', 'width'],
+ height: {max: maxHeight, min: height},
+ width: {max: width, min: width}
+ };
+ } else {
+ // http://stackoverflow.com/questions/28282385/webrtc-firefox-constraints/28911694#28911694
+ // https://github.com/meetecho/janus-gateway/pull/246
+ videoSupport = {
+ height: {ideal: height},
+ width: {ideal: width}
+ };
+ }
+ } else {
+ videoSupport = {
+ mandatory: {
+ maxHeight,
+ minHeight: height,
+ maxWidth: width,
+ minWidth: width
+ },
+ optional: []
+ };
+ }
+
+ if (typeof media.video === 'object') {
+ videoSupport = media.video;
+ }
+
+ this.client.debug(videoSupport);
+ } else if (media.video === 'screen' || media.video === 'window') {
+ // Not a webcam, but screen capture
+ if (window.location.protocol !== 'https:') {
+ // Screen sharing mandates HTTPS
+ this.client.warn('Screen sharing only works on HTTPS, try the https:// version of this page');
+ pluginHandle.consentDialog(false);
+ callbacks.error('Screen sharing only works on HTTPS, try the https:// version of this page');
+ return;
+ }
+
+ // We're going to try and use the extension for Chrome 34+, the old approach
+ // for older versions of Chrome, or the experimental support in Firefox 33+
+ const cache = {};
+ const self = this;
+
+ function callbackUserMedia(error, stream) {
+ pluginHandle.consentDialog(false);
+ if (error) {
+ callbacks.error({code: error.code, name: error.name, message: error.message});
+ } else {
+ self.streamsDone(handleId, jsep, media, callbacks, stream);
+ }
+ }
+
+ function getScreenMedia(constraint, gsmCallback) {
+ this.client.log('Adding media constraint (screen capture)');
+ this.client.debug(constraint);
+ navigator.mediaDevices.getUserMedia(constraint).
+ then((stream) => {
+ gsmCallback(null, stream);
+ }).
+ catch((error) => {
+ pluginHandle.consentDialog(false);
+ gsmCallback(error);
+ });
+ }
+
+ if (window.navigator.userAgent.match('Chrome')) {
+ const chromever = parseInt(window.navigator.userAgent.match(/Chrome\/(.*) /)[1], 10);
+ let maxver = 33;
+
+ if (window.navigator.userAgent.match('Linux')) {
+ maxver = 35; // 'known' crash in chrome 34 and 35 on linux
+ }
+
+ if (chromever >= 26 && chromever <= maxver) {
+ // Chrome 26->33 requires some awkward chrome://flags manipulation
+ constraints = {
+ video: {
+ mandatory: {
+ googLeakyBucket: true,
+ maxWidth: window.screen.width,
+ maxHeight: window.screen.height,
+ maxFrameRate: 3,
+ chromeMediaSource: 'screen'
+ }
+ },
+ audio: this.isAudioSendEnabled(media)
+ };
+ getScreenMedia(constraints, callbackUserMedia);
+ } else {
+ // Chrome 34+ requires an extension
+ var pending = window.setTimeout(
+ () => {
+ const error = new Error('NavigatorUserMediaError');
+ error.name = 'The required Chrome extension is not installed: click <a href="#">here</a> to install it. (NOTE: this will need you to refresh the page)';
+ pluginHandle.consentDialog(false);
+ return callbacks.error(error);
+ }, 1000);
+ cache[pending] = [callbackUserMedia, null];
+ window.postMessage({
+ type: 'janusGetScreen',
+ id: pending
+ }, '*');
+ }
+ } else if (window.navigator.userAgent.match('Firefox')) {
+ const ffver = parseInt(window.navigator.userAgent.match(/Firefox\/(.*)/)[1], 10);
+ if (ffver >= 33) {
+ // Firefox 33+ has experimental support for screen sharing
+ constraints = {
+ video: {
+ mozMediaSource: media.video,
+ mediaSource: media.video
+ },
+ audio: this.isAudioSendEnabled(media)
+ };
+ getScreenMedia(constraints, (err, stream) => {
+ callbackUserMedia(err, stream);
+
+ // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1045810
+ if (!err) {
+ let lastTime = stream.currentTime;
+ const polly = window.setInterval(() => {
+ if (!stream) {
+ window.clearInterval(polly);
+ }
+
+ if (stream.currentTime === lastTime) {
+ window.clearInterval(polly);
+ if (stream.onended) {
+ stream.onended();
+ }
+ }
+
+ lastTime = stream.currentTime;
+ }, 500);
+ }
+ });
+ } else {
+ const error = new Error('NavigatorUserMediaError');
+ error.name = 'Your version of Firefox does not support screen sharing, please install Firefox 33 (or more recent versions)';
+ pluginHandle.consentDialog(false);
+ callbacks.error(error);
+ return;
+ }
+ }
+
+ // Wait for events from the Chrome Extension
+ window.addEventListener('message', (event) => {
+ if (event.origin !== window.location.origin) {
+ return;
+ }
+ if (event.data.type === 'mattermostGotScreen' && cache[event.data.id]) {
+ const data = cache[event.data.id];
+ const callback = data[0];
+
+ Reflect.deleteProperty(cache, event.data.id);
+
+ if (event.data.sourceId === '') {
+ // user canceled
+ const error = new Error('NavigatorUserMediaError');
+ error.name = 'You cancelled the request for permission, giving up...';
+ pluginHandle.consentDialog(false);
+ callbacks.error(error);
+ } else {
+ constraints = {
+ audio: this.isAudioSendEnabled(media),
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ maxWidth: window.screen.width,
+ maxHeight: window.screen.height,
+ maxFrameRate: 3
+ },
+ optional: [
+ {googLeakyBucket: true},
+ {googTemporalLayeredScreencast: true}
+ ]
+ }
+ };
+ constraints.video.mandatory.chromeMediaSourceId = event.data.sourceId;
+ getScreenMedia(constraints, callback);
+ }
+ } else if (event.data.type === 'mattermostGetScreenPending') {
+ window.clearTimeout(event.data.id);
+ }
+ });
+ return;
+ }
+ }
+
+ // If we got here, we're not screensharing
+ if (!media || media.video !== 'screen') {
+ // Check whether all media sources are actually available or not
+ navigator.mediaDevices.enumerateDevices().then((devices) => {
+ const audioExist = devices.some((device) => {
+ return device.kind === 'audioinput';
+ });
+
+ const videoExist = devices.some((device) => {
+ return device.kind === 'videoinput';
+ });
+
+ // Check whether a missing device is really a problem
+ const audioSend = this.isAudioSendEnabled(media);
+ const videoSend = this.isVideoSendEnabled(media);
+
+ if (audioSend || videoSend) {
+ // We need to send either audio or video
+ const haveAudioDevice = audioSend ? audioExist : false;
+ const haveVideoDevice = videoSend ? videoExist : false;
+
+ if (!haveAudioDevice && !haveVideoDevice) {
+ // FIXME Should we really give up, or just assume recvonly for both?
+ pluginHandle.consentDialog(false);
+ callbacks.error('No capture device found');
+ return false;
+ }
+ }
+
+ navigator.mediaDevices.getUserMedia({
+ audio: audioExist ? audioSupport : false,
+ video: videoExist ? videoSupport : false
+ }).
+ then((stream) => {
+ pluginHandle.consentDialog(false);
+ this.streamsDone(handleId, jsep, media, callbacks, stream);
+ }).
+ catch((error) => {
+ pluginHandle.consentDialog(false);
+ callbacks.error({
+ code: error.code,
+ name: error.name,
+ message: error.message
+ });
+ });
+
+ return true;
+ }).
+ catch((error) => {
+ pluginHandle.consentDialog(false);
+ callbacks.error('enumerateDevices error', error);
+ });
+ }
+ } else {
+ // No need to do a getUserMedia, create offer/answer right away
+ this.streamsDone(handleId, jsep, media, callbacks);
+ }
+ }
+
+ prepareWebrtcPeer(handleId, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.webrtcError;
+
+ const jsep = callbacks.jsep;
+ const pluginHandle = this.pluginHandles[handleId];
+
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+
+ if (jsep) {
+ if (config.pc === null) {
+ this.client.warn('Wait, no PeerConnection?? if this is an answer, use createAnswer and not handleRemoteJsep');
+ callbacks.error('No PeerConnection: if this is an answer, use createAnswer and not handleRemoteJsep');
+ return;
+ }
+ config.pc.setRemoteDescription(
+ new window.RTCSessionDescription(jsep),
+ () => {
+ this.client.log('Remote description accepted!');
+ callbacks.success();
+ }, callbacks.error);
+ } else {
+ callbacks.error('Invalid JSEP');
+ }
+ }
+
+ createOffer(handleId, media, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ this.client.log('Creating offer (iceDone=' + config.iceDone + ')');
+
+ // https://code.google.com/p/webrtc/issues/detail?id=3508
+ let mediaConstraints = null;
+ const browser = adapter.browserDetails.browser;
+ if (browser === 'firefox' || browser === 'edge') {
+ mediaConstraints = {
+ offerToReceiveAudio: this.isAudioRecvEnabled(media),
+ offerToReceiveVideo: this.isVideoRecvEnabled(media)
+ };
+ } else {
+ mediaConstraints = {
+ mandatory: {
+ OfferToReceiveAudio: this.isAudioRecvEnabled(media),
+ OfferToReceiveVideo: this.isVideoRecvEnabled(media)
+ }
+ };
+ }
+
+ this.client.debug(mediaConstraints);
+ config.pc.createOffer(
+ (offer) => {
+ this.client.debug(offer);
+
+ if (!config.mySdp) {
+ this.client.log('Setting local description');
+ config.mySdp = offer.sdp;
+ config.pc.setLocalDescription(offer);
+ }
+
+ if (!config.iceDone && !config.trickle) {
+ // Don't do anything until we have all candidates
+ this.client.log('Waiting for all candidates...');
+ return;
+ }
+
+ if (config.sdpSent) {
+ this.client.log('Offer already sent, not sending it again');
+ return;
+ }
+
+ this.client.log('Offer ready');
+ this.client.debug(callbacks);
+ config.sdpSent = true;
+
+ // JSON.stringify doesn't work on some WebRTC objects anymore
+ // See https://code.google.com/p/chromium/issues/detail?id=467366
+ const jsep = {
+ type: offer.type,
+ sdp: offer.sdp
+ };
+ callbacks.success(jsep);
+ }, callbacks.error, mediaConstraints);
+ }
+
+ createAnswer(handleId, media, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ callbacks.error('Invalid handle');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ this.client.log('Creating answer (iceDone=' + config.iceDone + ')');
+
+ let mediaConstraints = null;
+ const browser = adapter.browserDetails.browser;
+ if (browser === 'firefox' || browser === 'edge') {
+ mediaConstraints = {
+ offerToReceiveAudio: this.isAudioRecvEnabled(media),
+ offerToReceiveVideo: this.isVideoRecvEnabled(media)
+ };
+ } else {
+ mediaConstraints = {
+ mandatory: {
+ OfferToReceiveAudio: this.isAudioRecvEnabled(media),
+ OfferToReceiveVideo: this.isVideoRecvEnabled(media)
+ }
+ };
+ }
+ this.client.debug(mediaConstraints);
+ config.pc.createAnswer(
+ (answer) => {
+ this.client.debug(answer);
+ if (!config.mySdp) {
+ this.client.log('Setting local description');
+ config.mySdp = answer.sdp;
+ config.pc.setLocalDescription(answer);
+ }
+ if (!config.iceDone && !config.trickle) {
+ // Don't do anything until we have all candidates
+ this.client.log('Waiting for all candidates...');
+ return;
+ }
+ if (config.sdpSent) { // FIXME badly
+ this.client.log('Answer already sent, not sending it again');
+ return;
+ }
+ config.sdpSent = true;
+
+ // JSON.stringify doesn't work on some WebRTC objects anymore
+ // See https://code.google.com/p/chromium/issues/detail?id=467366
+ const jsep = {
+ type: answer.type,
+ sdp: answer.sdp
+ };
+ callbacks.success(jsep);
+ }, callbacks.error, mediaConstraints);
+ }
+
+ sendSDP(handleId, cbs) {
+ const callbacks = cbs || {};
+ callbacks.success = (typeof cbs.success == 'function') ? cbs.success : this.client.noop;
+ callbacks.error = (typeof cbs.error == 'function') ? cbs.error : this.client.noop;
+
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle, not sending anything');
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ this.client.log('Sending offer/answer SDP...');
+ if (!config.mySdp) {
+ this.client.warn('Local SDP instance is invalid, not sending anything...');
+ return;
+ }
+
+ config.mySdp = {
+ type: config.pc.localDescription.type,
+ sdp: config.pc.localDescription.sdp
+ };
+
+ if (config.sdpSent) {
+ this.client.log('Offer/Answer SDP already sent, not sending it again');
+ return;
+ }
+
+ if (config.trickle === false) {
+ config.mySdp.trickle = false;
+ }
+ this.client.debug(callbacks);
+ config.sdpSent = true;
+ callbacks.success(config.mySdp);
+ }
+
+ getVolume(handleId) {
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ return 0;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ const browser = adapter.browserDetails.browser;
+
+ // Start getting the volume, if getStats is supported
+ if (config.pc.getStats && browser === 'chrome') { // FIXME
+ if (!config.remoteStream) {
+ this.client.warn('Remote stream unavailable');
+ return 0;
+ }
+
+ // http://webrtc.googlecode.com/svn/trunk/samples/js/demos/html/constraints-and-stats.html
+ if (!config.volume.timer) {
+ this.client.log('Starting volume monitor');
+ config.volume.timer = setInterval(() => {
+ config.pc.getStats((stats) => {
+ const results = stats.result();
+ for (let i = 0; i < results.length; i++) {
+ const res = results[i];
+ if (res.type === 'ssrc' && res.stat('audioOutputLevel')) {
+ config.volume.value = res.stat('audioOutputLevel');
+ }
+ }
+ });
+ }, 200);
+ return 0; // We don't have a volume to return yet
+ }
+ return config.volume.value;
+ }
+
+ this.client.log('Getting the remote volume unsupported by browser');
+ return 0;
+ }
+
+ isMuted(handleId, video) {
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ return true;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+
+ if (!config.pc) {
+ this.client.warn('Invalid PeerConnection');
+ return true;
+ }
+
+ if (!config.myStream) {
+ this.client.warn('Invalid local MediaStream');
+ return true;
+ }
+
+ if (video) {
+ // Check video track
+ if (!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
+ this.client.warn('No video track');
+ return true;
+ }
+ return !config.myStream.getVideoTracks()[0].enabled;
+ }
+
+ // Check audio track
+ if (!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
+ this.client.warn('No audio track');
+ return true;
+ }
+ return !config.myStream.getAudioTracks()[0].enabled;
+ }
+
+ mute(handleId, video, mute) {
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ return false;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ if (!config.pc) {
+ this.client.warn('Invalid PeerConnection');
+ return false;
+ }
+
+ if (!config.myStream) {
+ this.client.warn('Invalid local MediaStream');
+ return false;
+ }
+
+ if (video) {
+ // Mute/unmute video track
+ if (!config.myStream.getVideoTracks() || config.myStream.getVideoTracks().length === 0) {
+ this.client.warn('No video track');
+ return false;
+ }
+ config.myStream.getVideoTracks()[0].enabled = mute;
+ return true;
+ }
+
+ // Mute/unmute audio track
+ if (!config.myStream.getAudioTracks() || config.myStream.getAudioTracks().length === 0) {
+ this.client.warn('No audio track');
+ return false;
+ }
+ config.myStream.getAudioTracks()[0].enabled = mute;
+ return true;
+ }
+
+ getBitrate(handleId) {
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle || !pluginHandle.webrtcStuff) {
+ this.client.warn('Invalid handle');
+ return 'Invalid handle';
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ if (!config.pc) {
+ return 'Invalid PeerConnection';
+ }
+
+ // Start getting the bitrate, if getStats is supported
+ const browser = adapter.browserDetails.browser;
+ if (config.pc.getStats && browser === 'chrome') {
+ // Do it the Chrome way
+ if (!config.remoteStream) {
+ this.client.warn('Remote stream unavailable');
+ return 'Remote stream unavailable';
+ }
+
+ // http://webrtc.googlecode.com/svn/trunk/samples/js/demos/html/constraints-and-stats.html
+ if (!config.bitrate.timer) {
+ this.client.log('Starting bitrate timer (Chrome)');
+ config.bitrate.timer = setInterval(() => {
+ config.pc.getStats((stats) => {
+ const results = stats.result();
+ for (let i = 0; i < results.length; i++) {
+ const res = results[i];
+ if (res.type === 'ssrc' && res.stat('googFrameHeightReceived')) {
+ config.bitrate.bsnow = res.stat('bytesReceived');
+ config.bitrate.tsnow = res.timestamp;
+ if (config.bitrate.bsbefore === null || config.bitrate.tsbefore === null) {
+ // Skip this round
+ config.bitrate.bsbefore = config.bitrate.bsnow;
+ config.bitrate.tsbefore = config.bitrate.tsnow;
+ } else {
+ // Calculate bitrate
+ var bitRate = Math.round(((config.bitrate.bsnow - config.bitrate.bsbefore) * 8) / (config.bitrate.tsnow - config.bitrate.tsbefore));
+ config.bitrate.value = bitRate + ' kbits/sec';
+
+ //~ this.client.log('Estimated bitrate is ' + config.bitrate.value);
+ config.bitrate.bsbefore = config.bitrate.bsnow;
+ config.bitrate.tsbefore = config.bitrate.tsnow;
+ }
+ }
+ }
+ });
+ }, 1000);
+ return '0 kbits/sec'; // We don't have a bitrate value yet
+ }
+ return config.bitrate.value;
+ } else if (config.pc.getStats && browser === 'firefox') {
+ // Do it the Firefox way
+ if (!config.remoteStream || !config.remoteStream.stream) {
+ this.client.warn('Remote stream unavailable');
+ return 'Remote stream unavailable';
+ }
+
+ const videoTracks = config.remoteStream.stream.getVideoTracks();
+ if (!videoTracks || videoTracks.length < 1) {
+ this.client.warn('No video track');
+ return 'No video track';
+ }
+
+ // https://github.com/muaz-khan/getStats/blob/master/getStats.js
+ if (!config.bitrate.timer) {
+ this.client.log('Starting bitrate timer (Firefox)');
+ config.bitrate.timer = setInterval(() => {
+ // We need a helper callback
+ function cb(res) {
+ if (!res || res.inbound_rtp_video_1 == null || res.inbound_rtp_video_1 == null) {
+ config.bitrate.value = 'Missing inbound_rtp_video_1';
+ return;
+ }
+
+ config.bitrate.bsnow = res.inbound_rtp_video_1.bytesReceived;
+ config.bitrate.tsnow = res.inbound_rtp_video_1.timestamp;
+
+ if (config.bitrate.bsbefore === null || config.bitrate.tsbefore === null) {
+ // Skip this round
+ config.bitrate.bsbefore = config.bitrate.bsnow;
+ config.bitrate.tsbefore = config.bitrate.tsnow;
+ } else {
+ // Calculate bitrate
+ var bitRate = Math.round(((config.bitrate.bsnow - config.bitrate.bsbefore) * 8) / (config.bitrate.tsnow - config.bitrate.tsbefore));
+ config.bitrate.value = bitRate + ' kbits/sec';
+ config.bitrate.bsbefore = config.bitrate.bsnow;
+ config.bitrate.tsbefore = config.bitrate.tsnow;
+ }
+ }
+
+ // Actually get the stats
+ config.pc.getStats(videoTracks[0], (stats) => {
+ cb(stats);
+ }, cb);
+ }, 1000);
+ return '0 kbits/sec'; // We don't have a bitrate value yet
+ }
+ return config.bitrate.value;
+ }
+
+ this.client.warn('Getting the video bitrate unsupported by browser');
+ return 'Feature unsupported by browser';
+ }
+
+ webrtcError(error) {
+ this.client.error('WebRTC error:', error);
+ }
+
+ cleanupWebrtc(handleId, hangupRequest) {
+ this.client.log('Cleaning WebRTC stuff');
+ const pluginHandle = this.pluginHandles[handleId];
+ if (!pluginHandle) {
+ // Nothing to clean
+ return;
+ }
+
+ const config = pluginHandle.webrtcStuff;
+ if (config) {
+ if (hangupRequest === true) {
+ // Send a hangup request (we don't really care about the response)
+ const request = {
+ janus: 'hangup',
+ transaction: WebrtcSession.randomString(transationLength)
+ };
+
+ if (this.token) {
+ request.token = this.token;
+ }
+
+ if (this.apisecret) {
+ request.apisecret = this.apisecret;
+ }
+
+ this.client.debug('Sending hangup request (handle=' + handleId + '):');
+ this.client.debug(request);
+ if (this.websockets) {
+ request.session_id = this.sessionId;
+ request.handle_id = handleId;
+ this.ws.send(JSON.stringify(request));
+ }
+ }
+
+ // Cleanup stack
+ config.remoteStream = null;
+ if (config.volume.timer) {
+ clearInterval(config.volume.timer);
+ }
+
+ config.volume.value = null;
+ if (config.bitrate.timer) {
+ clearInterval(config.bitrate.timer);
+ }
+
+ config.bitrate.timer = null;
+ config.bitrate.bsnow = null;
+ config.bitrate.bsbefore = null;
+ config.bitrate.tsnow = null;
+ config.bitrate.tsbefore = null;
+ config.bitrate.value = null;
+
+ try {
+ // Try a MediaStream.stop() first
+ if (!config.streamExternal && config.myStream) {
+ this.client.log('Stopping local stream');
+ config.myStream.stop();
+ }
+ } catch (e) {
+ // Do nothing if this fails
+ }
+
+ try {
+ // Try a MediaStreamTrack.stop() for each track as well
+ if (!config.streamExternal && config.myStream) {
+ this.client.log('Stopping local stream tracks');
+ WebrtcSession.stopMediaStream(config.myStream);
+ }
+ } catch (e) {
+ // Do nothing if this fails
+ }
+
+ config.streamExternal = false;
+ config.myStream = null;
+
+ // Close PeerConnection
+ try {
+ config.pc.close();
+ } catch (e) {
+ // Do nothing
+ }
+ config.pc = null;
+ config.mySdp = null;
+ config.iceDone = false;
+ config.sdpSent = false;
+ config.dataChannel = null;
+ config.dtmfSender = null;
+ }
+ pluginHandle.oncleanup();
+ }
+
+ isAudioSendEnabled(media) {
+ this.client.debug('isAudioSendEnabled:', media);
+ if (!media) {
+ return true; // Default
+ }
+
+ if (media.audio === false) {
+ return false; // Generic audio has precedence
+ }
+
+ if (!media.audioSend) {
+ return true; // Default
+ }
+
+ return (media.audioSend === true);
+ }
+
+ isAudioRecvEnabled(media) {
+ this.client.debug('isAudioRecvEnabled:', media);
+ if (!media) {
+ return true; // Default
+ }
+
+ if (media.audio === false) {
+ return false; // Generic audio has precedence
+ }
+
+ if (!media.audioRecv) {
+ return true; // Default
+ }
+
+ return (media.audioRecv === true);
+ }
+
+ isVideoSendEnabled(media) {
+ this.client.debug('isVideoSendEnabled:', media);
+ const browser = adapter.browserDetails.browser;
+ if (browser === 'edge') {
+ this.client.warn("Edge doesn't support compatible video yet");
+ return false;
+ }
+
+ if (!media) {
+ return true; // Default
+ }
+
+ if (media.video === false) {
+ return false; // Generic video has precedence
+ }
+
+ if (!media.videoSend) {
+ return true; // Default
+ }
+
+ return (media.videoSend === true);
+ }
+
+ isVideoRecvEnabled(media) {
+ this.client.debug('isVideoRecvEnabled:', media);
+ const browser = adapter.browserDetails.browser;
+ if (browser === 'edge') {
+ this.client.warn("Edge doesn't support compatible video yet");
+ return false;
+ }
+
+ if (!media) {
+ return true; // Default
+ }
+
+ if (media.video === false) {
+ return false; // Generic video has precedence
+ }
+
+ if (!media.videoRecv) {
+ return true; // Default
+ }
+
+ return (media.videoRecv === true);
+ }
+
+ isDataEnabled(media) {
+ this.client.debug('isDataEnabled:', media);
+ const browser = adapter.browserDetails.browser;
+ if (browser === 'edge') {
+ this.client.warn("Edge doesn't support data channels yet");
+ return false;
+ }
+
+ if (!media) {
+ return false; // Default
+ }
+
+ return (media.data === true);
+ }
+
+ isTrickleEnabled(trickle) {
+ this.client.debug('isTrickleEnabled:', trickle);
+ if (!trickle) {
+ return true; // Default is true
+ }
+
+ return (trickle === true);
+ }
+
+ unbindWebSocket = (onUnbindMessage, onUnbindError) => {
+ for (var eventName in this.wsHandlers) {
+ if (this.wsHandlers.hasOwnProperty(eventName)) {
+ this.ws.removeEventListener(eventName, this.wsHandlers[eventName]);
+ }
+ }
+ this.ws.removeEventListener('message', onUnbindMessage);
+ this.ws.removeEventListener('error', onUnbindError);
+ if (this.wsKeepaliveTimeoutId) {
+ clearTimeout(this.wsKeepaliveTimeoutId);
+ }
+ };
+} \ No newline at end of file
diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx
index 1e74df05f..8600e1e8c 100644
--- a/webapp/components/admin_console/admin_sidebar.jsx
+++ b/webapp/components/admin_console/admin_sidebar.jsx
@@ -264,7 +264,7 @@ export default class AdminSidebar extends React.Component {
title={
<FormattedMessage
id='admin.sidebar.webrtc'
- defaultMessage='WebRTC'
+ defaultMessage='WebRTC (Beta)'
/>
}
/>
diff --git a/webapp/components/admin_console/webrtc_settings.jsx b/webapp/components/admin_console/webrtc_settings.jsx
index eac075bfa..19423cf56 100644
--- a/webapp/components/admin_console/webrtc_settings.jsx
+++ b/webapp/components/admin_console/webrtc_settings.jsx
@@ -15,10 +15,23 @@ export default class WebrtcSettings extends AdminSettings {
constructor(props) {
super(props);
+ this.canSave = this.canSave.bind(this);
+ this.handleAgreeChange = this.handleAgreeChange.bind(this);
+
this.getConfigFromState = this.getConfigFromState.bind(this);
this.renderSettings = this.renderSettings.bind(this);
}
+ canSave() {
+ return !this.state.enableWebrtc || this.state.agree;
+ }
+
+ handleAgreeChange(e) {
+ this.setState({
+ agree: e.target.checked
+ });
+ }
+
getConfigFromState(config) {
config.WebrtcSettings.Enable = this.state.enableWebrtc;
config.WebrtcSettings.GatewayWebsocketUrl = this.state.gatewayWebsocketUrl;
@@ -44,7 +57,8 @@ export default class WebrtcSettings extends AdminSettings {
stunURI: settings.StunURI,
turnURI: settings.TurnURI,
turnUsername: settings.TurnUsername,
- turnSharedKey: settings.TurnSharedKey
+ turnSharedKey: settings.TurnSharedKey,
+ agree: settings.Enable
};
}
@@ -53,13 +67,32 @@ export default class WebrtcSettings extends AdminSettings {
<h3>
<FormattedMessage
id='admin.integrations.webrtc'
- defaultMessage='Mattermost WebRTC'
+ defaultMessage='Mattermost WebRTC (Beta)'
/>
</h3>
);
}
renderSettings() {
+ const tosCheckbox = (
+ <div className='form-group'>
+ <div className='col-sm-4'/>
+ <div className='col-sm-8'>
+ <input
+ type='checkbox'
+ ref='agree'
+ checked={this.state.agree}
+ onChange={this.handleAgreeChange}
+ disabled={!this.state.enableWebrtc}
+ />
+ <FormattedHTMLMessage
+ id='admin.webrtc.agree'
+ defaultMessage=' I understand and accept the Mattermost Hosted WebRTC Service <a href="https://about.mattermost.com/webrtc-terms/" target="_blank">Terms of Service</a> and <a href="https://about.mattermost.com/webrtc-privacy/" target="_blank">Privacy Policy</a>.'
+ />
+ </div>
+ </div>
+ );
+
return (
<SettingsGroup>
<BooleanSetting
@@ -79,6 +112,7 @@ export default class WebrtcSettings extends AdminSettings {
value={this.state.enableWebrtc}
onChange={this.handleChange}
/>
+ {tosCheckbox}
<TextSetting
id='gatewayWebsocketUrl'
label={
@@ -111,7 +145,7 @@ export default class WebrtcSettings extends AdminSettings {
helpText={
<FormattedMessage
id='admin.webrtc.gatewayAdminUrlDescription'
- defaultMessage='Enter https://<mattermost-webrtc-gateway-url>:<port>. Make sure you use HTTP or HTTPS in your URL depending on your server configuration.
+ defaultMessage='Enter https://<mattermost-webrtc-gateway-url>:<port>/admin. Make sure you use HTTP or HTTPS in your URL depending on your server configuration.
Mattermost WebRTC uses this URL to obtain valid tokens for each peer to establish the connection.'
/>
}
@@ -167,7 +201,7 @@ export default class WebrtcSettings extends AdminSettings {
}
placeholder={Utils.localizeMessage('admin.webrtc.turnUriExample', 'Ex "turn:webrtc.mattermost.com:5349"')}
helpText={
- <FormattedHTMLMessage
+ <FormattedMessage
id='admin.webrtc.turnUriDescription'
defaultMessage='Enter your TURN URI as turn:<your-turn-url>:<port>. TURN is a standardized network protocol to allow an end host to assist devices to establish a connection by using a relay public IP address if it is located behind a symmetric NAT.'
/>
@@ -186,7 +220,7 @@ export default class WebrtcSettings extends AdminSettings {
}
placeholder={Utils.localizeMessage('admin.webrtc.turnUsernameExample', 'Ex "myusername"')}
helpText={
- <FormattedHTMLMessage
+ <FormattedMessage
id='admin.webrtc.turnUsernameDescription'
defaultMessage='Enter your TURN Server Username.'
/>
@@ -205,7 +239,7 @@ export default class WebrtcSettings extends AdminSettings {
}
placeholder={Utils.localizeMessage('admin.webrtc.turnSharedKeyExample', 'Ex "bXdkOWQxc3d0Ynk3emY5ZmsxZ3NtazRjaWg="')}
helpText={
- <FormattedHTMLMessage
+ <FormattedMessage
id='admin.webrtc.turnSharedKeyDescription'
defaultMessage='Enter your TURN Server Shared Key. This is used to created dynamic passwords to establish the connection. Each password is valid for a short period of time.'
/>
diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx
index add088913..65c151d8a 100644
--- a/webapp/components/channel_header.jsx
+++ b/webapp/components/channel_header.jsx
@@ -21,21 +21,26 @@ import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import SearchStore from 'stores/search_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
+import * as GlobalActions from 'actions/global_actions.jsx';
+import * as WebrtcActions from 'actions/webrtc_actions.jsx';
import * as Utils from 'utils/utils.jsx';
import * as TextFormatting from 'utils/text_formatting.jsx';
import Client from 'client/web_client.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import {getFlaggedPosts} from 'actions/post_actions.jsx';
-import {ActionTypes, Constants, Preferences} from 'utils/constants.jsx';
+import {ActionTypes, Constants, Preferences, UserStatuses} from 'utils/constants.jsx';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {browserHistory} from 'react-router/es6';
import {Tooltip, OverlayTrigger, Popover} from 'react-bootstrap';
+const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES;
+
export default class ChannelHeader extends React.Component {
constructor(props) {
super(props);
@@ -47,6 +52,8 @@ export default class ChannelHeader extends React.Component {
this.hideRenameChannelModal = this.hideRenameChannelModal.bind(this);
this.openRecentMentions = this.openRecentMentions.bind(this);
this.getFlagged = this.getFlagged.bind(this);
+ this.initWebrtc = this.initWebrtc.bind(this);
+ this.onBusy = this.onBusy.bind(this);
const state = this.getStateFromStores();
state.showEditChannelPurposeModal = false;
@@ -64,7 +71,8 @@ export default class ChannelHeader extends React.Component {
users: extraInfo.members,
userCount: extraInfo.member_count,
currentUser: UserStore.getCurrentUser(),
- enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true)
+ enableFormatting: PreferenceStore.getBool(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', true),
+ isBusy: WebrtcStore.isBusy()
};
}
@@ -85,6 +93,9 @@ export default class ChannelHeader extends React.Component {
SearchStore.addSearchChangeListener(this.onListenerChange);
PreferenceStore.addChangeListener(this.onListenerChange);
UserStore.addChangeListener(this.onListenerChange);
+ UserStore.addStatusesChangeListener(this.onListenerChange);
+ WebrtcStore.addChangedListener(this.onListenerChange);
+ WebrtcStore.addBusyListener(this.onBusy);
$('.sidebar--left .dropdown-menu').perfectScrollbar();
document.addEventListener('keydown', this.openRecentMentions);
}
@@ -95,6 +106,9 @@ export default class ChannelHeader extends React.Component {
SearchStore.removeSearchChangeListener(this.onListenerChange);
PreferenceStore.removeChangeListener(this.onListenerChange);
UserStore.removeChangeListener(this.onListenerChange);
+ UserStore.removeStatusesChangeListener(this.onListenerChange);
+ WebrtcStore.removeChangedListener(this.onListenerChange);
+ WebrtcStore.removeBusyListener(this.onBusy);
document.removeEventListener('keydown', this.openRecentMentions);
}
@@ -204,6 +218,17 @@ export default class ChannelHeader extends React.Component {
return true;
}
+ initWebrtc(contactId, isOnline) {
+ if (isOnline && !this.state.isBusy) {
+ GlobalActions.emitCloseRightHandSide();
+ WebrtcActions.initWebrtc(contactId, true);
+ }
+ }
+
+ onBusy(isBusy) {
+ this.setState({isBusy});
+ }
+
render() {
const flagIcon = Constants.FLAG_ICON_SVG;
@@ -250,10 +275,12 @@ export default class ChannelHeader extends React.Component {
const isAdmin = TeamStore.isTeamAdminForCurrentTeam() || UserStore.isSystemAdminForCurrentUser();
const isSystemAdmin = UserStore.isSystemAdminForCurrentUser();
const isDirect = (this.state.channel.type === 'D');
+ let webrtc;
if (isDirect) {
+ const userMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+ let contact;
if (this.state.users.length > 1) {
- let contact;
if (this.state.users[0].id === currentId) {
contact = this.state.users[1];
} else {
@@ -261,6 +288,68 @@ export default class ChannelHeader extends React.Component {
}
channelTitle = Utils.displayUsername(contact.id);
}
+
+ const webrtcEnabled = global.mm_config.EnableWebrtc === 'true' && global.mm_license.Webrtc === 'true' &&
+ global.mm_config.EnableDeveloper === 'true' && userMedia && Utils.isFeatureEnabled(PreReleaseFeatures.WEBRTC_PREVIEW);
+
+ if (webrtcEnabled) {
+ const isOffline = UserStore.getStatus(contact.id) === UserStatuses.OFFLINE;
+ const busy = this.state.isBusy;
+ let circleClass = '';
+ let webrtcMessage;
+
+ if (isOffline || busy) {
+ circleClass = 'offline';
+ if (busy) {
+ webrtcMessage = (
+ <FormattedMessage
+ id='channel_header.webrtc.unavailable'
+ defaultMessage='New call unavailable until your existing call ends'
+ />
+ );
+ }
+ } else {
+ webrtcMessage = (
+ <FormattedMessage
+ id='channel_header.webrtc.call'
+ defaultMessage='Start Video Call'
+ />
+ );
+ }
+
+ webrtc = (
+ <div className='webrtc__header'>
+ <a
+ href='#'
+ onClick={() => this.initWebrtc(contact.id, !isOffline)}
+ disabled={isOffline}
+ >
+ <svg
+ id='webrtc-btn'
+ className='webrtc__button'
+ xmlns='http://www.w3.org/2000/svg'
+ >
+ <circle
+ className={circleClass}
+ cx='16'
+ cy='16'
+ r='18'
+ >
+ <title>
+ {webrtcMessage}
+ </title>
+ </circle>
+ <path
+ className='off'
+ transform='scale(0.4), translate(17,16)'
+ d='M40 8H8c-2.21 0-4 1.79-4 4v24c0 2.21 1.79 4 4 4h32c2.21 0 4-1.79 4-4V12c0-2.21-1.79-4-4-4zm-4 24l-8-6.4V32H12V16h16v6.4l8-6.4v16z'
+ fill='white'
+ />
+ </svg>
+ </a>
+ </div>
+ );
+ }
}
let channelTerm = (
@@ -541,6 +630,7 @@ export default class ChannelHeader extends React.Component {
<tr>
<th>
<div className='channel-header__info'>
+ {webrtc}
<div className='dropdown'>
<a
href='#'
@@ -564,8 +654,8 @@ export default class ChannelHeader extends React.Component {
<OverlayTrigger
trigger={'click'}
placement='bottom'
- overlay={popoverContent}
rootClose={true}
+ overlay={popoverContent}
ref='headerOverlay'
>
<div
diff --git a/webapp/components/leave_team_modal.jsx b/webapp/components/leave_team_modal.jsx
index 7263f23d4..f8f5675c7 100644
--- a/webapp/components/leave_team_modal.jsx
+++ b/webapp/components/leave_team_modal.jsx
@@ -1,11 +1,11 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import Constants from 'utils/constants.jsx';
-const ActionTypes = Constants.ActionTypes;
+import {ActionTypes, WebrtcActionTypes} from 'utils/constants.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import ModalStore from 'stores/modal_store.jsx';
import UserStore from 'stores/user_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import {intlShape, injectIntl, FormattedMessage} from 'react-intl';
@@ -40,12 +40,18 @@ class LeaveTeamModal extends React.Component {
});
}
- handleSubmit() {
- GlobalActions.emitLeaveTeam();
-
+ handleSubmit(e) {
this.setState({
show: false
});
+
+ if (WebrtcStore.isBusy()) {
+ WebrtcStore.emitChanged({action: WebrtcActionTypes.IN_PROGRESS});
+ e.preventDefault();
+ return;
+ }
+
+ GlobalActions.emitLeaveTeam();
}
handleHide() {
diff --git a/webapp/components/loading_screen.jsx b/webapp/components/loading_screen.jsx
index 9f4abf7f6..288eda389 100644
--- a/webapp/components/loading_screen.jsx
+++ b/webapp/components/loading_screen.jsx
@@ -11,6 +11,17 @@ export default class LoadingScreen extends React.Component {
this.state = {};
}
render() {
+ let message = (
+ <FormattedMessage
+ id='loading_screen.loading'
+ defaultMessage='Loading'
+ />
+ );
+
+ if (this.props.message) {
+ message = this.props.message;
+ }
+
return (
<div
className='loading-screen'
@@ -18,10 +29,7 @@ export default class LoadingScreen extends React.Component {
>
<div className='loading__content'>
<h3>
- <FormattedMessage
- id='loading_screen.loading'
- defaultMessage='Loading'
- />
+ {message}
</h3>
<div className='round round-1'></div>
<div className='round round-2'></div>
@@ -36,5 +44,6 @@ LoadingScreen.defaultProps = {
position: 'relative'
};
LoadingScreen.propTypes = {
- position: React.PropTypes.oneOf(['absolute', 'fixed', 'relative', 'static', 'inherit'])
+ position: React.PropTypes.oneOf(['absolute', 'fixed', 'relative', 'static', 'inherit']),
+ message: React.PropTypes.node
};
diff --git a/webapp/components/navbar_dropdown.jsx b/webapp/components/navbar_dropdown.jsx
index 413942865..c29bf61c3 100644
--- a/webapp/components/navbar_dropdown.jsx
+++ b/webapp/components/navbar_dropdown.jsx
@@ -6,12 +6,13 @@ import * as GlobalActions from 'actions/global_actions.jsx';
import TeamStore from 'stores/team_store.jsx';
import UserStore from 'stores/user_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import AboutBuildModal from './about_build_modal.jsx';
import TeamMembersModal from './team_members_modal.jsx';
import ToggleModalButton from './toggle_modal_button.jsx';
import UserSettingsModal from './user_settings/user_settings_modal.jsx';
-import Constants from 'utils/constants.jsx';
+import {Constants, WebrtcActionTypes} from 'utils/constants.jsx';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router/es6';
@@ -30,6 +31,8 @@ export default class NavbarDropdown extends React.Component {
this.renderCustomEmojiLink = this.renderCustomEmojiLink.bind(this);
+ this.handleClick = this.handleClick.bind(this);
+
this.state = {
showUserSettingsModal: false,
showAboutModal: false,
@@ -38,6 +41,13 @@ export default class NavbarDropdown extends React.Component {
};
}
+ handleClick(e) {
+ if (WebrtcStore.isBusy()) {
+ WebrtcStore.emitChanged({action: WebrtcActionTypes.IN_PROGRESS});
+ e.preventDefault();
+ }
+ }
+
handleAboutModal() {
this.setState({showAboutModal: true});
}
@@ -77,7 +87,10 @@ export default class NavbarDropdown extends React.Component {
return (
<li>
- <Link to={'/' + this.props.teamName + '/emoji'}>
+ <Link
+ onClick={this.handleClick}
+ to={'/' + this.props.teamName + '/emoji'}
+ >
<FormattedMessage
id='navbar_dropdown.emoji'
defaultMessage='Custom Emoji'
@@ -198,7 +211,10 @@ export default class NavbarDropdown extends React.Component {
if (integrationsEnabled && (isAdmin || config.EnableOnlyAdminIntegrations !== 'true')) {
integrationsLink = (
<li>
- <Link to={'/' + this.props.teamName + '/integrations'}>
+ <Link
+ to={'/' + this.props.teamName + '/integrations'}
+ onClick={this.handleClick}
+ >
<FormattedMessage
id='navbar_dropdown.integrations'
defaultMessage='Integrations'
@@ -213,6 +229,7 @@ export default class NavbarDropdown extends React.Component {
<li>
<Link
to={'/admin_console'}
+ onClick={this.handleClick}
>
<FormattedMessage
id='navbar_dropdown.console'
@@ -231,6 +248,7 @@ export default class NavbarDropdown extends React.Component {
<Link
key='newTeam_a'
to='/create_team'
+ onClick={this.handleClick}
>
<FormattedMessage
id='navbar_dropdown.create'
diff --git a/webapp/components/needs_team.jsx b/webapp/components/needs_team.jsx
index cd80f0fc7..c5408b18b 100644
--- a/webapp/components/needs_team.jsx
+++ b/webapp/components/needs_team.jsx
@@ -21,6 +21,9 @@ import ErrorBar from 'components/error_bar.jsx';
import SidebarRight from 'components/sidebar_right.jsx';
import SidebarRightMenu from 'components/sidebar_right_menu.jsx';
import Navbar from 'components/navbar.jsx';
+import WebrtcSidebar from './webrtc/components/webrtc_sidebar.jsx';
+
+import WebrtcNotification from './webrtc/components/webrtc_notification.jsx';
// Modals
import GetPostLinkModal from 'components/get_post_link_modal.jsx';
@@ -146,9 +149,11 @@ export default class NeedsTeam extends React.Component {
return (
<div className='channel-view'>
<ErrorBar/>
+ <WebrtcNotification/>
<div className='container-fluid'>
<SidebarRight/>
<SidebarRightMenu teamType={this.state.team.type}/>
+ <WebrtcSidebar/>
{content}
<GetPostLinkModal/>
diff --git a/webapp/components/post_view/components/post.jsx b/webapp/components/post_view/components/post.jsx
index 7aa0c028e..2dce093ec 100644
--- a/webapp/components/post_view/components/post.jsx
+++ b/webapp/components/post_view/components/post.jsx
@@ -118,6 +118,10 @@ export default class Post extends React.Component {
return true;
}
+ if (nextProps.isBusy !== this.props.isBusy) {
+ return true;
+ }
+
return false;
}
render() {
@@ -246,6 +250,8 @@ export default class Post extends React.Component {
displayNameType={this.props.displayNameType}
useMilitaryTime={this.props.useMilitaryTime}
isFlagged={this.props.isFlagged}
+ status={this.props.status}
+ isBusy={this.props.isBusy}
/>
<PostBody
post={post}
@@ -282,5 +288,6 @@ Post.propTypes = {
isCommentMention: React.PropTypes.bool,
useMilitaryTime: React.PropTypes.bool.isRequired,
isFlagged: React.PropTypes.bool,
- status: React.PropTypes.string
+ status: React.PropTypes.string,
+ isBusy: React.PropTypes.bool
};
diff --git a/webapp/components/post_view/components/post_header.jsx b/webapp/components/post_view/components/post_header.jsx
index 5900c8281..ff691c12b 100644
--- a/webapp/components/post_view/components/post_header.jsx
+++ b/webapp/components/post_view/components/post_header.jsx
@@ -24,6 +24,8 @@ export default class PostHeader extends React.Component {
<UserProfile
user={this.props.user}
displayNameType={this.props.displayNameType}
+ status={this.props.status}
+ isBusy={this.props.isBusy}
/>
);
let botIndicator;
@@ -98,5 +100,7 @@ PostHeader.propTypes = {
compactDisplay: React.PropTypes.bool,
displayNameType: React.PropTypes.string,
useMilitaryTime: React.PropTypes.bool.isRequired,
- isFlagged: React.PropTypes.bool.isRequired
+ isFlagged: React.PropTypes.bool.isRequired,
+ status: React.PropTypes.string,
+ isBusy: React.PropTypes.bool
};
diff --git a/webapp/components/post_view/components/post_list.jsx b/webapp/components/post_view/components/post_list.jsx
index a05507703..d686b28e5 100644
--- a/webapp/components/post_view/components/post_list.jsx
+++ b/webapp/components/post_view/components/post_list.jsx
@@ -316,6 +316,7 @@ export default class PostList extends React.Component {
useMilitaryTime={this.props.useMilitaryTime}
isFlagged={isFlagged}
status={status}
+ isBusy={this.props.isBusy}
/>
);
@@ -585,5 +586,6 @@ PostList.propTypes = {
useMilitaryTime: React.PropTypes.bool.isRequired,
isFocusPost: React.PropTypes.bool,
flaggedPosts: React.PropTypes.object,
- statuses: React.PropTypes.object
+ statuses: React.PropTypes.object,
+ isBusy: React.PropTypes.bool
};
diff --git a/webapp/components/post_view/post_focus_view_controller.jsx b/webapp/components/post_view/post_focus_view_controller.jsx
index 7903087e9..dec4ca709 100644
--- a/webapp/components/post_view/post_focus_view_controller.jsx
+++ b/webapp/components/post_view/post_focus_view_controller.jsx
@@ -9,6 +9,7 @@ import PostStore from 'stores/post_store.jsx';
import UserStore from 'stores/user_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import Constants from 'utils/constants.jsx';
const ScrollTypes = Constants.ScrollTypes;
@@ -26,6 +27,7 @@ export default class PostFocusView extends React.Component {
this.onStatusChange = this.onStatusChange.bind(this);
this.onPreferenceChange = this.onPreferenceChange.bind(this);
this.onPostListScroll = this.onPostListScroll.bind(this);
+ this.onBusy = this.onBusy.bind(this);
const focusedPostId = PostStore.getFocusedPostId();
@@ -38,13 +40,14 @@ export default class PostFocusView extends React.Component {
const joinLeaveEnabled = PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', true);
let statuses;
- if (channel && channel.type !== Constants.DM_CHANNEL) {
+ if (channel) {
statuses = Object.assign({}, UserStore.getStatuses());
}
this.state = {
postList: PostStore.filterPosts(focusedPostId, joinLeaveEnabled),
currentUser: UserStore.getCurrentUser(),
+ isBusy: WebrtcStore.isBusy(),
profiles,
statuses,
scrollType: ScrollTypes.POST,
@@ -64,6 +67,7 @@ export default class PostFocusView extends React.Component {
UserStore.addStatusesChangeListener(this.onStatusChange);
EmojiStore.addChangeListener(this.onEmojiChange);
PreferenceStore.addChangeListener(this.onPreferenceChange);
+ WebrtcStore.addBusyListener(this.onBusy);
}
componentWillUnmount() {
@@ -73,6 +77,7 @@ export default class PostFocusView extends React.Component {
UserStore.removeStatusesChangeListener(this.onStatusChange);
EmojiStore.removeChangeListener(this.onEmojiChange);
PreferenceStore.removeChangeListener(this.onPreferenceChange);
+ WebrtcStore.removeBusyListener(this.onBusy);
}
onChannelChange() {
@@ -113,7 +118,7 @@ export default class PostFocusView extends React.Component {
onStatusChange() {
const channel = ChannelStore.getCurrent();
let statuses;
- if (channel && channel.type !== Constants.DM_CHANNEL) {
+ if (channel) {
statuses = Object.assign({}, UserStore.getStatuses());
}
@@ -144,6 +149,10 @@ export default class PostFocusView extends React.Component {
this.setState({scrollType: ScrollTypes.FREE});
}
+ onBusy(isBusy) {
+ this.setState({isBusy});
+ }
+
render() {
const postsToHighlight = {};
postsToHighlight[this.state.scrollPostId] = true;
@@ -172,6 +181,7 @@ export default class PostFocusView extends React.Component {
emojis={this.state.emojis}
flaggedPosts={this.state.flaggedPosts}
statuses={this.state.statuses}
+ isBusy={this.state.isBusy}
/>
);
}
diff --git a/webapp/components/post_view/post_view_controller.jsx b/webapp/components/post_view/post_view_controller.jsx
index 840f71f23..b96374225 100644
--- a/webapp/components/post_view/post_view_controller.jsx
+++ b/webapp/components/post_view/post_view_controller.jsx
@@ -8,6 +8,7 @@ import PreferenceStore from 'stores/preference_store.jsx';
import UserStore from 'stores/user_store.jsx';
import PostStore from 'stores/post_store.jsx';
import ChannelStore from 'stores/channel_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import * as Utils from 'utils/utils.jsx';
@@ -30,6 +31,7 @@ export default class PostViewController extends React.Component {
this.onPostListScroll = this.onPostListScroll.bind(this);
this.onActivate = this.onActivate.bind(this);
this.onDeactivate = this.onDeactivate.bind(this);
+ this.onBusy = this.onBusy.bind(this);
const channel = props.channel;
let profiles = UserStore.getProfiles();
@@ -54,6 +56,7 @@ export default class PostViewController extends React.Component {
channel,
postList: PostStore.filterPosts(channel.id, joinLeaveEnabled),
currentUser: UserStore.getCurrentUser(),
+ isBusy: WebrtcStore.isBusy(),
profiles,
statuses,
atTop: PostStore.getVisibilityAtTop(channel.id),
@@ -140,6 +143,7 @@ export default class PostViewController extends React.Component {
PostStore.addChangeListener(this.onPostsChange);
PostStore.addPostsViewJumpListener(this.onPostsViewJumpRequest);
ChannelStore.addLastViewedListener(this.onSetNewMessageIndicator);
+ WebrtcStore.addBusyListener(this.onBusy);
}
onDeactivate() {
@@ -149,6 +153,7 @@ export default class PostViewController extends React.Component {
PostStore.removeChangeListener(this.onPostsChange);
PostStore.removePostsViewJumpListener(this.onPostsViewJumpRequest);
ChannelStore.removeLastViewedListener(this.onSetNewMessageIndicator);
+ WebrtcStore.removeBusyListener(this.onBusy);
}
componentWillReceiveProps(nextProps) {
@@ -217,6 +222,10 @@ export default class PostViewController extends React.Component {
}
}
+ onBusy(isBusy) {
+ this.setState({isBusy});
+ }
+
shouldComponentUpdate(nextProps, nextState) {
if (nextProps.active !== this.props.active) {
return true;
@@ -294,6 +303,10 @@ export default class PostViewController extends React.Component {
return true;
}
+ if (nextState.isBusy !== this.state.isBusy) {
+ return true;
+ }
+
return false;
}
@@ -326,6 +339,7 @@ export default class PostViewController extends React.Component {
lastViewed={this.state.lastViewed}
ownNewMessage={this.state.ownNewMessage}
statuses={this.state.statuses}
+ isBusy={this.state.isBusy}
/>
);
}
diff --git a/webapp/components/rhs_header_post.jsx b/webapp/components/rhs_header_post.jsx
index 978c58c85..d0d720bb5 100644
--- a/webapp/components/rhs_header_post.jsx
+++ b/webapp/components/rhs_header_post.jsx
@@ -36,7 +36,7 @@ export default class RhsHeaderPost extends React.Component {
handleBack(e) {
e.preventDefault();
- if (this.props.fromSearch) {
+ if (this.props.fromSearch || this.props.isWebrtc) {
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_SEARCH_TERM,
term: this.props.fromSearch,
@@ -82,6 +82,15 @@ export default class RhsHeaderPost extends React.Component {
/>
</Tooltip>
);
+ } else if (this.props.isWebrtc) {
+ backToResultsTooltip = (
+ <Tooltip id='backToResultsTooltip'>
+ <FormattedMessage
+ id='rhs_header.backToCallTooltip'
+ defaultMessage='Back to Call'
+ />
+ </Tooltip>
+ );
}
const expandSidebarTooltip = (
@@ -102,7 +111,7 @@ export default class RhsHeaderPost extends React.Component {
</Tooltip>
);
- if (this.props.fromSearch || this.props.fromFlaggedPosts) {
+ if (this.props.fromSearch || this.props.fromFlaggedPosts || this.props.isWebrtc) {
back = (
<a
href='#'
@@ -178,6 +187,7 @@ RhsHeaderPost.defaultProps = {
};
RhsHeaderPost.propTypes = {
isMentionSearch: React.PropTypes.bool,
+ isWebrtc: React.PropTypes.bool,
fromSearch: React.PropTypes.string,
fromFlaggedPosts: React.PropTypes.bool,
toggleSize: React.PropTypes.function,
diff --git a/webapp/components/rhs_thread.jsx b/webapp/components/rhs_thread.jsx
index e4267dc04..73c2fb9dc 100644
--- a/webapp/components/rhs_thread.jsx
+++ b/webapp/components/rhs_thread.jsx
@@ -282,6 +282,7 @@ export default class RhsThread extends React.Component {
<RhsHeaderPost
fromFlaggedPosts={this.props.fromFlaggedPosts}
fromSearch={this.props.fromSearch}
+ isWebrtc={this.props.isWebrtc}
isMentionSearch={this.props.isMentionSearch}
toggleSize={this.props.toggleSize}
shrink={this.props.shrink}
@@ -362,6 +363,7 @@ RhsThread.defaultProps = {
RhsThread.propTypes = {
fromSearch: React.PropTypes.string,
fromFlaggedPosts: React.PropTypes.bool,
+ isWebrtc: React.PropTypes.bool,
isMentionSearch: React.PropTypes.bool,
currentUser: React.PropTypes.object.isRequired,
useMilitaryTime: React.PropTypes.bool.isRequired,
diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right.jsx
index 6d1184799..7d9934919 100644
--- a/webapp/components/sidebar_right.jsx
+++ b/webapp/components/sidebar_right.jsx
@@ -5,11 +5,11 @@ import $ from 'jquery';
import SearchResults from './search_results.jsx';
import RhsThread from './rhs_thread.jsx';
-
import SearchStore from 'stores/search_store.jsx';
import PostStore from 'stores/post_store.jsx';
import UserStore from 'stores/user_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import {getFlaggedPosts} from 'actions/post_actions.jsx';
@@ -101,6 +101,8 @@ export default class SidebarRight extends React.Component {
}
componentDidUpdate() {
+ const isOpen = this.state.searchVisible || this.state.postRightVisible;
+ WebrtcStore.emitRhsChanged(isOpen);
this.doStrangeThings();
}
@@ -175,6 +177,7 @@ export default class SidebarRight extends React.Component {
<RhsThread
fromFlaggedPosts={this.state.fromFlaggedPosts}
fromSearch={this.state.fromSearch}
+ isWebrtc={WebrtcStore.isBusy()}
isMentionSearch={this.state.isMentionSearch}
currentUser={this.state.currentUser}
useMilitaryTime={this.state.useMilitaryTime}
diff --git a/webapp/components/sidebar_right_menu.jsx b/webapp/components/sidebar_right_menu.jsx
index 3cbceab4f..a28125264 100644
--- a/webapp/components/sidebar_right_menu.jsx
+++ b/webapp/components/sidebar_right_menu.jsx
@@ -10,12 +10,13 @@ import AboutBuildModal from './about_build_modal.jsx';
import UserStore from 'stores/user_store.jsx';
import TeamStore from 'stores/team_store.jsx';
import PreferenceStore from 'stores/preference_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import {getFlaggedPosts} from 'actions/post_actions.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import * as Utils from 'utils/utils.jsx';
-import Constants from 'utils/constants.jsx';
+import {Constants, WebrtcActionTypes} from 'utils/constants.jsx';
const ActionTypes = Constants.ActionTypes;
const Preferences = Constants.Preferences;
@@ -33,6 +34,7 @@ export default class SidebarRightMenu extends React.Component {
super(props);
this.onPreferenceChange = this.onPreferenceChange.bind(this);
+ this.handleClick = this.handleClick.bind(this);
this.handleAboutModal = this.handleAboutModal.bind(this);
this.searchMentions = this.searchMentions.bind(this);
this.aboutModalDismissed = this.aboutModalDismissed.bind(this);
@@ -47,6 +49,13 @@ export default class SidebarRightMenu extends React.Component {
this.state = state;
}
+ handleClick(e) {
+ if (WebrtcStore.isBusy()) {
+ WebrtcStore.emitChanged({action: WebrtcActionTypes.IN_PROGRESS});
+ e.preventDefault();
+ }
+ }
+
handleAboutModal() {
this.setState({showAboutModal: true});
}
@@ -254,6 +263,7 @@ export default class SidebarRightMenu extends React.Component {
<li>
<Link
to={'/admin_console'}
+ onClick={this.handleClick}
>
<i className='icon fa fa-wrench'></i>
<FormattedMessage
diff --git a/webapp/components/user_profile.jsx b/webapp/components/user_profile.jsx
index bc542165a..4007f19fb 100644
--- a/webapp/components/user_profile.jsx
+++ b/webapp/components/user_profile.jsx
@@ -4,22 +4,33 @@
import * as Utils from 'utils/utils.jsx';
import Client from 'client/web_client.jsx';
import UserStore from 'stores/user_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
+import * as GlobalActions from 'actions/global_actions.jsx';
+import * as WebrtcActions from 'actions/webrtc_actions.jsx';
+import Constants from 'utils/constants.jsx';
+const UserStatuses = Constants.UserStatuses;
+const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES;
import {Popover, OverlayTrigger} from 'react-bootstrap';
-
-var id = 0;
-
-function nextId() {
- id = id + 1;
- return id;
-}
+import {FormattedMessage} from 'react-intl';
import React from 'react';
export default class UserProfile extends React.Component {
constructor(props) {
super(props);
- this.uniqueId = nextId();
+
+ this.initWebrtc = this.initWebrtc.bind(this);
+ this.state = {
+ currentUserId: UserStore.getCurrentId()
+ };
+ }
+
+ initWebrtc() {
+ if (this.props.status !== UserStatuses.OFFLINE && !WebrtcStore.isBusy()) {
+ GlobalActions.emitCloseRightHandSide();
+ WebrtcActions.initWebrtc(this.props.user.id, true);
+ }
}
shouldComponentUpdate(nextProps) {
@@ -43,6 +54,14 @@ export default class UserProfile extends React.Component {
return true;
}
+ if (nextProps.status !== this.props.status) {
+ return true;
+ }
+
+ if (nextProps.isBusy !== this.props.isBusy) {
+ return true;
+ }
+
return false;
}
@@ -68,6 +87,70 @@ export default class UserProfile extends React.Component {
return <div className='user-popover'>{name}</div>;
}
+ let webrtc;
+ const userMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+
+ const webrtcEnabled = global.mm_config.EnableWebrtc === 'true' && global.mm_license.Webrtc === 'true' &&
+ global.mm_config.EnableDeveloper === 'true' && userMedia && Utils.isFeatureEnabled(PreReleaseFeatures.WEBRTC_PREVIEW);
+
+ if (webrtcEnabled && this.props.user.id !== this.state.currentUserId) {
+ const isOnline = this.props.status !== UserStatuses.OFFLINE;
+ let webrtcMessage;
+ let circleClass = 'offline';
+ if (isOnline && !this.props.isBusy) {
+ circleClass = '';
+ webrtcMessage = (
+ <FormattedMessage
+ id='user_profile.webrtc.call'
+ defaultMessage='Start Video Call'
+ />
+ );
+ } else if (this.props.isBusy) {
+ webrtcMessage = (
+ <FormattedMessage
+ id='user_profile.webrtc.unavailable'
+ defaultMessage='New call unavailable until your existing call ends'
+ />
+ );
+ }
+
+ webrtc = (
+ <div
+ className='webrtc__user-profile'
+ key='makeCall'
+ >
+ <a
+ href='#'
+ onClick={() => this.initWebrtc()}
+ disabled={!isOnline}
+ >
+ <svg
+ id='webrtc-btn'
+ className='webrtc__button'
+ xmlns='http://www.w3.org/2000/svg'
+ >
+ <circle
+ className={circleClass}
+ cx='16'
+ cy='16'
+ r='18'
+ >
+ <title>
+ {webrtcMessage}
+ </title>
+ </circle>
+ <path
+ className='off'
+ transform='scale(0.4), translate(17,16)'
+ d='M40 8H8c-2.21 0-4 1.79-4 4v24c0 2.21 1.79 4 4 4h32c2.21 0 4-1.79 4-4V12c0-2.21-1.79-4-4-4zm-4 24l-8-6.4V32H12V16h16v6.4l8-6.4v16z'
+ fill='white'
+ />
+ </svg>
+ </a>
+ </div>
+ );
+ }
+
var dataContent = [];
dataContent.push(
<img
@@ -97,6 +180,8 @@ export default class UserProfile extends React.Component {
);
}
+ dataContent.push(webrtc);
+
if (global.window.mm_config.ShowEmailAddress === 'true' || UserStore.isSystemAdminForCurrentUser() || this.props.user === UserStore.getCurrentUser()) {
dataContent.push(
<div
@@ -150,5 +235,7 @@ UserProfile.propTypes = {
overwriteName: React.PropTypes.string,
overwriteImage: React.PropTypes.string,
disablePopover: React.PropTypes.bool,
- displayNameType: React.PropTypes.string
+ displayNameType: React.PropTypes.string,
+ status: React.PropTypes.string,
+ isBusy: React.PropTypes.bool
};
diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx
index 88fd410c8..fe7b7bb5a 100644
--- a/webapp/components/user_settings/user_settings_advanced.jsx
+++ b/webapp/components/user_settings/user_settings_advanced.jsx
@@ -33,7 +33,7 @@ export default class AdvancedSettingsDisplay extends React.Component {
}
getStateFromStores() {
- const preReleaseFeaturesKeys = Object.keys(PreReleaseFeatures);
+ let preReleaseFeaturesKeys = Object.keys(PreReleaseFeatures);
const advancedSettings = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS);
const settings = {
send_on_ctrl_enter: PreferenceStore.get(
@@ -55,6 +55,13 @@ export default class AdvancedSettingsDisplay extends React.Component {
let enabledFeatures = 0;
for (const [name, value] of advancedSettings) {
+ const webrtcEnabled = global.mm_config.EnableWebrtc === 'true' && global.mm_license.Webrtc === 'true' &&
+ global.mm_config.EnableDeveloper === 'true';
+
+ if (!webrtcEnabled) {
+ preReleaseFeaturesKeys = preReleaseFeaturesKeys.filter((f) => f !== 'WEBRTC_PREVIEW');
+ }
+
for (const key of preReleaseFeaturesKeys) {
const feature = PreReleaseFeatures[key];
@@ -329,6 +336,13 @@ export default class AdvancedSettingsDisplay extends React.Component {
defaultMessage='Show experimental previews of link content, when available'
/>
);
+ case 'WEBRTC_PREVIEW':
+ return (
+ <FormattedMessage
+ id='user.settings.advance.webrtc_preview'
+ defaultMessage='Enable the ability to make and receive one-on-one WebRTC calls'
+ />
+ );
default:
return null;
}
diff --git a/webapp/components/webrtc/components/webrtc_header.jsx b/webapp/components/webrtc/components/webrtc_header.jsx
new file mode 100644
index 000000000..a4a9c740c
--- /dev/null
+++ b/webapp/components/webrtc/components/webrtc_header.jsx
@@ -0,0 +1,100 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import Constants from 'utils/constants.jsx';
+
+import {Tooltip, OverlayTrigger} from 'react-bootstrap';
+import {FormattedMessage} from 'react-intl';
+
+import React from 'react';
+
+export default class WebrtcHeader extends React.Component {
+ render() {
+ const title = (
+ <FormattedMessage
+ id='webrtc.header'
+ defaultMessage='Call with {username}'
+ values={{
+ username: this.props.username
+ }}
+ />
+ );
+
+ const closeSidebarTooltip = (
+ <Tooltip id='closeSidebarTooltip'>
+ <FormattedMessage
+ id='rhs_header.closeTooltip'
+ defaultMessage='Close Sidebar'
+ />
+ </Tooltip>
+ );
+
+ const expandSidebarTooltip = (
+ <Tooltip id='expandSidebarTooltip'>
+ <FormattedMessage
+ id='rhs_header.expandTooltip'
+ defaultMessage='Expand Sidebar'
+ />
+ </Tooltip>
+ );
+
+ const shrinkSidebarTooltip = (
+ <Tooltip id='shrinkSidebarTooltip'>
+ <FormattedMessage
+ id='rhs_header.expandTooltip'
+ defaultMessage='Shrink Sidebar'
+ />
+ </Tooltip>
+ );
+
+ return (
+ <div className='sidebar--right__header'>
+ <span className='sidebar--right__title'>{title}</span>
+ <div className='pull-right'>
+ <button
+ type='button'
+ className='sidebar--right__expand'
+ aria-label='Expand'
+ onClick={this.props.toggleSize}
+ >
+ <OverlayTrigger
+ delayShow={Constants.OVERLAY_TIME_DELAY}
+ placement='top'
+ overlay={expandSidebarTooltip}
+ >
+ <i className='fa fa-expand'/>
+ </OverlayTrigger>
+ <OverlayTrigger
+ delayShow={Constants.OVERLAY_TIME_DELAY}
+ placement='top'
+ overlay={shrinkSidebarTooltip}
+ >
+ <i className='fa fa-compress'/>
+ </OverlayTrigger>
+ </button>
+ <button
+ type='button'
+ className='sidebar--right__close'
+ aria-label='Close'
+ title='Close'
+ onClick={this.props.onClose}
+ >
+ <OverlayTrigger
+ delayShow={Constants.OVERLAY_TIME_DELAY}
+ placement='top'
+ overlay={closeSidebarTooltip}
+ >
+ <i className='fa fa-sign-out'/>
+ </OverlayTrigger>
+ </button>
+ </div>
+ </div>
+ );
+ }
+}
+
+WebrtcHeader.propTypes = {
+ username: React.PropTypes.string.isRequired,
+ onClose: React.PropTypes.func.isRequired,
+ toggleSize: React.PropTypes.function
+};
diff --git a/webapp/components/webrtc/components/webrtc_notification.jsx b/webapp/components/webrtc/components/webrtc_notification.jsx
new file mode 100644
index 000000000..5456d6cb8
--- /dev/null
+++ b/webapp/components/webrtc/components/webrtc_notification.jsx
@@ -0,0 +1,320 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import Client from 'client/web_client.jsx';
+import WebSocketClient from 'client/web_websocket_client.jsx';
+
+import UserStore from 'stores/user_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
+
+import * as GlobalActions from 'actions/global_actions.jsx';
+import * as WebrtcActions from 'actions/webrtc_actions.jsx';
+import * as Utils from 'utils/utils.jsx';
+import {Constants, WebrtcActionTypes} from 'utils/constants.jsx';
+
+import React from 'react';
+
+import {FormattedMessage} from 'react-intl';
+
+import ring from 'images/ring.mp3';
+
+const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES;
+
+export default class WebrtcNotification extends React.Component {
+ constructor() {
+ super();
+
+ this.mounted = false;
+
+ this.closeNotification = this.closeNotification.bind(this);
+ this.onIncomingCall = this.onIncomingCall.bind(this);
+ this.onCancelCall = this.onCancelCall.bind(this);
+ this.onRhs = this.onRhs.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ this.handleAnswer = this.handleAnswer.bind(this);
+ this.handleTimeout = this.handleTimeout.bind(this);
+ this.stopRinging = this.stopRinging.bind(this);
+ this.closeRightHandSide = this.closeRightHandSide.bind(this);
+
+ this.state = {
+ userCalling: null,
+ rhsOpened: false
+ };
+ }
+
+ componentDidMount() {
+ WebrtcStore.addNotifyListener(this.onIncomingCall);
+ WebrtcStore.addChangedListener(this.onCancelCall);
+ WebrtcStore.addRhsChangedListener(this.onRhs);
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ WebrtcStore.removeNotifyListener(this.onIncomingCall);
+ WebrtcStore.removeChangedListener(this.onCancelCall);
+ WebrtcStore.removeRhsChangedListener(this.onRhs);
+ if (this.refs.ring) {
+ this.refs.ring.removeListener('ended', this.handleTimeout);
+ }
+ this.mounted = false;
+ }
+
+ componentDidUpdate() {
+ if (this.state.userCalling) {
+ this.refs.ring.addEventListener('ended', this.handleTimeout);
+ }
+ }
+
+ closeNotification() {
+ this.setState({
+ userCalling: null
+ });
+ }
+
+ stopRinging() {
+ if (this.refs.ring) {
+ this.refs.ring.pause();
+ this.refs.ring.currentTime = 0;
+ }
+ this.setState({userCalling: null});
+ }
+
+ closeRightHandSide(e) {
+ e.preventDefault();
+ GlobalActions.emitCloseRightHandSide();
+ }
+
+ onIncomingCall(incoming) {
+ if (this.mounted) {
+ const userId = incoming.from_user_id;
+ const userMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+ const featureEnabled = Utils.isFeatureEnabled(PreReleaseFeatures.WEBRTC_PREVIEW);
+
+ if (featureEnabled) {
+ if (WebrtcStore.isBusy()) {
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.BUSY,
+ from_user_id: UserStore.getCurrentId(),
+ to_user_id: userId
+ });
+ this.stopRinging();
+ } else if (userMedia) {
+ WebrtcStore.setVideoCallWith(userId);
+ this.setState({
+ userCalling: UserStore.getProfile(userId)
+ });
+ } else {
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.UNSUPPORTED,
+ from_user_id: UserStore.getCurrentId(),
+ to_user_id: userId
+ });
+ this.stopRinging();
+ }
+ } else {
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.DISABLED,
+ from_user_id: UserStore.getCurrentId(),
+ to_user_id: userId
+ });
+ this.stopRinging();
+ }
+ }
+ }
+
+ onCancelCall(message) {
+ if (message && message.action !== WebrtcActionTypes.CANCEL) {
+ return;
+ } else if (message && message.action === WebrtcActionTypes.CANCEL && this.state.userCalling && message.from_user_id !== this.state.userCalling.id) {
+ return;
+ }
+
+ WebrtcStore.setVideoCallWith(null);
+ this.closeNotification();
+ }
+
+ onRhs(rhsOpened) {
+ this.setState({rhsOpened});
+ }
+
+ handleTimeout() {
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.NO_ANSWER,
+ from_user_id: UserStore.getCurrentId(),
+ to_user_id: this.state.userCalling.id
+ });
+
+ this.onCancelCall();
+ }
+
+ handleAnswer(e) {
+ if (e) {
+ e.preventDefault();
+ }
+
+ const caller = this.state.userCalling;
+ if (caller) {
+ const callerId = caller.id;
+ const currentUserId = UserStore.getCurrentId();
+ const message = {
+ action: WebrtcActionTypes.ANSWER,
+ from_user_id: currentUserId,
+ to_user_id: callerId
+ };
+
+ GlobalActions.emitCloseRightHandSide();
+ WebrtcActions.initWebrtc(callerId, false);
+ WebSocketClient.sendMessage('webrtc', message);
+
+ // delay till next tick (this will give time to listen for events
+ setTimeout(() => {
+ //we switch from and to user to handle the event locally
+ message.from_user_id = callerId;
+ message.to_user_id = currentUserId;
+ WebrtcActions.handle(message);
+ }, 0);
+
+ this.closeNotification();
+ }
+ }
+
+ handleClose(e) {
+ if (e) {
+ e.preventDefault();
+ }
+ if (this.state.userCalling) {
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.DECLINE,
+ from_user_id: UserStore.getCurrentId(),
+ to_user_id: this.state.userCalling.id
+ });
+ }
+
+ this.onCancelCall();
+ }
+
+ render() {
+ const user = this.state.userCalling;
+ if (user) {
+ const username = Utils.displayUsername(user.id);
+ const profileImgSrc = Client.getUsersRoute() + '/' + user.id + '/image?time=' + (user.update_at || new Date().getTime());
+ const profileImg = (
+ <img
+ className='user-popover__image'
+ src={profileImgSrc}
+ height='128'
+ width='128'
+ key='user-popover-image'
+ />
+ );
+ const answerBtn = (
+ <svg
+ className='webrtc-icons__pickup'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={this.handleAnswer}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>
+ <FormattedMessage
+ id='webrtc.notification.answer'
+ defaultMessage='Answer'
+ />
+ </title>
+ </circle>
+ <path
+ transform='translate(-10,-10)'
+ fill='#fff'
+ d='M29.854,37.627c1.723,1.904 3.679,3.468 5.793,4.684l3.683,-3.334c0.469,-0.424 1.119,-0.517 1.669,-0.302c1.628,0.63 3.331,1.021 5.056,1.174c0.401,0.026 0.795,0.199 1.09,0.525c0.295,0.326 0.433,0.741 0.407,1.153l-0.279,5.593c-0.02,0.418 -0.199,0.817 -0.525,1.112c-0.326,0.296 -0.741,0.434 -1.159,0.413c-6.704,-0.504 -13.238,-3.491 -18.108,-8.87c-4.869,-5.38 -7.192,-12.179 -7.028,-18.899c0.015,-0.413 0.199,-0.817 0.526,-1.113c0.326,-0.295 0.74,-0.433 1.153,-0.407l5.593,0.279c0.407,0.02 0.812,0.193 1.107,0.519c0.29,0.32 0.428,0.735 0.413,1.137c-0.018,1.732 0.202,3.464 0.667,5.147c0.159,0.569 0.003,1.207 -0.466,1.631l-3.683,3.334c1.005,2.219 2.368,4.32 4.091,6.224Z'
+ />
+ </svg>
+ );
+
+ const rejectBtn = (
+ <svg
+ className='webrtc-icons__cancel'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={this.handleClose}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>
+ <FormattedMessage
+ id='webrtc.notification.decline'
+ defaultMessage='Decline'
+ />
+ </title>
+ </circle>
+ <path
+ transform='scale(0.7), translate(11,10)'
+ d='M24 18c-3.21 0-6.3.5-9.2 1.44v6.21c0 .79-.46 1.47-1.12 1.8-1.95.98-3.74 2.23-5.33 3.7-.36.35-.85.57-1.4.57-.55 0-1.05-.22-1.41-.59L.59 26.18c-.37-.37-.59-.87-.59-1.42 0-.55.22-1.05.59-1.42C6.68 17.55 14.93 14 24 14s17.32 3.55 23.41 9.34c.37.36.59.87.59 1.42 0 .55-.22 1.05-.59 1.41l-4.95 4.95c-.36.36-.86.59-1.41.59-.54 0-1.04-.22-1.4-.57-1.59-1.47-3.38-2.72-5.33-3.7-.66-.33-1.12-1.01-1.12-1.8v-6.21C30.3 18.5 27.21 18 24 18z'
+ fill='white'
+ />
+ </svg>
+ );
+
+ const msg = (
+ <div>
+ <FormattedMessage
+ id='webrtc.notification.incoming_call'
+ defaultMessage='{username} is calling you.'
+ values={{
+ username
+ }}
+ />
+ <div
+ className='webrtc-buttons webrtc-icons active'
+ style={{marginTop: '5px'}}
+ >
+ {answerBtn}
+ {rejectBtn}
+ </div>
+ </div>
+ );
+
+ return (
+ <div className='webrtc-notification'>
+ <audio
+ ref='ring'
+ src={ring}
+ autoPlay={true}
+ />
+ <div>
+ {profileImg}
+ </div>
+ {msg}
+ </div>
+ );
+ } else if (this.state.rhsOpened && WebrtcStore.isBusy()) {
+ return (
+ <div
+ className='webrtc__notification--rhs'
+ onClick={this.closeRightHandSide}
+ >
+ <i className='fa fa-phone'/>
+ <FormattedMessage
+ id='webrtc.notification.returnToCall'
+ defaultMessage='Return to ongoing call with {username}'
+ values={{
+ username: Utils.displayUsername(WebrtcStore.getVideoCallWith())
+ }}
+ />
+ </div>
+ );
+ }
+
+ return <div/>;
+ }
+}
diff --git a/webapp/components/webrtc/components/webrtc_sidebar.jsx b/webapp/components/webrtc/components/webrtc_sidebar.jsx
new file mode 100644
index 000000000..59c25890b
--- /dev/null
+++ b/webapp/components/webrtc/components/webrtc_sidebar.jsx
@@ -0,0 +1,133 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import $ from 'jquery';
+
+import WebrtcController from '../webrtc_controller.jsx';
+import UserStore from 'stores/user_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+import React from 'react';
+
+export default class SidebarRight extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.plScrolledToBottom = true;
+
+ this.onShrink = this.onShrink.bind(this);
+ this.toggleSize = this.toggleSize.bind(this);
+ this.onInitializeVideoCall = this.onInitializeVideoCall.bind(this);
+
+ this.doStrangeThings = this.doStrangeThings.bind(this);
+
+ this.state = {
+ expanded: false,
+ currentUser: UserStore.getCurrentUser(),
+ videoCallVisible: false,
+ isCaller: false,
+ videoCallWithUserId: null
+ };
+ }
+
+ componentDidMount() {
+ WebrtcStore.addInitListener(this.onInitializeVideoCall);
+ this.doStrangeThings();
+ }
+
+ componentWillUnmount() {
+ WebrtcStore.removeInitListener(this.onInitializeVideoCall);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return !Utils.areObjectsEqual(nextState, this.state);
+ }
+
+ doStrangeThings() {
+ // We should have a better way to do this stuff
+ // Hence the function name.
+ $('.app__body .inner-wrap').removeClass('move--right');
+ $('.app__body .inner-wrap').addClass('webrtc--show');
+ $('.app__body .sidebar--left').removeClass('move--right');
+ $('.app__body .webrtc').addClass('webrtc--show');
+
+ //$('.sidebar--right').prepend('<div class="sidebar__overlay"></div>');
+ if (!this.state.videoCallVisible) {
+ $('.app__body .inner-wrap').removeClass('webrtc--show').removeClass('move--right');
+ $('.app__body .webrtc').removeClass('webrtc--show');
+ return (
+ <div></div>
+ );
+ }
+ return null;
+ }
+
+ componentDidUpdate() {
+ this.doStrangeThings();
+ }
+
+ onShrink() {
+ this.setState({expanded: false});
+ }
+
+ toggleSize(e) {
+ if (e) {
+ e.preventDefault();
+ }
+ this.setState({expanded: !this.state.expanded});
+ }
+
+ onInitializeVideoCall(userId, isCaller) {
+ let expanded = this.state.expanded;
+ if (userId === null) {
+ expanded = false;
+ }
+ this.setState({
+ videoCallVisible: (userId !== null),
+ isCaller,
+ videoCallWithUserId: userId,
+ expanded
+ });
+
+ if (userId !== null) {
+ this.forceUpdate();
+ }
+ }
+
+ render() {
+ let content = null;
+ let expandedClass = '';
+
+ if (this.state.expanded) {
+ expandedClass = 'sidebar--right--expanded';
+ }
+
+ if (this.state.videoCallVisible) {
+ content = (
+ <WebrtcController
+ currentUser={this.state.currentUser}
+ userId={this.state.videoCallWithUserId}
+ isCaller={this.state.isCaller}
+ expanded={this.state.expanded}
+ toggleSize={this.toggleSize}
+ />
+ );
+ }
+
+ return (
+ <div
+ className={'sidebar--right webrtc ' + expandedClass}
+ id='sidebar-webrtc'
+ >
+ <div
+ onClick={this.onShrink}
+ className='sidebar--right__bg'
+ />
+ <div className='sidebar-right-container'>
+ {content}
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/webapp/components/webrtc/webrtc_controller.jsx b/webapp/components/webrtc/webrtc_controller.jsx
new file mode 100644
index 000000000..f9cf241d5
--- /dev/null
+++ b/webapp/components/webrtc/webrtc_controller.jsx
@@ -0,0 +1,1214 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import UserStore from 'stores/user_store.jsx';
+import ChannelStore from 'stores/channel_store.jsx';
+import WebrtcStore from 'stores/webrtc_store.jsx';
+
+import Client from 'client/web_client.jsx';
+import WebSocketClient from 'client/web_websocket_client.jsx';
+import WebrtcSession from 'client/webrtc_session.jsx';
+
+import SearchBox from '../search_bar.jsx';
+import WebrtcHeader from './components/webrtc_header.jsx';
+import ConnectingScreen from 'components/loading_screen.jsx';
+
+import * as WebrtcActions from 'actions/webrtc_actions.jsx';
+
+import * as Utils from 'utils/utils.jsx';
+import {Constants, UserStatuses, WebrtcActionTypes} from 'utils/constants.jsx';
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import ring from 'images/ring.mp3';
+
+const VIDEO_WIDTH = 640;
+const VIDEO_HEIGHT = 360;
+const MIN_ASPECT = 1.777;
+const MAX_ASPECT = 1.778;
+const ALREADY_REGISTERED_ERROR = 477;
+const USERNAME_TAKEN = 476;
+
+export default class WebrtcController extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.mounted = false;
+ this.localMedia = null;
+ this.session = null;
+ this.videocall = null;
+
+ this.handleResize = this.handleResize.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ this.close = this.close.bind(this);
+ this.clearError = this.clearError.bind(this);
+
+ this.previewVideo = this.previewVideo.bind(this);
+ this.stopRinging = this.stopRinging.bind(this);
+
+ this.handleMakeOffer = this.handleMakeOffer.bind(this);
+ this.handleCancelOffer = this.handleCancelOffer.bind(this);
+ this.handleWebrtcEvent = this.handleWebrtcEvent.bind(this);
+ this.handleVideoCallEvent = this.handleVideoCallEvent.bind(this);
+ this.handleRemoteStream = this.handleRemoteStream.bind(this);
+
+ this.onStatusChange = this.onStatusChange.bind(this);
+ this.onCallDeclined = this.onCallDeclined.bind(this);
+ this.onUnsupported = this.onUnsupported.bind(this);
+ this.onNoAnswer = this.onNoAnswer.bind(this);
+ this.onBusy = this.onBusy.bind(this);
+ this.onDisabled = this.onDisabled.bind(this);
+ this.onFailed = this.onFailed.bind(this);
+ this.onCancelled = this.onCancelled.bind(this);
+ this.onConnectCall = this.onConnectCall.bind(this);
+
+ this.onSessionCreated = this.onSessionCreated.bind(this);
+ this.onSessionError = this.onSessionError.bind(this);
+
+ this.doCall = this.doCall.bind(this);
+ this.doAnswer = this.doAnswer.bind(this);
+ this.doHangup = this.doHangup.bind(this);
+ this.doCleanup = this.doCleanup.bind(this);
+
+ this.renderButtons = this.renderButtons.bind(this);
+ this.onToggleVideo = this.onToggleVideo.bind(this);
+ this.onToggleAudio = this.onToggleAudio.bind(this);
+ this.onToggleRemoteMute = this.onToggleRemoteMute.bind(this);
+ this.toggleIcons = this.toggleIcons.bind(this);
+
+ const currentUser = UserStore.getCurrentUser();
+ const remoteUser = UserStore.getProfile(props.userId);
+ const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.update_at;
+
+ this.state = {
+ windowWidth: Utils.windowWidth(),
+ windowHeight: Utils.windowHeight(),
+ channelId: ChannelStore.getCurrentId(),
+ currentUser,
+ currentUserImage: Client.getUsersRoute() + '/' + currentUser.id + '/image?time=' + currentUser.update_at,
+ remoteUserImage,
+ localMediaLoaded: false,
+ isPaused: false,
+ isMuted: false,
+ isRemotePaused: false,
+ isRemoteMuted: false,
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error: null,
+ ended: null
+ };
+ }
+
+ componentDidMount() {
+ window.addEventListener('resize', this.handleResize);
+ WebrtcStore.addChangedListener(this.handleWebrtcEvent);
+ UserStore.addStatusesChangeListener(this.onStatusChange);
+
+ this.mounted = true;
+ this.previewVideo();
+
+ if (this.props.isCaller) {
+ this.handleMakeOffer();
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.handleResize);
+ WebrtcStore.removeChangedListener(this.handleWebrtcEvent);
+ UserStore.removeStatusesChangeListener(this.onStatusChange);
+ this.mounted = false;
+ this.close();
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if ((nextProps.currentUser !== this.props.currentUser) ||
+ (nextProps.userId !== this.props.userId) ||
+ (nextProps.isCaller !== this.props.isCaller)) {
+ const remoteUser = UserStore.getProfile(nextProps.userId);
+ const remoteUserImage = Client.getUsersRoute() + '/' + remoteUser.id + '/image?time=' + remoteUser.update_at;
+ this.setState({
+ error: null,
+ remoteUserImage
+ });
+ }
+
+ if (nextProps.isCaller && nextProps.expanded === this.props.expanded) {
+ this.startCall = true;
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.props.isCaller && this.startCall) {
+ this.startCall = false;
+ this.handleMakeOffer();
+ }
+ }
+
+ handleResize() {
+ this.setState({
+ windowWidth: Utils.windowWidth(),
+ windowHeight: Utils.windowHeight()
+ });
+ }
+
+ clearError() {
+ setTimeout(() => {
+ this.setState({error: null, ended: null});
+ }, Constants.WEBRTC_CLEAR_ERROR_DELAY);
+ }
+
+ previewVideo() {
+ if (this.mounted) {
+ if (this.localMedia) {
+ this.setState({
+ localMediaLoaded: true,
+ error: null
+ });
+ this.localMedia.enabled = true;
+ } else {
+ WebrtcSession.getLocalMedia(
+ {
+ audio: true,
+ video: {
+ mandatory: {
+ minAspectRatio: MIN_ASPECT,
+ maxAspectRatio: MAX_ASPECT
+ },
+ width: VIDEO_WIDTH,
+ height: VIDEO_HEIGHT
+ }
+ },
+ this.refs['local-video'],
+ (error, stream) => {
+ if (error) {
+ this.setState({
+ error: (
+ <FormattedMessage
+ id='webrtc.mediaError'
+ defaultMessage='Unable to access Camera and Microphone'
+ />
+ )
+ });
+ return;
+ }
+ this.localMedia = stream;
+ this.setState({
+ localMediaLoaded: true
+ });
+ });
+ }
+ }
+ }
+
+ stopRinging() {
+ if (this.refs.ring) {
+ this.refs.ring.pause();
+ this.refs.ring.currentTime = 0;
+ }
+ }
+
+ handleMakeOffer() {
+ if (UserStore.getStatus(this.props.userId) === UserStatuses.OFFLINE) {
+ this.onStatusChange();
+ } else {
+ const connectingMsg = (
+ <FormattedMessage
+ id='calling_screen'
+ defaultMessage='Calling'
+ />
+ );
+
+ this.setState({
+ isCalling: true,
+ isAnswering: false,
+ callInProgress: false,
+ error: null,
+ ended: null,
+ connectingMsg
+ });
+
+ WebrtcStore.setVideoCallWith(this.props.userId);
+
+ const user = this.state.currentUser;
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.NOTIFY,
+ from_user_id: user.id,
+ to_user_id: this.props.userId
+ });
+ }
+ }
+
+ handleCancelOffer() {
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error: null,
+ ended: null
+ });
+
+ const user = this.state.currentUser;
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.CANCEL,
+ from_user_id: user.id,
+ to_user_id: this.props.userId
+ });
+
+ this.doCleanup();
+ }
+
+ handleWebrtcEvent(message) {
+ switch (message.action) {
+ case WebrtcActionTypes.DECLINE:
+ this.onCallDeclined();
+ this.clearError();
+ break;
+ case WebrtcActionTypes.UNSUPPORTED:
+ this.onUnsupported();
+ this.clearError();
+ break;
+ case WebrtcActionTypes.BUSY:
+ this.onBusy();
+ this.clearError();
+ break;
+ case WebrtcActionTypes.NO_ANSWER:
+ this.onNoAnswer();
+ this.clearError();
+ break;
+ case WebrtcActionTypes.FAILED:
+ this.onFailed();
+ this.clearError();
+ break;
+ case WebrtcActionTypes.ANSWER:
+ this.onConnectCall();
+ break;
+ case WebrtcActionTypes.CANCEL:
+ this.onCancelled();
+ this.clearError();
+ break;
+ case WebrtcActionTypes.MUTED:
+ this.onToggleRemoteMute(message);
+ break;
+ case WebrtcActionTypes.IN_PROGRESS:
+ this.setState({
+ error: (
+ <FormattedMessage
+ id='webrtc.inProgress'
+ defaultMessage='You have a call in progress. Please hangup first.'
+ />
+ )
+ });
+ this.clearError();
+ break;
+ case WebrtcActionTypes.DISABLED:
+ this.onDisabled();
+ this.clearError();
+ break;
+ }
+ }
+
+ handleVideoCallEvent(msg, jsep) {
+ const result = msg.result;
+
+ if (result) {
+ const event = result.event;
+ switch (event) {
+ case 'registered':
+ if (this.state.isCalling) {
+ this.doCall();
+ }
+ break;
+ case 'incomingcall':
+ this.doAnswer(jsep);
+ break;
+ case 'accepted':
+ this.stopRinging();
+
+ if (jsep) {
+ this.videocall.handleRemoteJsep({jsep});
+ }
+ break;
+ case 'hangup':
+ this.doHangup(false);
+ break;
+ }
+ } else {
+ const errorCode = msg.error_code;
+ if (errorCode !== ALREADY_REGISTERED_ERROR && errorCode !== USERNAME_TAKEN) {
+ this.doHangup(true);
+ } else if (this.state.isCalling) {
+ this.doCall();
+ }
+ }
+ }
+
+ handleRemoteStream(stream) {
+ // attaching stream to where they belong
+ this.refs['main-video'].srcObject = stream;
+
+ let isRemotePaused = false;
+ let isRemoteMuted = false;
+ const videoTracks = stream.getVideoTracks();
+ const audioTracks = stream.getAudioTracks();
+ if (!videoTracks || videoTracks.length === 0 || videoTracks[0].muted || !videoTracks[0].enabled) {
+ isRemotePaused = true;
+ }
+
+ if (!audioTracks || audioTracks.length === 0 || audioTracks[0].muted || !audioTracks[0].enabled) {
+ isRemoteMuted = true;
+ }
+
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: true,
+ isMuted: false,
+ isPaused: false,
+ error: null,
+ ended: null,
+ isRemotePaused,
+ isRemoteMuted
+ });
+ this.toggleIcons();
+ }
+
+ handleClose(e) {
+ e.preventDefault();
+ if (this.state.callInProgress) {
+ this.setState({
+ error: (
+ <FormattedMessage
+ id='webrtc.inProgress'
+ defaultMessage='You have a call in progress. Please hangup first.'
+ />
+ )
+ });
+ } else if (this.state.isCalling) {
+ this.handleCancelOffer();
+ this.close();
+ } else {
+ this.close();
+ }
+ }
+
+ close() {
+ this.doCleanup();
+
+ if (this.session) {
+ this.session.destroy();
+ this.session.disconnect();
+ this.session = null;
+ }
+
+ if (this.localMedia) {
+ WebrtcSession.stopMediaStream(this.localMedia);
+ this.localMedia = null;
+ }
+
+ WebrtcActions.initWebrtc(null, false);
+ }
+
+ onStatusChange() {
+ const status = UserStore.getStatus(this.props.userId);
+
+ if (status === UserStatuses.OFFLINE) {
+ const error = (
+ <FormattedMessage
+ id='webrtc.offline'
+ defaultMessage='{username} is offline'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ );
+
+ if (this.state.isCalling || this.state.isAnswering) {
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error
+ });
+ } else {
+ this.setState({
+ error
+ });
+ }
+ } else if (status !== UserStatuses.OFFLINE && this.state.error) {
+ this.setState({
+ error: null,
+ ended: null
+ });
+ }
+ }
+
+ onCallDeclined() {
+ let error = null;
+
+ if (this.state.isCalling) {
+ error = (
+ <FormattedMessage
+ id='webrtc.declined'
+ defaultMessage='Your call has been declined by {username}'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ );
+ }
+
+ this.stopRinging();
+
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error
+ });
+
+ this.doCleanup();
+ }
+
+ onUnsupported() {
+ if (this.mounted) {
+ this.stopRinging();
+
+ this.setState({
+ error: (
+ <FormattedMessage
+ id='webrtc.unsupported'
+ defaultMessage='Call to {username} not successful. Their client does not support video calls.'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ ),
+ callInProgress: false,
+ isCalling: false,
+ isAnswering: false
+ });
+ }
+
+ this.doCleanup();
+ }
+
+ onNoAnswer() {
+ let error = null;
+
+ if (this.state.isCalling) {
+ error = (
+ <FormattedMessage
+ id='webrtc.noAnswer'
+ defaultMessage='{username} is not answering the call'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ );
+ }
+ this.stopRinging();
+
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error
+ });
+
+ this.doCleanup();
+ }
+
+ onBusy() {
+ let error = null;
+
+ if (this.state.isCalling) {
+ error = (
+ <FormattedMessage
+ id='webrtc.busy'
+ defaultMessage='{username} is busy'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ );
+ }
+ this.stopRinging();
+
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error
+ });
+
+ this.doCleanup();
+ }
+
+ onDisabled() {
+ let error = null;
+
+ if (this.state.isCalling) {
+ error = (
+ <FormattedMessage
+ id='webrtc.disabled'
+ defaultMessage='{username} has WebRTC disabled, and cannot receive calls. To enable the feature, they must go to Account Settings > Advanced > Preview pre-release features and turn on WebRTC.'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ );
+ }
+
+ this.stopRinging();
+
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error
+ });
+
+ this.doCleanup();
+ }
+
+ onFailed() {
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ isPaused: false,
+ isMuted: false,
+ isRemotePaused: false,
+ isRemoteMuted: false,
+ error: (
+ <FormattedMessage
+ id='webrtc.failed'
+ defaultMessage='There was a problem connecting the video call'
+ />
+ )
+ });
+
+ this.stopRinging();
+
+ this.doCleanup();
+ }
+
+ onCancelled() {
+ if (this.mounted && this.state.isAnswering) {
+ this.stopRinging();
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ error: (
+ <FormattedMessage
+ id='webrtc.cancelled'
+ defaultMessage='{username} cancelled the call'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ )
+ });
+ }
+
+ this.doCleanup();
+ }
+
+ onConnectCall() {
+ Client.webrtcToken(
+ (info) => {
+ const connectingMsg = (
+ <FormattedMessage
+ id='connecting_screen'
+ defaultMessage='Connecting'
+ />
+ );
+
+ this.setState({isAnswering: !this.state.isCalling, connectingMsg});
+ if (this.session) {
+ this.onSessionCreated();
+ } else {
+ const iceServers = [];
+
+ if (info.stun_uri) {
+ iceServers.push({
+ urls: [info.stun_uri]
+ });
+ }
+
+ if (info.turn_uri) {
+ iceServers.push({
+ urls: [info.turn_uri],
+ username: info.turn_username,
+ credential: info.turn_password
+ });
+ }
+
+ this.session = new WebrtcSession({
+ debug: global.mm_config.EnableDeveloper === 'true',
+ server: info.gateway_url,
+ iceServers,
+ token: info.token,
+ success: this.onSessionCreated,
+ error: this.onSessionError
+ });
+ }
+ },
+ () => {
+ this.onSessionError();
+ });
+ }
+
+ onSessionCreated() {
+ if (this.videocall) {
+ this.doCall();
+ } else {
+ this.session.attach({
+ plugin: 'janus.plugin.videocall',
+ success: (plugin) => {
+ this.videocall = plugin;
+ this.videocall.send({message: {request: 'register', username: this.state.currentUser.id}});
+ },
+ error: this.onSessionError,
+ onmessage: this.handleVideoCallEvent,
+ onremotestream: this.handleRemoteStream
+ });
+ }
+ }
+
+ onSessionError() {
+ const user = this.state.currentUser;
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.FAILED,
+ from_user_id: user.id,
+ to_user_id: this.props.userId
+ });
+
+ this.onFailed();
+ }
+
+ doCall() {
+ // delay call so receiver has time to register
+ setTimeout(() => {
+ this.videocall.createOffer({
+ stream: this.localMedia,
+ success: (jsep) => {
+ const body = {request: 'call', username: this.props.userId};
+ this.videocall.send({message: body, jsep});
+ },
+ error: () => {
+ this.doHangup(true);
+ }
+ });
+ }, Constants.WEBRTC_TIME_DELAY);
+ }
+
+ doAnswer(jsep) {
+ this.videocall.createAnswer({
+ jsep,
+ stream: this.localMedia,
+ success: (jsepSuccess) => {
+ const body = {request: 'accept'};
+ this.videocall.send({message: body, jsep: jsepSuccess});
+ },
+ error: () => {
+ this.doHangup(true);
+ }
+ });
+ }
+
+ doHangup(error, manual) {
+ if (this.videocall && this.state.callInProgress) {
+ this.videocall.send({message: {request: 'hangup'}});
+ this.videocall.hangup();
+ this.toggleIcons();
+
+ this.localMedia.getVideoTracks()[0].enabled = true;
+ this.localMedia.getAudioTracks()[0].enabled = true;
+ }
+
+ if (error) {
+ this.onSessionError();
+ return this.doCleanup();
+ }
+ WebrtcStore.setVideoCallWith(null);
+
+ if (manual) {
+ return this.close();
+ }
+
+ this.setState({
+ isCalling: false,
+ isAnswering: false,
+ callInProgress: false,
+ isPaused: false,
+ isMuted: false,
+ isRemotePaused: false,
+ isRemoteMuted: false,
+ error: null,
+ ended: (
+ <FormattedMessage
+ id='webrtc.callEnded'
+ defaultMessage='Call with {username} ended.'
+ values={{
+ username: Utils.displayUsername(this.props.userId)
+ }}
+ />
+ )
+ });
+ this.clearError();
+ return this.doCleanup();
+ }
+
+ doCleanup() {
+ WebrtcStore.setVideoCallWith(null);
+
+ if (this.videocall) {
+ this.videocall.detach();
+ this.videocall = null;
+ }
+ }
+
+ onToggleVideo() {
+ const shouldPause = !this.state.isPaused;
+ if (shouldPause) {
+ this.videocall.unmuteVideo();
+ } else {
+ this.videocall.muteVideo();
+ }
+
+ const user = this.state.currentUser;
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.MUTED,
+ from_user_id: user.id,
+ to_user_id: this.props.userId,
+ type: 'video',
+ mute: shouldPause
+ });
+
+ this.setState({
+ isPaused: shouldPause,
+ error: null,
+ ended: null
+ });
+ }
+
+ onToggleAudio() {
+ const shouldMute = !this.state.isMuted;
+ if (shouldMute) {
+ this.videocall.unmuteAudio();
+ } else {
+ this.videocall.muteAudio();
+ }
+
+ const user = this.state.currentUser;
+ WebSocketClient.sendMessage('webrtc', {
+ action: WebrtcActionTypes.MUTED,
+ from_user_id: user.id,
+ to_user_id: this.props.userId,
+ type: 'audio',
+ mute: shouldMute
+ });
+
+ this.setState({
+ isMuted: shouldMute,
+ error: null,
+ ended: null
+ });
+ }
+
+ onToggleRemoteMute(message) {
+ if (message.type === 'video') {
+ this.setState({
+ isRemotePaused: message.mute
+ });
+ } else {
+ this.setState({isRemoteMuted: message.mute, error: null, ended: null});
+ }
+ }
+
+ toggleIcons() {
+ const icons = this.refs.icons;
+ if (icons) {
+ icons.classList.toggle('hidden');
+ icons.classList.toggle('active');
+ }
+ }
+
+ renderButtons() {
+ let buttons;
+ if (this.state.isCalling) {
+ buttons = (
+ <svg
+ id='cancel'
+ className='webrtc-icons__cancel'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={() => this.handleCancelOffer()}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>
+ <FormattedMessage
+ id='webrtc.cancel'
+ defaultMessage='Cancel Call'
+ />
+ </title>
+ </circle>
+ <path
+ transform='scale(0.8), translate(6,10)'
+ d='M24 18c-3.21 0-6.3.5-9.2 1.44v6.21c0 .79-.46 1.47-1.12 1.8-1.95.98-3.74 2.23-5.33 3.7-.36.35-.85.57-1.4.57-.55 0-1.05-.22-1.41-.59L.59 26.18c-.37-.37-.59-.87-.59-1.42 0-.55.22-1.05.59-1.42C6.68 17.55 14.93 14 24 14s17.32 3.55 23.41 9.34c.37.36.59.87.59 1.42 0 .55-.22 1.05-.59 1.41l-4.95 4.95c-.36.36-.86.59-1.41.59-.54 0-1.04-.22-1.4-.57-1.59-1.47-3.38-2.72-5.33-3.7-.66-.33-1.12-1.01-1.12-1.8v-6.21C30.3 18.5 27.21 18 24 18z'
+ fill='white'
+ />
+ </svg>
+ );
+ } else if (!this.state.callInProgress && this.state.localMediaLoaded) {
+ buttons = (
+ <svg
+ id='call'
+ className='webrtc-icons__call'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={() => this.handleMakeOffer()}
+ disabled={UserStore.getStatus(this.props.userId) === 'offline'}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>
+ <FormattedMessage
+ id='webrtc.call'
+ defaultMessage='Call'
+ />
+ </title>
+ </circle>
+ <path
+ transform='translate(-10,-10)'
+ fill='#fff'
+ d='M29.854,37.627c1.723,1.904 3.679,3.468 5.793,4.684l3.683,-3.334c0.469,-0.424 1.119,-0.517 1.669,-0.302c1.628,0.63 3.331,1.021 5.056,1.174c0.401,0.026 0.795,0.199 1.09,0.525c0.295,0.326 0.433,0.741 0.407,1.153l-0.279,5.593c-0.02,0.418 -0.199,0.817 -0.525,1.112c-0.326,0.296 -0.741,0.434 -1.159,0.413c-6.704,-0.504 -13.238,-3.491 -18.108,-8.87c-4.869,-5.38 -7.192,-12.179 -7.028,-18.899c0.015,-0.413 0.199,-0.817 0.526,-1.113c0.326,-0.295 0.74,-0.433 1.153,-0.407l5.593,0.279c0.407,0.02 0.812,0.193 1.107,0.519c0.29,0.32 0.428,0.735 0.413,1.137c-0.018,1.732 0.202,3.464 0.667,5.147c0.159,0.569 0.003,1.207 -0.466,1.631l-3.683,3.334c1.005,2.219 2.368,4.32 4.091,6.224Z'
+ />
+ </svg>
+ );
+ } else if (this.state.callInProgress) {
+ const onClass = 'on';
+ const offClass = 'off';
+ let audioOnClass = offClass;
+ let audioOffClass = onClass;
+ let videoOnClass = offClass;
+ let videoOffClass = onClass;
+
+ let audioTitle = (
+ <FormattedMessage
+ id='webrtc.mute_audio'
+ defaultMessage='Mute'
+ />
+ );
+
+ let videoTitle = (
+ <FormattedMessage
+ id='webrtc.pause_video'
+ defaultMessage='Turn off Video'
+ />
+ );
+
+ if (this.state.isMuted) {
+ audioOnClass = onClass;
+ audioOffClass = offClass;
+ audioTitle = (
+ <FormattedMessage
+ id='webrtc.unmute_audio'
+ defaultMessage='Unmute'
+ />
+ );
+ }
+
+ if (this.state.isPaused) {
+ videoOnClass = onClass;
+ videoOffClass = offClass;
+ videoTitle = (
+ <FormattedMessage
+ id='webrtc.unpause_video'
+ defaultMessage='Turn on Video'
+ />
+ );
+ }
+
+ buttons = (
+ <div
+ ref='icons'
+ className='webrtc-icons hidden'
+ >
+
+ <svg
+ id='mute-audio'
+ className='webrtc-icons__call'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={() => this.onToggleAudio()}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>{audioTitle}</title>
+ </circle>
+ <path
+ className={audioOnClass}
+ transform='scale(0.6), translate(17,18)'
+ d='M38 22h-3.4c0 1.49-.31 2.87-.87 4.1l2.46 2.46C37.33 26.61 38 24.38 38 22zm-8.03.33c0-.11.03-.22.03-.33V10c0-3.32-2.69-6-6-6s-6 2.68-6 6v.37l11.97 11.96zM8.55 6L6 8.55l12.02 12.02v1.44c0 3.31 2.67 6 5.98 6 .45 0 .88-.06 1.3-.15l3.32 3.32c-1.43.66-3 1.03-4.62 1.03-5.52 0-10.6-4.2-10.6-10.2H10c0 6.83 5.44 12.47 12 13.44V42h4v-6.56c1.81-.27 3.53-.9 5.08-1.81L39.45 42 42 39.46 8.55 6z'
+ fill='white'
+ />
+ <path
+ className={audioOffClass}
+ transform='scale(0.6), translate(17,18)'
+ d='M24 28c3.31 0 5.98-2.69 5.98-6L30 10c0-3.32-2.68-6-6-6-3.31 0-6 2.68-6 6v12c0 3.31 2.69 6 6 6zm10.6-6c0 6-5.07 10.2-10.6 10.2-5.52 0-10.6-4.2-10.6-10.2H10c0 6.83 5.44 12.47 12 13.44V42h4v-6.56c6.56-.97 12-6.61 12-13.44h-3.4z'
+ fill='white'
+ />
+ </svg>
+
+ <svg
+ id='mute-video'
+ className='webrtc-icons__call'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={() => this.onToggleVideo()}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>{videoTitle}</title>
+ </circle>
+ <path
+ className={videoOnClass}
+ transform='scale(0.6), translate(17,16)'
+ d='M40 8H15.64l8 8H28v4.36l1.13 1.13L36 16v12.36l7.97 7.97L44 36V12c0-2.21-1.79-4-4-4zM4.55 2L2 4.55l4.01 4.01C4.81 9.24 4 10.52 4 12v24c0 2.21 1.79 4 4 4h29.45l4 4L44 41.46 4.55 2zM12 16h1.45L28 30.55V32H12V16z'
+ fill='white'
+ />
+ <path
+ className={videoOffClass}
+ transform='scale(0.6), translate(17,16)'
+ d='M40 8H8c-2.21 0-4 1.79-4 4v24c0 2.21 1.79 4 4 4h32c2.21 0 4-1.79 4-4V12c0-2.21-1.79-4-4-4zm-4 24l-8-6.4V32H12V16h16v6.4l8-6.4v16z'
+ fill='white'
+ />
+ </svg>
+
+ <svg
+ id='hangup'
+ className='webrtc-icons__cancel'
+ xmlns='http://www.w3.org/2000/svg'
+ width='48'
+ height='48'
+ viewBox='-10 -10 68 68'
+ onClick={() => this.doHangup(false, true)}
+ >
+ <circle
+ cx='24'
+ cy='24'
+ r='34'
+ >
+ <title>
+ <FormattedMessage
+ id='webrtc.hangup'
+ defaultMessage='Hangup'
+ />
+ </title>
+ </circle>
+ <path
+ transform='scale(0.7), translate(11,10)'
+ d='M24 18c-3.21 0-6.3.5-9.2 1.44v6.21c0 .79-.46 1.47-1.12 1.8-1.95.98-3.74 2.23-5.33 3.7-.36.35-.85.57-1.4.57-.55 0-1.05-.22-1.41-.59L.59 26.18c-.37-.37-.59-.87-.59-1.42 0-.55.22-1.05.59-1.42C6.68 17.55 14.93 14 24 14s17.32 3.55 23.41 9.34c.37.36.59.87.59 1.42 0 .55-.22 1.05-.59 1.41l-4.95 4.95c-.36.36-.86.59-1.41.59-.54 0-1.04-.22-1.4-.57-1.59-1.47-3.38-2.72-5.33-3.7-.66-.33-1.12-1.01-1.12-1.8v-6.21C30.3 18.5 27.21 18 24 18z'
+ fill='white'
+ />
+ </svg>
+
+ </div>
+ );
+ }
+
+ return buttons;
+ }
+
+ render() {
+ const currentId = UserStore.getCurrentId();
+ const remoteImage = (<img src={this.state.remoteUserImage}/>);
+ let localImage;
+ let localVideoHidden;
+ let remoteVideoHidden = 'hidden';
+ let error;
+ let remoteMute;
+ let videoClass = '';
+ let localImageHidden = 'webrtc__local-image hidden';
+ let remoteImageHidden = 'webrtc__remote-image';
+
+ if (this.state.error) {
+ error = (
+ <div className='webrtc__error'>
+ <div className='form-group has-error'>
+ <label className='control-label'>{this.state.error}</label>
+ </div>
+ </div>
+ );
+ } else if (this.state.ended) {
+ error = (
+ <div className='webrtc__error'>
+ <div className='form-group'>
+ <label className='control-label'>{this.state.ended}</label>
+ </div>
+ </div>
+ );
+ }
+
+ if (this.state.isRemoteMuted) {
+ remoteMute = (
+ <div className='webrtc__remote-mute'>
+ <svg
+ xmlns='http://www.w3.org/2000/svg'
+ width='60'
+ height='60'
+ viewBox='-10 -10 68 68'
+ >
+ <path
+ className='off'
+ transform='scale(0.6), translate(17,18)'
+ d='M38 22h-3.4c0 1.49-.31 2.87-.87 4.1l2.46 2.46C37.33 26.61 38 24.38 38 22zm-8.03.33c0-.11.03-.22.03-.33V10c0-3.32-2.69-6-6-6s-6 2.68-6 6v.37l11.97 11.96zM8.55 6L6 8.55l12.02 12.02v1.44c0 3.31 2.67 6 5.98 6 .45 0 .88-.06 1.3-.15l3.32 3.32c-1.43.66-3 1.03-4.62 1.03-5.52 0-10.6-4.2-10.6-10.2H10c0 6.83 5.44 12.47 12 13.44V42h4v-6.56c1.81-.27 3.53-.9 5.08-1.81L39.45 42 42 39.46 8.55 6z'
+ fill='white'
+ />
+ <path
+ className='on'
+ transform='scale(0.6), translate(17,18)'
+ d='M24 28c3.31 0 5.98-2.69 5.98-6L30 10c0-3.32-2.68-6-6-6-3.31 0-6 2.68-6 6v12c0 3.31 2.69 6 6 6zm10.6-6c0 6-5.07 10.2-10.6 10.2-5.52 0-10.6-4.2-10.6-10.2H10c0 6.83 5.44 12.47 12 13.44V42h4v-6.56c6.56-.97 12-6.61 12-13.44h-3.4z'
+ fill='white'
+ />
+ </svg>
+ </div>
+ );
+ }
+
+ let searchForm;
+ if (currentId != null) {
+ searchForm = <SearchBox/>;
+ }
+
+ const buttons = this.renderButtons();
+ const calling = this.state.isCalling;
+ let connecting;
+ let audio;
+ if (calling || this.state.isAnswering) {
+ if (calling) {
+ audio = (
+ <audio
+ ref='ring'
+ src={ring}
+ autoPlay={true}
+ />
+ );
+ }
+
+ connecting = (
+ <div className='connecting'>
+ <ConnectingScreen
+ position='absolute'
+ message={this.state.connectingMsg}
+ />
+ {audio}
+ </div>
+ );
+ }
+
+ if (this.state.callInProgress) {
+ if (this.state.isPaused) {
+ localVideoHidden = 'hidden';
+ localImageHidden = 'webrtc__local-image';
+ localImage = (<img src={this.state.currentUserImage}/>);
+ }
+
+ if (this.state.isRemotePaused) {
+ remoteVideoHidden = 'hidden';
+ remoteImageHidden = 'webrtc__remote-image';
+ } else {
+ remoteVideoHidden = '';
+ remoteImageHidden = 'webrtc__remote-image hidden';
+ }
+ } else {
+ videoClass = 'small';
+ }
+
+ return (
+ <div className='post-right__container'>
+ <div className='search-bar__container sidebar--right__search-header'>{searchForm}</div>
+ <div className='sidebar-right__body'>
+ <WebrtcHeader
+ username={Utils.displayUsername(this.props.userId)}
+ onClose={this.handleClose}
+ toggleSize={this.props.toggleSize}
+ />
+ <div className='post-right__scroll'>
+ <div
+ id='videos'
+ className={videoClass}
+ >
+ {remoteMute}
+ <div
+ id='main-video'
+ className={remoteVideoHidden}
+ autoPlay={true}
+ >
+ <video
+ ref='main-video'
+ autoPlay={true}
+ />
+ </div>
+ <div
+ id='local-video'
+ className={localVideoHidden}
+ >
+ <video
+ ref='local-video'
+ autoPlay={true}
+ muted={true}
+ />
+ </div>
+ <div className={remoteImageHidden}>
+ {remoteImage}
+ </div>
+ <div className={localImageHidden}>
+ {localImage}
+ </div>
+ </div>
+ {error}
+ {connecting}
+ <div className='webrtc-buttons'>
+ {buttons}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+WebrtcController.propTypes = {
+ currentUser: React.PropTypes.object,
+ userId: React.PropTypes.string.isRequired,
+ isCaller: React.PropTypes.bool.isRequired,
+ expanded: React.PropTypes.bool.isRequired,
+ toggleSize: React.PropTypes.function
+}; \ No newline at end of file
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index dac46c691..74ff88542 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -726,6 +726,7 @@
"admin.sidebar.users": "Users",
"admin.sidebar.usersAndTeams": "Users and Teams",
"admin.sidebar.view_statistics": "Site Statistics",
+ "admin.sidebar.webrtc": "WebRTC (Beta)",
"admin.sidebarHeader.systemConsole": "System Console",
"admin.sql.dataSource": "Data Source:",
"admin.sql.driverName": "Driver Name:",
@@ -829,7 +830,7 @@
"admin.webrtc.gatewayAdminSecretDescription": "Enter your admin secret password to access the Gateway Admin URL.",
"admin.webrtc.gatewayAdminSecretExample": "Ex \"PVRzWNN1Tg6szn7IQWvhpAvLByScWxdy\"",
"admin.webrtc.gatewayAdminSecretTitle": "Gateway Admin Secret:",
- "admin.webrtc.gatewayAdminUrlDescription": "Enter https://<mattermost-webrtc-gateway-url>:<port>. Make sure you use HTTP or HTTPS in your URL depending on your server configuration. Mattermost WebRTC uses this URL to obtain valid tokens for each peer to establish the connection.",
+ "admin.webrtc.gatewayAdminUrlDescription": "Enter https://<mattermost-webrtc-gateway-url>:<port>/admin. Make sure you use HTTP or HTTPS in your URL depending on your server configuration. Mattermost WebRTC uses this URL to obtain valid tokens for each peer to establish the connection.",
"admin.webrtc.gatewayAdminUrlExample": "Ex \"https://webrtc.mattermost.com:7089/admin\"",
"admin.webrtc.gatewayAdminUrlTitle": "Gateway Admin URL:",
"admin.webrtc.gatewayWebsocketUrlDescription": "Enter wss://<mattermost-webrtc-gateway-url>:<port>. Make sure you use WS or WSS in your URL depending on your server configuration. This is the websocket used to signal and establish communication between the peers.",
@@ -951,6 +952,7 @@
"backstage_sidebar.integrations.incoming_webhooks": "Incoming Webhooks",
"backstage_sidebar.integrations.oauthApps": "OAuth 2.0 Applications",
"backstage_sidebar.integrations.outgoing_webhooks": "Outgoing Webhooks",
+ "calling_screen": "Calling",
"center_panel.recent": "Click here to jump to recent messages. ",
"chanel_header.addMembers": "Add Members",
"change_url.close": "Close",
@@ -982,6 +984,8 @@
"channel_header.setPurpose": "Set {term} Purpose...",
"channel_header.viewInfo": "View Info",
"channel_header.viewMembers": "View Members",
+ "channel_header.webrtc.call": "Start Video Call",
+ "channel_header.webrtc.unavailable": "New call unavailable until your existing call ends",
"channel_info.about": "About",
"channel_info.close": "Close",
"channel_info.header": "Header:",
@@ -1773,6 +1777,7 @@
"user.settings.advance.sendTitle": "Send messages on CTRL + ENTER",
"user.settings.advance.slashCmd_autocmp": "Enable external application to offer slash command autocomplete",
"user.settings.advance.title": "Advanced Settings",
+ "user.settings.advance.webrtc_preview": "Enable the ability to make and receive one-on-one WebRTC calls",
"user.settings.custom_theme.awayIndicator": "Away Indicator",
"user.settings.custom_theme.buttonBg": "Button BG",
"user.settings.custom_theme.buttonColor": "Button Text",
@@ -2020,6 +2025,8 @@
"user.settings.security.title": "Security Settings",
"user.settings.security.viewHistory": "View Access History",
"user_list.notFound": "No users found",
+ "user_profile.webrtc.call": "Start Video Call",
+ "user_profile.webrtc.unavailable": "New call unavailable until your existing call ends",
"view_image.loading": "Loading ",
"view_image_popover.download": "Download",
"view_image_popover.file": "File {count} of {total}",
@@ -2030,5 +2037,31 @@
"web.footer.terms": "Terms",
"web.header.back": "Back",
"web.root.signup_info": "All team communication in one place, searchable and accessible anywhere",
- "youtube_video.notFound": "Video not found"
+ "webrtc.busy": "{username} is busy.",
+ "webrtc.call": "Call",
+ "webrtc.callEnded": "Call with {username} ended.",
+ "webrtc.cancel": "Cancel call",
+ "webrtc.cancelled": "{username} cancelled the call.",
+ "webrtc.declined": "Your call has been declined by {username}.",
+ "webrtc.disabled": "{username} has WebRTC disabled, and cannot receive calls. To enable the feature, they must go to Account Settings > Advanced > Preview pre-release features and turn on WebRTC.",
+ "webrtc.failed": "There was a problem connecting the video call.",
+ "webrtc.hangup": "Hangup",
+ "webrtc.header": "Call with {username}",
+ "webrtc.inProgress": "You have a call in progress. Please hangup first.",
+ "webrtc.mediaError": "Unable to access camera or microphone.",
+ "webrtc.mute_audio": "Mute microphone",
+ "webrtc.noAnswer": "{username} is not answering the call.",
+ "webrtc.notification.answer": "Answer",
+ "webrtc.notification.decline": "Decline",
+ "webrtc.notification.incoming_call": "{username} is calling you.",
+ "webrtc.notification.returnToCall": "Return to ongoing call with {username}",
+ "webrtc.offline": "{username} is offline.",
+ "webrtc.pause_video": "Turn off camera",
+ "webrtc.unmute_audio": "Unmute microphone",
+ "webrtc.unpause_video": "Turn on camera",
+ "webrtc.unsupported": "{username} client does not support video calls.",
+ "youtube_video.notFound": "Video not found",
+ "channel_header.webrtc.unavailable": "New call unavailable until your existing call ends",
+ "user_profile.webrtc.unavailable": "New call unavailable until your existing call ends",
+ "admin.sidebar.webrtc": "WebRTC (Beta)"
}
diff --git a/webapp/images/ring.mp3 b/webapp/images/ring.mp3
new file mode 100644
index 000000000..d7d32bce4
--- /dev/null
+++ b/webapp/images/ring.mp3
Binary files differ
diff --git a/webapp/package.json b/webapp/package.json
index 2ad477f4d..394cb02f1 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -33,6 +33,7 @@
"superagent": "2.1.0",
"twemoji": "2.0.5",
"velocity-animate": "1.2.3",
+ "webrtc-adapter": "1.4.0",
"xregexp": "3.1.1"
},
"devDependencies": {
diff --git a/webapp/sass/components/_module.scss b/webapp/sass/components/_module.scss
index 8c2c575c2..3e587707d 100644
--- a/webapp/sass/components/_module.scss
+++ b/webapp/sass/components/_module.scss
@@ -19,3 +19,4 @@
@import 'tooltip';
@import 'tutorial';
@import 'videos';
+@import 'webrtc';
diff --git a/webapp/sass/components/_webrtc.scss b/webapp/sass/components/_webrtc.scss
new file mode 100644
index 000000000..b8fc64442
--- /dev/null
+++ b/webapp/sass/components/_webrtc.scss
@@ -0,0 +1,297 @@
+@charset 'UTF-8';
+
+.webrtc__user-profile {
+ @include webrtc-button;
+ text-align: center;
+
+ #webrtc-btn {
+ display: inherit;
+ }
+}
+
+.webrtc__header {
+ @include webrtc-button;
+ float: left;
+ margin-right: 10px;
+ position: relative;
+ top: 13px;
+}
+
+.webrtc__notification--rhs {
+ background: $button--ready;
+ border-radius: 3px 0 0 3px;
+ bottom: 10px;
+ color: $white;
+ cursor: pointer;
+ padding: 10px 15px;
+ position: absolute;
+ right: 0;
+ z-index: 9999;
+
+ &:empty {
+ display: none;
+ }
+
+ .fa {
+ margin-right: 6px;
+ }
+}
+
+.webrtc__error {
+ padding: 0 20px;
+ text-align: center;
+
+ .control-label {
+ @include border-radius(3px);
+ background: #f2f2f2;
+ font-size: 14px;
+ font-weight: normal;
+ margin: 1em 0 -1em;
+ padding: .7em 1em;
+ width: 100%;
+ }
+
+ .has-error {
+ .control-label {
+ color: #a94442;
+ font-size: .95em;
+
+ &:before {
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+ color: #a94442;
+ content: '\F071';
+ display: inline-block;
+ font: normal normal normal 14px/1 FontAwesome;
+ margin-right: 4px;
+ text-rendering: auto;
+ transform: translate(0, 0);
+ }
+ }
+ }
+}
+
+#videos {
+ background: $black;
+ font-size: 0;
+ left: 0;
+ pointer-events: none;
+ position: relative;
+ text-align: center;
+ transition: all 1s;
+ width: 100%;
+
+ #main-video video {
+ width: 100%;
+ }
+
+ #local-video {
+ border: 1px solid gray;
+ max-width: 30%;
+ opacity: 1;
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ transition: opacity 1s;
+ z-index: 2;
+
+ video {
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ -webkit-transform: scale(-1, 1);
+ height: 100%;
+ max-height: 100%;
+ max-width: 100%;
+ object-fit: cover;
+ transform: scale(-1, 1);
+ transition: opacity 1s;
+ width: 100%;
+ }
+ }
+
+ .webrtc__local-image {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ z-index: 2;
+
+ img {
+ border-radius: 50%;
+ height: 64px;
+ max-width: 100%;
+ width: 64px;
+ }
+ }
+
+ .webrtc__remote-image {
+ background-color: $black;
+ border: 1px solid alpha-color($black, .2);
+ padding: 40px 0;
+
+ img {
+ border-radius: 50%;
+ max-height: 128px;
+ max-width: 128px;
+ width: 100%;
+ }
+ }
+
+ .webrtc__remote-mute {
+ bottom: 0;
+ position: absolute;
+ right: 10px;
+ z-index: 9999;
+ }
+}
+
+.webrtc-buttons {
+ margin-top: 1.2em;
+ text-align: center;
+
+ .webrtc-icons__call {
+ border-radius: 48px;
+
+ circle {
+ fill: $button--ready;
+ }
+
+ &:hover {
+ cursor: pointer;
+
+ circle {
+ fill: darken($button--ready, 5%);
+ }
+ }
+
+ &[disabled] {
+ circle {
+ fill: $video-circle-offline;
+ }
+ }
+ }
+
+ .webrtc-icons__cancel {
+ border-radius: 48px;
+
+ circle {
+ fill: $button--cancel;
+ }
+
+ &:hover {
+ cursor: pointer;
+
+ circle {
+ fill: darken($button--cancel, 5%);
+ }
+ }
+ }
+}
+
+.webrtc-icons {
+ &.active {
+ svg {
+ transform: translateY(0);
+ }
+ }
+
+ svg {
+ border-radius: 48px;
+ margin: 5px;
+
+ path {
+ &.on {
+ display: block;
+ }
+
+ &.off {
+ display: none;
+ }
+ }
+ }
+}
+
+.connecting {
+ position: absolute;
+ top: 0;
+ width: 100%;
+
+ .loading-screen {
+ background-color: rgba(0,0,0, .3);
+ color: $connecting;
+ margin-left: 5px;
+ padding: 20px;
+ position: relative;
+ top: 75px;
+ width: 97%;
+
+ .loading__content {
+ .round {
+ background: $connecting !important;
+ }
+ }
+ }
+}
+
+.webrtc-notification {
+ background-color: $webrtc-notification-bg;
+ color: $white;
+ left: calc(50% - 200px);
+ padding: 20px 30px 10px;
+ position: absolute;
+ text-align: center;
+ top: calc(50% - 115px);
+ width: 400px;
+ z-index: 99999;
+
+ .webrtc-buttons {
+ margin-top: 1.2em;
+ text-align: center;
+
+ .webrtc-icons__pickup {
+ circle {
+ fill: $button--pickup;
+ }
+
+ &:hover {
+ cursor: pointer;
+
+ circle {
+ fill: darken($button--pickup, 3%);
+ }
+ }
+ }
+ }
+}
+
+.sidebar--right--expanded {
+ #videos {
+ height: 100%;
+ position: absolute;
+
+ #main-video {
+ height: 100%;
+
+ video {
+ height: 100%;
+ width: auto;
+ }
+ }
+ }
+
+ .webrtc-buttons {
+ bottom: 5px;
+ position: absolute;
+ width: 100%;
+ }
+
+ #videos {
+ #local-video {
+ max-width: 200px;
+ }
+ }
+
+ #videos.small {
+ position: relative;
+ }
+}
diff --git a/webapp/sass/layout/_content.scss b/webapp/sass/layout/_content.scss
index 514093f1f..22e8a5369 100644
--- a/webapp/sass/layout/_content.scss
+++ b/webapp/sass/layout/_content.scss
@@ -1,7 +1,8 @@
@charset 'UTF-8';
.inner-wrap {
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
.search-bar__container {
display: none;
}
diff --git a/webapp/sass/layout/_headers.scss b/webapp/sass/layout/_headers.scss
index c3a5f8fda..7e1ccc554 100644
--- a/webapp/sass/layout/_headers.scss
+++ b/webapp/sass/layout/_headers.scss
@@ -388,7 +388,8 @@
}
}
- .channel__wrap.move--left & {
+ .channel__wrap.move--left &,
+ .channel__wrap.webrtc--show & {
position: absolute;
right: -400px;
top: 14px;
diff --git a/webapp/sass/layout/_sidebar-right.scss b/webapp/sass/layout/_sidebar-right.scss
index a70c30b16..915bb2d09 100644
--- a/webapp/sass/layout/_sidebar-right.scss
+++ b/webapp/sass/layout/_sidebar-right.scss
@@ -8,7 +8,11 @@
position: fixed;
right: 0;
width: 400px;
- z-index: 5;
+ z-index: 6;
+
+ &.webrtc {
+ z-index: 5;
+ }
.sidebar--right__bg {
@include single-transition(background-color, .5s, ease);
@@ -20,7 +24,8 @@
width: 300%;
}
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
.sidebar--right__bg {
left: -100%;
}
diff --git a/webapp/sass/responsive/_desktop.scss b/webapp/sass/responsive/_desktop.scss
index d891fe17f..50df5ba02 100644
--- a/webapp/sass/responsive/_desktop.scss
+++ b/webapp/sass/responsive/_desktop.scss
@@ -2,7 +2,8 @@
@media screen and (max-width: 1800px) {
.inner-wrap {
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
.date-separator,
.new-separator {
&.hovered--comment {
@@ -18,7 +19,8 @@
@media screen and (max-width: 1440px) {
.inner-wrap {
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
.help__format-text {
display: none;
}
@@ -53,7 +55,8 @@
}
.inner-wrap {
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
.file-overlay {
font-size: em(18px);
diff --git a/webapp/sass/responsive/_mobile.scss b/webapp/sass/responsive/_mobile.scss
index e00b818e7..c39870bd8 100644
--- a/webapp/sass/responsive/_mobile.scss
+++ b/webapp/sass/responsive/_mobile.scss
@@ -779,7 +779,8 @@
display: block;
width: 290px;
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
@include translate3d(0, 0, 0);
}
}
@@ -958,7 +959,8 @@
}
}
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
margin: 0;
@include translate3d(-100%, 0, 0);
}
diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss
index 36b6af851..45fc4fad4 100644
--- a/webapp/sass/responsive/_tablet.scss
+++ b/webapp/sass/responsive/_tablet.scss
@@ -17,7 +17,8 @@
@include single-transition(all, .5s, ease);
@include translateX(100%);
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
-webkit-transform: translateX(0) !important;
-moz-transform: translateX(0) !important;
-ms-transform: translateX(0) !important;
@@ -53,7 +54,8 @@
margin-right: 0 !important;
}
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
margin-right: 0 !important;
.channel-header__links {
@@ -72,7 +74,8 @@
}
.sidebar--right {
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
@include translateX(0);
}
@@ -106,7 +109,8 @@
}
.inner-wrap {
- &.move--left {
+ &.move--left,
+ &.webrtc--show {
margin-right: 400px;
}
}
diff --git a/webapp/sass/routes/_print.scss b/webapp/sass/routes/_print.scss
index e7380e21c..079fc200e 100644
--- a/webapp/sass/routes/_print.scss
+++ b/webapp/sass/routes/_print.scss
@@ -78,7 +78,8 @@
margin-left: 0;
}
- #channel_view .inner-wrap.move--left {
+ #channel_view .inner-wrap.move--left,
+ #channel_view .inner-wrap.webrtc--show {
margin-right: 0;
}
diff --git a/webapp/sass/utils/_mixins.scss b/webapp/sass/utils/_mixins.scss
index 6e4488fca..e252086ae 100644
--- a/webapp/sass/utils/_mixins.scss
+++ b/webapp/sass/utils/_mixins.scss
@@ -26,4 +26,74 @@
@mixin cursor($value) {
cursor: -webkit-$value;
cursor: zoom-$value;
-} \ No newline at end of file
+}
+
+// Webrtc button
+@mixin webrtc-button {
+ .webrtc__button {
+ @include border-radius(50px);
+ display: block;
+ height: 32px;
+ width: 32px;
+
+ &.on,
+ &:hover {
+ background: darken($button--ready, 5%);
+ }
+
+ &:hover circle {
+ fill: darken($button--ready, 5%);
+ }
+
+ circle {
+ fill: $button--ready;
+
+ &.offline {
+ fill: $video-circle-offline;
+ }
+ }
+
+ path {
+ .on {
+ display: none;
+ }
+
+ .off {
+ display: block;
+ }
+ }
+
+ &.on {
+ path {
+ .on {
+ display: block;
+ }
+
+ .off {
+ display: none;
+ }
+ }
+
+ circle {
+ fill-opacity: 0;
+ }
+ }
+ }
+
+ a {
+ &[disabled] {
+ .webrtc__button {
+ &:hover {
+ background: none;
+ box-shadow: none;
+ }
+
+ &:hover {
+ circle {
+ fill: $video-circle-offline;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/webapp/sass/utils/_variables.scss b/webapp/sass/utils/_variables.scss
index 53004520e..ffbcc847e 100644
--- a/webapp/sass/utils/_variables.scss
+++ b/webapp/sass/utils/_variables.scss
@@ -17,3 +17,13 @@ $border-gray: 1px solid #ddd;
// Random variables
$border-rad: 1px;
+
+// Webrtc Variables
+$button--ready: #00A6EE;
+$video-circle-btn: #2f81b7;
+$video-circle-hover: #56C303;
+$video-circle-offline: #aaa;
+$button--cancel: #E41020;
+$button--pickup: #73B001;
+$connecting: $white;
+$webrtc-notification-bg: rgba(112, 128, 144, .8); \ No newline at end of file
diff --git a/webapp/stores/webrtc_store.jsx b/webapp/stores/webrtc_store.jsx
new file mode 100644
index 000000000..8213b15aa
--- /dev/null
+++ b/webapp/stores/webrtc_store.jsx
@@ -0,0 +1,110 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
+import EventEmitter from 'events';
+import {WebrtcActionTypes} from 'utils/constants.jsx';
+
+class WebrtcStoreClass extends EventEmitter {
+ constructor() {
+ super();
+
+ this.video_call_with = null;
+ }
+
+ setVideoCallWith(userId) {
+ this.video_call_with = userId;
+ this.emitBusy(userId !== null);
+ }
+
+ getVideoCallWith() {
+ return this.video_call_with;
+ }
+
+ isBusy() {
+ return this.video_call_with !== null;
+ }
+
+ emitInit(userId, isCaller) {
+ this.emit(WebrtcActionTypes.INITIALIZE, userId, isCaller);
+ }
+
+ addInitListener(callback) {
+ this.on(WebrtcActionTypes.INITIALIZE, callback);
+ }
+
+ removeInitListener(callback) {
+ this.removeListener(WebrtcActionTypes.INITIALIZE, callback);
+ }
+
+ emitBusy(isBusy) {
+ this.emit(WebrtcActionTypes.BUSY, isBusy);
+ }
+
+ addBusyListener(callback) {
+ this.on(WebrtcActionTypes.BUSY, callback);
+ }
+
+ removeBusyListener(callback) {
+ this.removeListener(WebrtcActionTypes.BUSY, callback);
+ }
+
+ emitNotify(message) {
+ this.emit(WebrtcActionTypes.NOTIFY, message);
+ }
+
+ addNotifyListener(callback) {
+ this.on(WebrtcActionTypes.NOTIFY, callback);
+ }
+
+ removeNotifyListener(callback) {
+ this.removeListener(WebrtcActionTypes.NOTIFY, callback);
+ }
+
+ emitChanged(message) {
+ this.emit(WebrtcActionTypes.CHANGED, message);
+ }
+
+ addChangedListener(callback) {
+ this.on(WebrtcActionTypes.CHANGED, callback);
+ }
+
+ removeChangedListener(callback) {
+ this.removeListener(WebrtcActionTypes.CHANGED, callback);
+ }
+
+ emitRhsChanged(isOpen) {
+ this.emit(WebrtcActionTypes.RHS, isOpen);
+ }
+
+ addRhsChangedListener(callback) {
+ this.on(WebrtcActionTypes.RHS, callback);
+ }
+
+ removeRhsChangedListener(callback) {
+ this.removeListener(WebrtcActionTypes.RHS, callback);
+ }
+}
+
+var WebrtcStore = new WebrtcStoreClass();
+WebrtcStore.setMaxListeners(0);
+
+WebrtcStore.dispatchToken = AppDispatcher.register((payload) => {
+ var action = payload.action;
+
+ switch (action.type) {
+ case WebrtcActionTypes.INITIALIZE:
+ WebrtcStore.emitInit(action.user_id, action.is_calling);
+ break;
+ case WebrtcActionTypes.NOTIFY:
+ WebrtcStore.emitNotify(action.message);
+ break;
+ default:
+ if (action.message) {
+ WebrtcStore.emitChanged(action.message);
+ }
+ break;
+ }
+});
+
+export default WebrtcStore; \ No newline at end of file
diff --git a/webapp/utils/channel_intro_messages.jsx b/webapp/utils/channel_intro_messages.jsx
index f94fab489..1d5f29f09 100644
--- a/webapp/utils/channel_intro_messages.jsx
+++ b/webapp/utils/channel_intro_messages.jsx
@@ -55,7 +55,10 @@ export function createDMIntroMessage(channel, centeredIntro) {
</div>
<div className='channel-intro-profile'>
<strong>
- <UserProfile user={teammate}/>
+ <UserProfile
+ user={teammate}
+ disablePopover={true}
+ />
</strong>
</div>
<p className='channel-intro-text'>
diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx
index b4eb1d11a..602b6ae4e 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -154,9 +154,55 @@ export const ActionTypes = keyMirror({
SUGGESTION_SELECT_PREVIOUS: null
});
+export const WebrtcActionTypes = keyMirror({
+ INITIALIZE: null,
+ NOTIFY: null,
+ CHANGED: null,
+ ANSWER: null,
+ DECLINE: null,
+ CANCEL: null,
+ NO_ANSWER: null,
+ BUSY: null,
+ FAILED: null,
+ UNSUPPORTED: null,
+ MUTED: null,
+ IN_PROGRESS: null,
+ DISABLED: null,
+ RHS: null
+});
+
+export const UserStatuses = {
+ OFFLINE: 'offline',
+ AWAY: 'away',
+ ONLINE: 'online'
+};
+
+export const SocketEvents = {
+ POSTED: 'posted',
+ POST_EDITED: 'post_edited',
+ POST_DELETED: 'post_deleted',
+ CHANNEL_DELETED: 'channel_deleted',
+ CHANNEL_VIEWED: 'channel_viewed',
+ DIRECT_ADDED: 'direct_added',
+ NEW_USER: 'new_user',
+ LEAVE_TEAM: 'leave_team',
+ USER_ADDED: 'user_added',
+ USER_REMOVED: 'user_removed',
+ USER_UPDATED: 'user_updated',
+ TYPING: 'typing',
+ PREFERENCE_CHANGED: 'preference_changed',
+ EPHEMERAL_MESSAGE: 'ephemeral_message',
+ STATUS_CHANGED: 'status_change',
+ HELLO: 'hello',
+ WEBRTC: 'webrtc'
+};
+
export const Constants = {
Preferences,
+ SocketEvents,
ActionTypes,
+ WebrtcActionTypes,
+ UserStatuses,
PayloadSources: keyMirror({
SERVER_ACTION: null,
@@ -183,25 +229,6 @@ export const Constants = {
STAT_MAX_ACTIVE_USERS: 20,
STAT_MAX_NEW_USERS: 20,
- SocketEvents: {
- POSTED: 'posted',
- POST_EDITED: 'post_edited',
- POST_DELETED: 'post_deleted',
- CHANNEL_DELETED: 'channel_deleted',
- CHANNEL_VIEWED: 'channel_viewed',
- DIRECT_ADDED: 'direct_added',
- NEW_USER: 'new_user',
- LEAVE_TEAM: 'leave_team',
- USER_ADDED: 'user_added',
- USER_REMOVED: 'user_removed',
- USER_UPDATED: 'user_updated',
- TYPING: 'typing',
- PREFERENCE_CHANGED: 'preference_changed',
- EPHEMERAL_MESSAGE: 'ephemeral_message',
- STATUS_CHANGED: 'status_change',
- HELLO: 'hello'
- },
-
UserUpdateEvents: {
USERNAME: 'username',
FULLNAME: 'fullname',
@@ -218,12 +245,6 @@ export const Constants = {
POST: 5
},
- UserStatuses: {
- OFFLINE: 'offline',
- AWAY: 'away',
- ONLINE: 'online'
- },
-
SPECIAL_MENTIONS: ['all', 'channel', 'here'],
CHARACTER_LIMIT: 4000,
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg'],
@@ -778,9 +799,15 @@ export const Constants = {
EMBED_PREVIEW: {
label: 'embed_preview',
description: 'Show preview snippet of links below message'
+ },
+ WEBRTC_PREVIEW: {
+ label: 'webrtc_preview',
+ description: 'Enable WebRTC one on one calls'
}
},
OVERLAY_TIME_DELAY: 400,
+ WEBRTC_TIME_DELAY: 750,
+ WEBRTC_CLEAR_ERROR_DELAY: 15000,
DEFAULT_MAX_USERS_PER_TEAM: 50,
MIN_TEAMNAME_LENGTH: 4,
MAX_TEAMNAME_LENGTH: 15,