feat(callee-info): Redesign.

This commit is contained in:
hristoterezov 2018-06-26 17:56:22 -05:00 committed by Hristo Terezov
parent 485ff81443
commit 769e782c6f
34 changed files with 536 additions and 605 deletions

View File

@ -1662,6 +1662,7 @@ export default {
const displayName = user.getDisplayName();
APP.store.dispatch(participantJoined({
botType: user.getBotType(),
conference: room,
id,
name: displayName,
@ -1862,6 +1863,17 @@ export default {
APP.UI.changeDisplayName(id, formattedDisplayName);
}
);
room.on(
JitsiConferenceEvents.BOT_TYPE_CHANGED,
(id, botType) => {
APP.store.dispatch(participantUpdated({
conference: room,
id,
botType
}));
}
);
room.on(
JitsiConferenceEvents.LOCK_STATE_CHANGED,

View File

@ -6,12 +6,10 @@
height: 100%;
position: fixed;
z-index: $ringingZ;
background: linear-gradient(transparent, #000);
opacity: 0.8;
@include transparentBg(#283447, 0.95);
&.solidBG {
background: $defaultBackground;
opacity: 1;
}
&__content {
@ -22,20 +20,26 @@
top: 50%;
margin-left: -200px;
margin-top: -125px;
font-weight: 400;
font-size: 14px;
text-align: center;
font-weight: normal;
color: #FFFFFF;
}
&__avatar {
width: 100px;
height: 100px;
width: 128px;
height: 128px;
border-radius: 50%;
border: 2px solid #1B2638;
}
&__caller-info {
.mention {
color: #333;
}
&__status{
margin-top: 15px;
font-size: 14px;
line-height: 20px;
}
&__name {
font-size: 24px;
line-height: 32px;
}
}

View File

@ -632,12 +632,12 @@
},
"presenceStatus": {
"invited": "Invited",
"ringing": "Ringing",
"calling": "Calling",
"initializingCall": "Initializing Call",
"ringing": "Ringing...",
"calling": "Calling...",
"initializingCall": "Initializing Call...",
"connected": "Connected",
"connecting": "Connecting",
"connecting2": "Connecting*",
"connecting": "Connecting...",
"connecting2": "Connecting*...",
"disconnected": "Disconnected",
"busy": "Busy",
"rejected": "Rejected",

View File

@ -113,8 +113,10 @@ function initCommands() {
switch (name) {
case 'invite':
// The store should be already available because API.init is called
// on appWillMount action.
APP.store.dispatch(
invite(request.invitees))
invite(request.invitees, true))
.then(failedInvitees => {
let error;
let result;

View File

@ -238,7 +238,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
}
})
});
this._invitees = invitees;
this.invite(invitees);
this._isLargeVideoVisible = true;
this._numberOfParticipants = 0;
this._participants = {};
@ -369,9 +369,6 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
switch (name) {
case 'video-conference-joined':
if (this._invitees) {
this.invite(this._invitees);
}
this._myUserID = userID;
this._participants[userID] = {
avatarURL: data.avatarURL

View File

@ -492,7 +492,10 @@ UI.updateUserRole = user => {
* @param {string} status - The new status.
*/
UI.updateUserStatus = (user, status) => {
if (!status) {
const reduxState = APP.store.getState() || {};
const { calleeInfoVisible } = reduxState['features/invite'] || {};
if (!status || calleeInfoVisible) {
return;
}

View File

@ -428,7 +428,12 @@ export default class LargeVideoManager {
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<PresenceLabel participantID = { id } />
<PresenceLabel
noContentStyles = { {
className: 'presence-label no-presence'
} }
participantID = { id }
styles = { { className: 'presence-label' } } />
</I18nextProvider>
</Provider>,
presenceLabelContainer.get(0));

View File

@ -573,7 +573,12 @@ RemoteVideo.prototype.addPresenceLabel = function() {
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<PresenceLabel participantID = { this.id } />
<PresenceLabel
noContentStyles = { {
className: 'presence-label no-presence'
} }
participantID = { this.id }
styles = { { className: 'presence-label' } } />
</I18nextProvider>
</Provider>,
presenceLabelContainer);

View File

@ -144,6 +144,7 @@ function _addConferenceListeners(conference, dispatch) {
conference.on(
JitsiConferenceEvents.USER_JOINED,
(id, user) => !user.isHidden() && dispatch(participantJoined({
botType: user.getBotType(),
conference,
id,
name: user.getDisplayName(),
@ -161,6 +162,14 @@ function _addConferenceListeners(conference, dispatch) {
JitsiConferenceEvents.USER_STATUS_CHANGED,
(...args) => dispatch(participantPresenceChanged(...args)));
conference.on(
JitsiConferenceEvents.BOT_TYPE_CHANGED,
(id, botType) => dispatch(participantUpdated({
conference,
id,
botType
})));
conference.addCommandListener(
AVATAR_ID_COMMAND,
(data, id) => dispatch(participantUpdated({

View File

@ -1,13 +1,3 @@
/**
* The type of redux action which sets the visibility of {@code CalleeInfo}.
*
* {
* type: SET_CALLEE_INFO_VISIBLE,
* calleeInfoVisible: boolean
* }
*/
export const SET_CALLEE_INFO_VISIBLE = Symbol('SET_CALLEE_INFO_VISIBLE');
/**
* The type of redux action which stores a specific JSON Web Token (JWT) into
* the redux store.

View File

@ -1,28 +1,6 @@
// @flow
import { SET_CALLEE_INFO_VISIBLE, SET_JWT } from './actionTypes';
/**
* Sets the visibility of {@code CalleeInfo}.
*
* @param {boolean|undefined} [calleeInfoVisible] - If {@code CalleeInfo} is
* to be displayed/visible, then {@code true}; otherwise, {@code false} or
* {@code undefined}.
* @returns {{
* type: SET_CALLEE_INFO_VISIBLE,
* calleeInfoVisible: (boolean|undefined)
* }}
*/
export function setCalleeInfoVisible(calleeInfoVisible: ?boolean) {
return (dispatch: Dispatch<*>, getState: Function) => {
getState()['features/base/jwt']
.calleeInfoVisible === calleeInfoVisible
|| dispatch({
type: SET_CALLEE_INFO_VISIBLE,
calleeInfoVisible
});
};
}
import { SET_JWT } from './actionTypes';
/**
* Stores a specific JSON Web Token (JWT) into the redux store.

View File

@ -1,347 +0,0 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Audio } from '../../media';
import { Avatar } from '../../participants';
import { Container, Text } from '../../react';
import UIEvents from '../../../../../service/UI/UIEvents';
import styles from './styles';
declare var $: Object;
declare var APP: Object;
declare var interfaceConfig: Object;
/**
* The type of the React {@code Component} props of {@link CalleeInfo}.
*/
type Props = {
/**
* The callee's information such as avatar and display name.
*/
_callee: Object
};
/**
* The type of the React {@code Component} state of {@link CalleeInfo}.
*/
type State = {
/**
* The CSS class (name), if any, to add to this {@code CalleeInfo}.
*
* @type {string}
*/
className: ?string,
/**
* The indicator which determines whether this {@code CalleeInfo}
* should play/render audio to indicate the ringing phase of the
* call establishment between the local participant and the
* associated remote callee.
*
* @type {boolean}
*/
renderAudio: boolean,
/**
* The indicator which determines whether this {@code CalleeInfo}
* is depicting the ringing phase of the call establishment between
* the local participant and the associated remote callee or the
* phase afterwards when the callee has not answered the call for a
* period of time and, consequently, is considered unavailable.
*
* @type {boolean}
*/
ringing: boolean
};
/**
* Implements a React {@link Component} which depicts the establishment of a
* call with a specific remote callee.
*
* @extends Component
*/
class CalleeInfo extends Component<Props, State> {
/**
* The (reference to the) {@link Audio} which plays/renders the audio
* depicting the ringing phase of the call establishment represented by this
* {@code CalleeInfo}.
*/
_audio: ?Audio;
_onLargeVideoAvatarVisible: Function;
_playAudioInterval: ?IntervalID;
_ringingTimeout: ?TimeoutID;
_setAudio: Function;
/**
* Initializes a new {@code CalleeInfo} instance.
*
* @param {Object} props - The read-only React {@link Component} props with
* which the new instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
className: undefined,
renderAudio:
typeof interfaceConfig !== 'object'
|| !interfaceConfig.DISABLE_RINGING,
ringing: true
};
this._onLargeVideoAvatarVisible
= this._onLargeVideoAvatarVisible.bind(this);
this._setAudio = this._setAudio.bind(this);
if (typeof APP === 'object') {
APP.UI.addListener(
UIEvents.LARGE_VIDEO_AVATAR_VISIBLE,
this._onLargeVideoAvatarVisible);
}
}
/**
* Sets up timeouts such as the timeout to end the ringing phase of the call
* establishment depicted by this {@code CalleeInfo}.
*
* @inheritdoc
*/
componentDidMount() {
// Set up the timeout to end the ringing phase of the call establishment
// depicted by this CalleeInfo.
if (this.state.ringing && !this._ringingTimeout) {
this._ringingTimeout
= setTimeout(
() => {
this._pauseAudio();
this._ringingTimeout = undefined;
this.setState({
ringing: false
});
},
30000);
}
this._playAudio();
}
/**
* Cleans up before this {@code Calleverlay} is unmounted and destroyed.
*
* @inheritdoc
*/
componentWillUnmount() {
this._pauseAudio();
if (this._ringingTimeout) {
clearTimeout(this._ringingTimeout);
this._ringingTimeout = undefined;
}
if (typeof APP === 'object') {
APP.UI.removeListener(
UIEvents.LARGE_VIDEO_AVATAR_VISIBLE,
this._onLargeVideoAvatarVisible);
}
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { className, ringing } = this.state;
const { avatarUrl, avatar, name } = this.props._callee;
return (
<Container
{ ...this._style('ringing', className) }
id = 'ringOverlay'>
<Container
{ ...this._style('ringing__content') }>
<Text { ...this._style('ringing__text') }>
{ ringing ? 'Calling...' : '' }
</Text>
<Avatar
{ ...this._style('ringing__avatar') }
uri = { avatarUrl || avatar } />
<Container
{ ...this._style('ringing__caller-info') }>
<Text
{ ...this._style('ringing__text') }>
{ name }
{ ringing ? '' : ' isn\'t available' }
</Text>
</Container>
</Container>
{ this._renderAudio() }
</Container>
);
}
/**
* Notifies this {@code CalleeInfo} that the visibility of the
* participant's avatar in the large video has changed.
*
* @param {boolean} largeVideoAvatarVisible - If the avatar in the large
* video (i.e. of the participant on the stage) is visible, then
* {@code true}; otherwise, {@code false}.
* @private
* @returns {void}
*/
_onLargeVideoAvatarVisible(largeVideoAvatarVisible: boolean) {
this.setState({
className: largeVideoAvatarVisible ? 'solidBG' : undefined
});
}
/**
* Stops the playback of the audio which represents the ringing phase of the
* call establishment depicted by this {@code CalleeInfo}.
*
* @private
* @returns {void}
*/
_pauseAudio() {
const audio = this._audio;
if (audio) {
audio.pause();
}
if (this._playAudioInterval) {
clearInterval(this._playAudioInterval);
this._playAudioInterval = undefined;
}
}
/**
* Starts the playback of the audio which represents the ringing phase of
* the call establishment depicted by this {@code CalleeInfo}.
*
* @private
* @returns {void}
*/
_playAudio() {
if (this._audio) {
this._audio.play();
if (!this._playAudioInterval) {
this._playAudioInterval
= setInterval(() => this._playAudio(), 5000);
}
}
}
/**
* Renders an audio element to represent the ringing phase of the call
* establishment represented by this {@code CalleeInfo}.
*
* @private
* @returns {ReactElement}
*/
_renderAudio() {
if (this.state.renderAudio && this.state.ringing) {
return (
<Audio
setRef = { this._setAudio }
src = './sounds/ring.ogg' />
);
}
return null;
}
/**
* Sets the (reference to the) {@link Audio} which renders the ringing phase
* of the call establishment represented by this {@code CalleeInfo}.
*
* @param {Audio} audio - The (reference to the) {@code Audio} which
* plays/renders the audio depicting the ringing phase of the call
* establishment represented by this {@code CalleeInfo}.
* @private
* @returns {void}
*/
_setAudio(audio) {
this._audio = audio;
}
/**
* Attempts to convert specified CSS class names into React
* {@link Component} props {@code style} or {@code className}.
*
* @param {Array<string>} classNames - The CSS class names to convert
* into React {@code Component} props {@code style} or {@code className}.
* @returns {{
* className: string,
* style: Object
* }}
*/
_style(...classNames: Array<?string>) {
let className = '';
let style;
for (const aClassName of classNames) {
if (aClassName) {
// Attemp to convert aClassName into style.
if (styles && aClassName in styles) {
// React Native will accept an Array as the value of the
// style prop. However, I do not know about React.
style = {
...style,
...styles[aClassName]
};
} else {
// Otherwise, leave it as className.
className += aClassName;
}
}
}
// Choose which of the className and/or style props has a value and,
// consequently, must be returned.
const props = {};
if (className) {
props.className = className;
}
if (style) {
props.style = style;
}
return props;
}
}
/**
* Maps (parts of) the redux state to {@code CalleeInfo}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _callee: Object
* }}
*/
function _mapStateToProps(state) {
return {
/**
* The callee's information such as avatar and display name.
*
* @private
* @type {Object}
*/
_callee: state['features/base/jwt'].callee
};
}
export default connect(_mapStateToProps)(CalleeInfo);

View File

@ -1,6 +1,5 @@
export * from './actions';
export * from './actionTypes';
export * from './components';
export * from './functions';
import './middleware';

View File

@ -2,24 +2,15 @@
import jwtDecode from 'jwt-decode';
import {
CONFERENCE_FAILED,
CONFERENCE_LEFT,
CONFERENCE_WILL_LEAVE,
SET_ROOM
} from '../conference';
import { SET_CONFIG } from '../config';
import { SET_LOCATION_URL } from '../connection';
import { LIB_INIT_ERROR } from '../lib-jitsi-meet';
import {
getLocalParticipant,
getParticipantCount,
PARTICIPANT_JOINED,
participantUpdated
} from '../participants';
import { MiddlewareRegistry } from '../redux';
import { setCalleeInfoVisible, setJWT } from './actions';
import { setJWT } from './actions';
import { SET_JWT } from './actionTypes';
import { parseJWTFromURLParams } from './functions';
@ -34,14 +25,6 @@ declare var APP: Object;
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case CONFERENCE_FAILED:
case CONFERENCE_LEFT:
case CONFERENCE_WILL_LEAVE:
case LIB_INIT_ERROR:
case PARTICIPANT_JOINED:
case SET_ROOM:
return _maybeSetCalleeInfoVisible(store, next, action);
case SET_CONFIG:
case SET_LOCATION_URL:
// XXX The JSON Web Token (JWT) is not the only piece of state that we
@ -58,73 +41,6 @@ MiddlewareRegistry.register(store => next => action => {
return next(action);
});
/**
* Notifies the feature jwt that a specific {@code action} is being dispatched
* within a specific redux {@code store} which may have an effect on the
* visiblity of (the) {@code CalleeInfo}.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action which is being dispatched in the
* specified {@code store}.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified {@code action}.
*/
function _maybeSetCalleeInfoVisible({ dispatch, getState }, next, action) {
const result = next(action);
const state = getState();
const stateFeaturesBaseJWT = state['features/base/jwt'];
let calleeInfoVisible;
if (stateFeaturesBaseJWT.callee) {
const { conference, leaving, room } = state['features/base/conference'];
// XXX The CalleeInfo is to be displayed/visible as soon as possible
// including even before the conference is joined.
if (room && (!conference || conference !== leaving)) {
switch (action.type) {
case CONFERENCE_FAILED:
case CONFERENCE_LEFT:
case CONFERENCE_WILL_LEAVE:
case LIB_INIT_ERROR:
// Because the CalleeInfo is to be displayed/visible as soon as
// possible even before the connection is established and/or the
// conference is joined, it is very complicated to figure out
// based on the current state alone. In order to reduce the
// risks of displaying the CallOverly at inappropirate times, do
// not even attempt to figure out based on the current state.
// The (redux) actions listed above are also the equivalents of
// the execution ponints at which APP.UI.hideRingOverlay() used
// to be invoked.
break;
default: {
// The CalleeInfo is to no longer be displayed/visible as soon
// as another participant joins.
calleeInfoVisible
= getParticipantCount(state) === 1
&& Boolean(getLocalParticipant(state));
// However, the CallDialog is not to be displayed/visible again
// after all remote participants leave.
if (calleeInfoVisible
&& stateFeaturesBaseJWT.calleeInfoVisible === false) {
calleeInfoVisible = false;
}
break;
}
}
}
}
dispatch(setCalleeInfoVisible(calleeInfoVisible));
return result;
}
/**
* Overwrites the properties {@code avatarURL}, {@code email}, and {@code name}
* of the local participant stored in the redux state base/participants.
@ -248,7 +164,7 @@ function _setJWT(store, next, action) {
}
}
return _maybeSetCalleeInfoVisible(store, next, action);
return next(action);
}
/**

View File

@ -1,27 +1,18 @@
// @flow
import { equals, set, ReducerRegistry } from '../redux';
import { equals, ReducerRegistry } from '../redux';
import { SET_CALLEE_INFO_VISIBLE, SET_JWT } from './actionTypes';
import { SET_JWT } from './actionTypes';
/**
* The default/initial redux state of the feature jwt.
*
* @private
* @type {{
* calleeInfoVisible: ?boolean
* isGuest: boolean
* }}
*/
const DEFAULT_STATE = {
/**
* The indicator which determines whether (the) {@code CalleeInfo} is
* visible.
*
* @type {boolean|undefined}
*/
calleeInfoVisible: undefined,
/**
* The indicator which determines whether the local participant is a guest
* in the conference.
@ -44,9 +35,6 @@ ReducerRegistry.register(
'features/base/jwt',
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case SET_CALLEE_INFO_VISIBLE:
return set(state, 'calleeInfoVisible', action.calleeInfoVisible);
case SET_JWT: {
// eslint-disable-next-line no-unused-vars
const { type, ...payload } = action;

View File

@ -180,6 +180,7 @@ function _participant(state: Object = {}, action) {
function _participantJoined({ participant }) {
const {
avatarURL,
botType,
connectionStatus,
dominantSpeaker,
email,
@ -212,6 +213,7 @@ function _participantJoined({ participant }) {
return {
avatarID,
avatarURL,
botType,
conference,
connectionStatus,
dominantSpeaker: dominantSpeaker || false,

View File

@ -9,7 +9,7 @@ import { connect as reactReduxConnect } from 'react-redux';
import { appNavigate } from '../../app';
import { connect, disconnect } from '../../base/connection';
import { DialogContainer } from '../../base/dialog';
import { CalleeInfoContainer } from '../../base/jwt';
import { CalleeInfoContainer } from '../../invite';
import { getParticipantCount } from '../../base/participants';
import { Container, LoadingIndicator, TintedView } from '../../base/react';
import { TestConnectionInfo } from '../../base/testing';

View File

@ -7,8 +7,8 @@ import { connect as reactReduxConnect } from 'react-redux';
import { connect, disconnect } from '../../base/connection';
import { DialogContainer } from '../../base/dialog';
import { translate } from '../../base/i18n';
import { CalleeInfoContainer } from '../../base/jwt';
import { Filmstrip } from '../../filmstrip';
import { CalleeInfoContainer } from '../../invite';
import { LargeVideo } from '../../large-video';
import { NotificationsContainer } from '../../notifications';
import { SidePanel } from '../../side-panel';

View File

@ -32,6 +32,10 @@ export function isFilmstripVisible(stateful: Object | Function) {
* in the filmstrip, then {@code true}; otherwise, {@code false}.
*/
export function shouldRemoteVideosBeVisible(state: Object) {
if (state['features/invite'].calleeInfoVisible) {
return false;
}
const participantCount = getParticipantCount(state);
let pinnedParticipant;

View File

@ -1,10 +1,8 @@
// @flow
import { setLastN } from '../base/conference';
import { SET_CALLEE_INFO_VISIBLE } from '../base/jwt';
import { pinParticipant } from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import Filmstrip from '../../../modules/UI/videolayout/Filmstrip';
import { SET_FILMSTRIP_ENABLED } from './actionTypes';
@ -12,9 +10,6 @@ declare var APP: Object;
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_CALLEE_INFO_VISIBLE:
return _setCalleeInfoVisible(store, next, action);
case SET_FILMSTRIP_ENABLED:
return _setFilmstripEnabled(store, next, action);
}
@ -22,42 +17,6 @@ MiddlewareRegistry.register(store => next => action => {
return next(action);
});
/**
* Notifies the feature filmstrip that the action
* {@link SET_CALLEE_INFO_VISIBLE} is being dispatched within a specific redux
* store.
*
* @param {Store} store - The redux store in which the specified action is being
* dispatched.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* specified action to the specified store.
* @param {Action} action - The redux action {@code SET_CALLEE_INFO_VISIBLE}
* which is being dispatched in the specified store.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _setCalleeInfoVisible({ getState }, next, action) {
if (typeof APP !== 'undefined') {
const oldValue
= Boolean(getState()['features/base/jwt'].calleeInfoVisible);
const result = next(action);
const newValue
= Boolean(getState()['features/base/jwt'].calleeInfoVisible);
oldValue === newValue
// FIXME The following accesses the private state filmstrip of
// Filmstrip. It is written with the understanding that Filmstrip
// will be rewritten in React and, consequently, will not need the
// middleware implemented here, Filmstrip.init, and UI.start.
|| (Filmstrip.filmstrip && Filmstrip.toggleFilmstrip(!newValue));
return result;
}
return next(action);
}
/**
* Notifies the feature filmstrip that the action {@link SET_FILMSTRIP_ENABLED}
* is being dispatched within a specific redux store.

View File

@ -1,14 +1,3 @@
/**
* The type of the (redux) action which signals that a click/tap has been
* performed on {@link InviteButton} and that the execution flow for
* adding/inviting people to the current conference/meeting is to begin.
*
* {
* type: BEGIN_ADD_PEOPLE
* }
*/
export const BEGIN_ADD_PEOPLE = Symbol('BEGIN_ADD_PEOPLE');
/**
* The type of redux action to set the {@code EventEmitter} subscriptions
* utilized by the feature invite.
@ -22,6 +11,49 @@ export const BEGIN_ADD_PEOPLE = Symbol('BEGIN_ADD_PEOPLE');
*/
export const _SET_EMITTER_SUBSCRIPTIONS = Symbol('_SET_EMITTER_SUBSCRIPTIONS');
/**
* The type of redux action which will add pending invite request to the redux
* store.
*
* {
* type: ADD_PENDING_INVITE_REQUEST,
* request: Object
* }
*/
export const ADD_PENDING_INVITE_REQUEST = Symbol('ADD_PENDING_INVITE_REQUEST');
/**
* The type of the (redux) action which signals that a click/tap has been
* performed on {@link InviteButton} and that the execution flow for
* adding/inviting people to the current conference/meeting is to begin.
*
* {
* type: BEGIN_ADD_PEOPLE
* }
*/
export const BEGIN_ADD_PEOPLE = Symbol('BEGIN_ADD_PEOPLE');
/**
* The type of redux action which will remove pending invite requests from the
* redux store.
*
* {
* type: REMOVE_PENDING_INVITE_REQUESTS
* }
*/
export const REMOVE_PENDING_INVITE_REQUESTS
= Symbol('REMOVE_PENDING_INVITE_REQUESTS');
/**
* The type of redux action which sets the visibility of {@code CalleeInfo}.
*
* {
* type: SET_CALLEE_INFO_VISIBLE,
* calleeInfoVisible: boolean
* }
*/
export const SET_CALLEE_INFO_VISIBLE = Symbol('SET_CALLEE_INFO_VISIBLE');
/**
* The type of the action which signals an error occurred while requesting dial-
* in numbers.

View File

@ -2,9 +2,13 @@
import { getInviteURL } from '../base/connection';
import { inviteVideoRooms } from '../videosipgw';
import { getParticipants } from '../base/participants';
import {
ADD_PENDING_INVITE_REQUEST,
BEGIN_ADD_PEOPLE,
REMOVE_PENDING_INVITE_REQUESTS,
SET_CALLEE_INFO_VISIBLE,
UPDATE_DIAL_IN_NUMBERS_FAILED,
UPDATE_DIAL_IN_NUMBERS_SUCCESS
} from './actionTypes';
@ -31,23 +35,51 @@ export function beginAddPeople() {
};
}
/**
* Invites (i.e. sends invites to) an array of invitees (which may be a
* combination of users, rooms, phone numbers, and video rooms).
*
* @param {Array<Object>} invitees - The recepients to send invites to.
* @param {Array<Object>} showCalleeInfo - Indicates whether the
* {@code CalleeInfo} should be displayed or not.
* @returns {Promise<Array<Object>>} A {@code Promise} resolving with an array
* of invitees who were not invited (i.e. invites were not sent to them).
*/
export function invite(invitees: Array<Object>) {
export function invite(
invitees: Array<Object>,
showCalleeInfo: boolean = false) {
return (
dispatch: Dispatch<*>,
getState: Function): Promise<Array<Object>> => {
const state = getState();
const participants = getParticipants(state);
const { calleeInfoVisible } = state['features/invite'];
if (showCalleeInfo
&& !calleeInfoVisible
&& invitees.length === 1
&& invitees[0].type === 'user'
&& participants.length === 1) {
dispatch(setCalleeInfoVisible(true, invitees[0]));
}
const { conference } = state['features/base/conference'];
if (typeof conference === 'undefined') {
// Invite will fail before CONFERENCE_JOIN. The request will be
// cached in order to be executed on CONFERENCE_JOIN.
return new Promise(resolve => {
dispatch(addPendingInviteRequest({
invitees,
callback: failedInvitees => resolve(failedInvitees)
}));
});
}
let allInvitePromises = [];
let invitesLeftToSend = [ ...invitees ];
const state = getState();
const { conference } = state['features/base/conference'];
const {
callFlowsEnabled,
inviteServiceUrl,
@ -57,27 +89,25 @@ export function invite(invitees: Array<Object>) {
const { jwt } = state['features/base/jwt'];
// First create all promises for dialing out.
if (conference) {
const phoneNumbers
= invitesLeftToSend.filter(({ type }) => type === 'phone');
const phoneNumbers
= invitesLeftToSend.filter(({ type }) => type === 'phone');
// For each number, dial out. On success, remove the number from
// {@link invitesLeftToSend}.
const phoneInvitePromises = phoneNumbers.map(item => {
const numberToInvite = item.number;
// For each number, dial out. On success, remove the number from
// {@link invitesLeftToSend}.
const phoneInvitePromises = phoneNumbers.map(item => {
const numberToInvite = item.number;
return conference.dial(numberToInvite)
.then(() => {
invitesLeftToSend
= invitesLeftToSend.filter(
invitee => invitee !== item);
})
.catch(error =>
logger.error('Error inviting phone number:', error));
});
return conference.dial(numberToInvite)
.then(() => {
invitesLeftToSend
= invitesLeftToSend.filter(
invitee => invitee !== item);
})
.catch(error =>
logger.error('Error inviting phone number:', error));
});
allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
}
allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
const usersAndRooms
= invitesLeftToSend.filter(
@ -98,7 +128,10 @@ export function invite(invitees: Array<Object>) {
= invitesLeftToSend.filter(
({ type }) => type !== 'user' && type !== 'room');
})
.catch(error => logger.error('Error inviting people:', error));
.catch(error => {
dispatch(setCalleeInfoVisible(false));
logger.error('Error inviting people:', error);
});
allInvitePromises.push(peopleInvitePromise);
}
@ -163,3 +196,56 @@ export function updateDialInNumbers() {
});
};
}
/**
* Sets the visibility of {@code CalleeInfo}.
*
* @param {boolean|undefined} [calleeInfoVisible] - If {@code CalleeInfo} is
* to be displayed/visible, then {@code true}; otherwise, {@code false} or
* {@code undefined}.
* @param {Object|undefined} [initialCalleeInfo] - Callee information.
* @returns {{
* type: SET_CALLEE_INFO_VISIBLE,
* calleeInfoVisible: (boolean|undefined),
* initialCalleeInfo
* }}
*/
export function setCalleeInfoVisible(
calleeInfoVisible: boolean,
initialCalleeInfo: ?Object) {
return {
type: SET_CALLEE_INFO_VISIBLE,
calleeInfoVisible,
initialCalleeInfo
};
}
/**
* Adds pending invite request.
*
* @param {Object} request - The request.
* @returns {{
* type: ADD_PENDING_INVITE_REQUEST,
* request: Object
* }}
*/
export function addPendingInviteRequest(
request: { invitees: Array<Object>, callback: Function }) {
return {
type: ADD_PENDING_INVITE_REQUEST,
request
};
}
/**
* Removes all pending invite requests.
*
* @returns {{
* type: REMOVE_PENDING_INVITE_REQUEST
* }}
*/
export function removePendingInviteRequests() {
return {
type: REMOVE_PENDING_INVITE_REQUESTS
};
}

View File

@ -0,0 +1,161 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { MEDIA_TYPE } from '../../../base/media';
import {
Avatar,
getAvatarURL,
getParticipants,
getParticipantDisplayName,
getParticipantPresenceStatus
} from '../../../base/participants';
import { Container, Text } from '../../../base/react';
import { isLocalTrackMuted } from '../../../base/tracks';
import { CALLING, PresenceLabel } from '../../../presence-status';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link CalleeInfo}.
*/
type Props = {
/**
* The callee's information such as avatar and display name.
*/
_callee: Object,
_isVideoMuted: boolean
};
/**
* Implements a React {@link Component} which depicts the establishment of a
* call with a specific remote callee.
*
* @extends Component
*/
class CalleeInfo extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
avatar,
name,
status = CALLING
} = this.props._callee;
const className = this.props._isVideoMuted ? 'solidBG' : undefined;
return (
<Container
{ ...this._style('ringing', className) }
id = 'ringOverlay'>
<Container
{ ...this._style('ringing__content') }>
<Avatar
{ ...this._style('ringing__avatar') }
uri = { avatar } />
<Container { ...this._style('ringing__status') }>
<PresenceLabel
defaultPresence = { status }
styles = { this._style('ringing__text') } />
</Container>
<Container { ...this._style('ringing__name') }>
<Text
{ ...this._style('ringing__text') }>
{ name }
</Text>
</Container>
</Container>
</Container>
);
}
/**
* Attempts to convert specified CSS class names into React
* {@link Component} props {@code style} or {@code className}.
*
* @param {Array<string>} classNames - The CSS class names to convert
* into React {@code Component} props {@code style} or {@code className}.
* @returns {{
* className: string,
* style: Object
* }}
*/
_style(...classNames: Array<?string>) {
let className = '';
let style;
for (const aClassName of classNames) {
if (aClassName) {
// Attemp to convert aClassName into style.
if (styles && aClassName in styles) {
// React Native will accept an Array as the value of the
// style prop. However, I do not know about React.
style = {
...style,
...styles[aClassName]
};
} else {
// Otherwise, leave it as className.
className += `${aClassName} `;
}
}
}
// Choose which of the className and/or style props has a value and,
// consequently, must be returned.
const props = {};
if (className) {
props.className = className.trim();
}
if (style) {
props.style = style;
}
return props;
}
}
/**
* Maps (parts of) the redux state to {@code CalleeInfo}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _callee: Object
* }}
*/
function _mapStateToProps(state) {
const _isVideoMuted
= isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
const poltergeist
= getParticipants(state).find(p => p.botType === 'poltergeist');
if (poltergeist) {
const { id } = poltergeist;
return {
_callee: {
avatar: getAvatarURL(poltergeist),
name: getParticipantDisplayName(state, id),
status: getParticipantPresenceStatus(state, id)
},
_isVideoMuted
};
}
return {
_callee: state['features/invite'].initialCalleeInfo,
_isVideoMuted
};
}
export default connect(_mapStateToProps)(CalleeInfo);

View File

@ -57,7 +57,7 @@ function _mapStateToProps(state: Object): Object {
* @private
* @type {boolean}
*/
_calleeInfoVisible: state['features/base/jwt'].calleeInfoVisible
_calleeInfoVisible: state['features/invite'].calleeInfoVisible
};
}

View File

@ -1,2 +1 @@
export { default as CalleeInfo } from './CalleeInfo';
export { default as CalleeInfoContainer } from './CalleeInfoContainer';

View File

@ -1,6 +1,6 @@
import { StyleSheet } from 'react-native';
import { ColorPalette, createStyleSheet } from '../../styles';
import { ColorPalette, createStyleSheet } from '../../../base/styles';
export default createStyleSheet({
// XXX The names bellow were preserved for the purposes of compatibility

View File

@ -2,3 +2,4 @@ export { default as AddPeopleDialog } from './AddPeopleDialog';
export { DialInSummary } from './dial-in-summary';
export { default as InfoDialogButton } from './InfoDialogButton';
export { default as InviteButton } from './InviteButton';
export * from './callee-info';

View File

@ -2,11 +2,17 @@
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
import {
CONFERENCE_JOINED
} from '../base/conference';
import {
getLocalParticipant,
getParticipantPresenceStatus,
getParticipants,
PARTICIPANT_JOINED,
PARTICIPANT_JOINED_SOUND_ID,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED
PARTICIPANT_UPDATED,
pinParticipant
} from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import {
@ -24,7 +30,15 @@ import {
RINGING
} from '../presence-status';
import { UPDATE_DIAL_IN_NUMBERS_FAILED } from './actionTypes';
import {
SET_CALLEE_INFO_VISIBLE,
UPDATE_DIAL_IN_NUMBERS_FAILED
} from './actionTypes';
import {
invite,
removePendingInviteRequests,
setCalleeInfoVisible
} from './actions';
import {
OUTGOING_CALL_EXPIRED_SOUND_ID,
OUTGOING_CALL_REJECTED_SOUND_ID,
@ -59,13 +73,22 @@ const statusToRingtone = {
*/
MiddlewareRegistry.register(store => next => action => {
let oldParticipantPresence;
const { dispatch, getState } = store;
const state = getState();
if (action.type === PARTICIPANT_UPDATED
|| action.type === PARTICIPANT_LEFT) {
oldParticipantPresence
= getParticipantPresenceStatus(
store.getState(),
action.participant.id);
= getParticipantPresenceStatus(state, action.participant.id);
}
if (action.type === SET_CALLEE_INFO_VISIBLE) {
if (action.calleeInfoVisible) {
dispatch(pinParticipant(getLocalParticipant(state).id));
} else {
// unpin participant
dispatch(pinParticipant());
}
}
const result = next(action);
@ -73,23 +96,27 @@ MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
for (const [ soundId, sound ] of sounds.entries()) {
store.dispatch(registerSound(soundId, sound.file, sound.options));
dispatch(registerSound(soundId, sound.file, sound.options));
}
break;
case APP_WILL_UNMOUNT:
for (const soundId of sounds.keys()) {
store.dispatch(unregisterSound(soundId));
dispatch(unregisterSound(soundId));
}
break;
case CONFERENCE_JOINED:
_onConferenceJoined(store);
break;
case PARTICIPANT_JOINED:
case PARTICIPANT_LEFT:
case PARTICIPANT_UPDATED: {
_maybeHideCalleeInfo(action, store);
const newParticipantPresence
= getParticipantPresenceStatus(
store.getState(),
action.participant.id);
= getParticipantPresenceStatus(state, action.participant.id);
if (oldParticipantPresence === newParticipantPresence) {
break;
@ -108,11 +135,11 @@ MiddlewareRegistry.register(store => next => action => {
}
if (oldSoundId) {
store.dispatch(stopSound(oldSoundId));
dispatch(stopSound(oldSoundId));
}
if (newSoundId) {
store.dispatch(playSound(newSoundId));
dispatch(playSound(newSoundId));
}
break;
@ -126,3 +153,50 @@ MiddlewareRegistry.register(store => next => action => {
return result;
});
/**
* Hides the callee info layot if there are more than 1 real
* (not poltergeist, shared video, etc.) participants in the call.
*
* @param {Object} action - The redux action.
* @param {ReduxStore} store - The redux store.
* @returns {void}
*/
function _maybeHideCalleeInfo(action, store) {
const state = store.getState();
if (!state['features/invite'].calleeInfoVisible) {
return;
}
const participants = getParticipants(state);
const numberOfPoltergeists
= participants.filter(p => p.botType === 'poltergeist').length;
const numberOfRealParticipants = participants.length - numberOfPoltergeists;
if ((numberOfPoltergeists > 1 || numberOfRealParticipants > 1)
|| (action.type === PARTICIPANT_LEFT && participants.length === 1)) {
store.dispatch(setCalleeInfoVisible(false));
}
}
/**
* Executes the pending invitation requests if any.
*
* @param {ReduxStore} store - The redux store.
* @returns {void}
*/
function _onConferenceJoined(store) {
const { dispatch, getState } = store;
const pendingInviteRequests
= getState()['features/invite'].pendingInviteRequests || [];
pendingInviteRequests.forEach(({ invitees, callback }) => {
dispatch(invite(invitees))
.then(failedInvitees => {
callback(failedInvitees);
});
});
dispatch(removePendingInviteRequests());
}

View File

@ -4,12 +4,24 @@ import { assign, ReducerRegistry } from '../base/redux';
import {
_SET_EMITTER_SUBSCRIPTIONS,
ADD_PENDING_INVITE_REQUEST,
REMOVE_PENDING_INVITE_REQUESTS,
SET_CALLEE_INFO_VISIBLE,
UPDATE_DIAL_IN_NUMBERS_FAILED,
UPDATE_DIAL_IN_NUMBERS_SUCCESS
} from './actionTypes';
const DEFAULT_STATE = {
numbersEnabled: true
/**
* The indicator which determines whether (the) {@code CalleeInfo} is
* visible.
*
* @type {boolean|undefined}
*/
calleeInfoVisible: false,
numbersEnabled: true,
pendingInviteRequests: []
};
ReducerRegistry.register('features/invite', (state = DEFAULT_STATE, action) => {
@ -17,6 +29,26 @@ ReducerRegistry.register('features/invite', (state = DEFAULT_STATE, action) => {
case _SET_EMITTER_SUBSCRIPTIONS:
return (
assign(state, 'emitterSubscriptions', action.emitterSubscriptions));
case ADD_PENDING_INVITE_REQUEST:
return {
...state,
pendingInviteRequests: [
...state.pendingInviteRequests,
action.request
]
};
case REMOVE_PENDING_INVITE_REQUESTS:
return {
...state,
pendingInviteRequests: []
};
case SET_CALLEE_INFO_VISIBLE:
return {
...state,
calleeInfoVisible: action.calleeInfoVisible,
initialCalleeInfo: action.initialCalleeInfo
};
case UPDATE_DIAL_IN_NUMBERS_FAILED:
return {

View File

@ -139,8 +139,10 @@ export default class AbstractNotificationsContainer<P: Props>
export function _abstractMapStateToProps(state: Object) {
const isAnyOverlayVisible = Boolean(getOverlayToRender(state));
const { enabled, notifications } = state['features/notifications'];
const { calleeInfoVisible } = state['features/invite'];
return {
_notifications: enabled && !isAnyOverlayVisible ? notifications : []
_notifications: enabled && !isAnyOverlayVisible && !calleeInfoVisible
? notifications : []
};
}

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { getParticipantById } from '../../base/participants';
import { Text } from '../../base/react';
import { STATUS_TO_I18N_KEY } from '../constants';
@ -35,11 +36,27 @@ class PresenceLabel extends Component {
*/
_presence: PropTypes.string,
/**
* Default presence status that will be displayed if user's presence
* status is not available.
*/
defaultPresence: PropTypes.string,
/**
* Styles for the case where there isn't any content to be shown.
*/
noContentStyles: PropTypes.object,
/**
* The ID of the participant whose presence status shoul display.
*/
participantID: PropTypes.string,
/**
* Styles for the presence label.
*/
styles: PropTypes.object,
/**
* Invoked to obtain translated strings.
*/
@ -53,14 +70,13 @@ class PresenceLabel extends Component {
* @returns {ReactElement}
*/
render() {
const { _presence } = this.props;
const { _presence, styles, noContentStyles } = this.props;
const combinedStyles = _presence ? styles : noContentStyles;
return (
<div
className
= { `presence-label ${_presence ? '' : 'no-presence'}` }>
<Text { ...combinedStyles }>
{ this._getPresenceText() }
</div>
</Text>
);
}
@ -102,7 +118,8 @@ function _mapStateToProps(state, ownProps) {
const participant = getParticipantById(state, ownProps.participantID);
return {
_presence: participant && participant.presence
_presence:
(participant && participant.presence) || ownProps.defaultPresence
};
}

View File

@ -122,5 +122,6 @@ export const STATUS_TO_I18N_KEY = {
[CONNECTING]: 'presenceStatus.connecting',
[CONNECTING2]: 'presenceStatus.connecting2',
[CONNECTED_PHONE_NUMBER]: 'presenceStatus.connected',
[CONNECTED_USER]: 'presenceStatus.connected',
[DISCONNECTED]: 'presenceStatus.disconnected'
};

View File

@ -89,7 +89,7 @@ export function hideToolbox(force: boolean = false): Function {
if (!force
&& (hovered
|| state['features/base/jwt'].calleeInfoVisible
|| state['features/invite'].calleeInfoVisible
|| SideContainerToggler.isVisible())) {
dispatch(
setToolboxTimeout(