summaryrefslogtreecommitdiffstats
path: root/webapp/components
diff options
context:
space:
mode:
Diffstat (limited to 'webapp/components')
-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
22 files changed, 2135 insertions, 43 deletions
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