feat: convert invite dialog to react and redux

Converting the invite modal includes the following:
- Creating new react components to display InviteDialog. The
  main parent components are ShareLink and PasswordOverview,
  the later handles displaying lock state and password editing.
  These components do not make use of atlaskit as the component
  for input does not yet support readonly, so for consistency
  within the modal content no atlaskit was used.
- Using redux for keeping and accessing lock state instead of
  RoomLocker.
- Publicly exposing the redux action lockStateChanged for direct
  calling on lock events experienced on the web client.
- Removing Invite, InviteDialogView, and RoomLocker and references
  to them.
- Handling errors that occur when setting a password to preserve
  existing web funtionality.
This commit is contained in:
Leonard Kim
2017-04-19 20:41:23 -05:00
committed by Lyubo Marinov
parent 4097be1908
commit 44b81b20e3
30 changed files with 903 additions and 727 deletions
@@ -116,6 +116,17 @@ export const SET_LASTN = Symbol('SET_LASTN');
*/
export const SET_PASSWORD = Symbol('SET_PASSWORD');
/**
* The type of Redux action which signals that setting a password on a
* JitsiConference encountered an error and failed.
*
* {
* type: SET_PASSWORD_FAILED,
* error: string
* }
*/
export const SET_PASSWORD_FAILED = Symbol('SET_PASSWORD_FAILED');
/**
* The type of the Redux action which sets the name of the room of the
* conference to be joined.
+9 -3
View File
@@ -22,6 +22,7 @@ import {
_SET_AUDIO_ONLY_VIDEO_MUTED,
SET_LASTN,
SET_PASSWORD,
SET_PASSWORD_FAILED,
SET_ROOM
} from './actionTypes';
import {
@@ -56,7 +57,7 @@ function _addConferenceListeners(conference, dispatch) {
conference.on(
JitsiConferenceEvents.LOCK_STATE_CHANGED,
(...args) => dispatch(_lockStateChanged(conference, ...args)));
(...args) => dispatch(lockStateChanged(conference, ...args)));
// Dispatches into features/base/tracks follow:
@@ -292,7 +293,7 @@ export function createConference() {
* locked: boolean
* }}
*/
function _lockStateChanged(conference, locked) {
export function lockStateChanged(conference, locked) {
return {
type: LOCK_STATE_CHANGED,
conference,
@@ -440,7 +441,12 @@ export function setPassword(conference, method, password) {
conference,
method,
password
})));
}))
.catch(error => dispatch({
type: SET_PASSWORD_FAILED,
error
}))
);
}
return Promise.reject();
+25 -4
View File
@@ -1,3 +1,5 @@
import { LOCKED_LOCALLY, LOCKED_REMOTELY } from '../../room-lock';
import { JitsiConferenceErrors } from '../lib-jitsi-meet';
import {
ReducerRegistry,
@@ -83,7 +85,7 @@ function _conferenceFailed(state, action) {
audioOnlyVideoMuted: undefined,
conference: undefined,
leaving: undefined,
locked: undefined,
locked: passwordRequired ? LOCKED_REMOTELY : undefined,
password: undefined,
/**
@@ -112,7 +114,7 @@ function _conferenceJoined(state, action) {
// i.e. password-protected is private to lib-jitsi-meet. However, the
// library does not fire LOCK_STATE_CHANGED upon joining a JitsiConference
// with a password.
const locked = conference.room.locked || undefined;
const locked = conference.room.locked ? LOCKED_REMOTELY : undefined;
return (
setStateProperties(state, {
@@ -209,7 +211,16 @@ function _lockStateChanged(state, action) {
return state;
}
return setStateProperty(state, 'locked', action.locked || undefined);
let lockState;
if (action.locked) {
lockState = state.locked || LOCKED_REMOTELY;
}
return setStateProperties(state, {
locked: lockState,
password: action.locked ? state.password : null
});
}
/**
@@ -254,10 +265,12 @@ function _setPassword(state, action) {
const conference = action.conference;
switch (action.method) {
case conference.join:
case conference.join: {
if (state.passwordRequired === conference) {
return (
setStateProperties(state, {
locked: LOCKED_REMOTELY,
/**
* The password with which the conference is to be joined.
*
@@ -267,8 +280,16 @@ function _setPassword(state, action) {
passwordRequired: undefined
}));
}
break;
}
case conference.lock: {
return setStateProperties(state, {
locked: action.password ? LOCKED_LOCALLY : undefined,
password: action.password
});
}
}
return state;
}
+18
View File
@@ -0,0 +1,18 @@
/* globals APP */
import { openDialog } from '../../features/base/dialog';
import { InviteDialog } from './components';
/**
* Opens the Invite Dialog.
*
* @returns {Function}
*/
export function openInviteDialog() {
return dispatch => {
dispatch(openDialog(InviteDialog, {
conferenceUrl: encodeURI(APP.ConferenceUrl.getInviteUrl())
}));
};
}
@@ -0,0 +1,137 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { setPassword } from '../../base/conference';
import { translate } from '../../base/i18n';
/**
* A React Component for locking a JitsiConference with a password.
*/
class AddPasswordForm extends Component {
/**
* AddPasswordForm component's property types.
*
* @static
*/
static propTypes = {
/**
* The JitsiConference on which to lock and set a password.
*
* @type {JitsiConference}
*/
conference: React.PropTypes.object,
/**
* Invoked to set a password on the conference.
*/
dispatch: React.PropTypes.func,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func
}
/**
* Initializes a new AddPasswordForm instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
password: ''
};
this._onKeyDown = this._onKeyDown.bind(this);
this._onPasswordChange = this._onPasswordChange.bind(this);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<div
className = 'form-control'
onSubmit = { this._onSubmit } >
<div className = 'form-control__container'>
<input
autoFocus = { true }
className = 'input-control'
id = 'newPasswordInput'
onChange = { this._onPasswordChange }
onKeyDown = { this._onKeyDown }
placeholder
= { this.props.t('dialog.createPassword') }
type = 'text' />
<button
className = 'button-control button-control_light'
disabled = { !this.state.password }
id = 'addPasswordBtn'
onClick = { this._onSubmit }
type = 'button'>
{ this.props.t('dialog.add') }
</button>
</div>
</div>
);
}
/**
* Mimics form behavior by listening for enter key press and submitting the
* entered password.
*
* @param {Object} event - DOM Event for keydown.
* @private
* @returns {void}
*/
_onKeyDown(event) {
event.stopPropagation();
if (event.keyCode === /* Enter */ 13) {
this._onSubmit();
}
}
/**
* Updates the internal state of the entered password.
*
* @param {Object} event - DOM Event for value change.
* @private
* @returns {void}
*/
_onPasswordChange(event) {
this.setState({ password: event.target.value });
}
/**
* Dispatches a request to lock the conference with a password.
*
* @private
* @returns {void}
*/
_onSubmit() {
if (!this.state.password) {
return;
}
const conference = this.props.conference;
this.props.dispatch(setPassword(
conference,
conference.lock,
this.state.password
));
this.setState({ password: '' });
}
}
export default translate(connect()(AddPasswordForm));
@@ -0,0 +1,104 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Dialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
import JitsiMeetJS from '../../base/lib-jitsi-meet';
import {
getLocalParticipant,
PARTICIPANT_ROLE
} from '../../base/participants';
import PasswordContainer from './PasswordContainer';
import ShareLinkForm from './ShareLinkForm';
/**
* A React Component for displaying other components responsible for copying the
* current conference url and for setting or removing a conference password.
*/
class InviteDialog extends Component {
/**
* InviteDialog component's property types.
*
* @static
*/
static propTypes = {
/**
* The redux store representation of the JitsiConference.
*
*/
_conference: React.PropTypes.object,
/**
* Whether or not the current user is a conference moderator.
*/
_isModerator: React.PropTypes.bool,
/**
* The url for the JitsiConference.
*/
conferenceUrl: React.PropTypes.string,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func
}
/**
* Reports an analytics event for the invite modal being closed.
*
* @inheritdoc
*/
componentWillUnmount() {
JitsiMeetJS.analytics.sendEvent('toolbar.invite.close');
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<Dialog
cancelDisabled = { true }
okTitleKey = 'dialog.done'
titleString = { this.props.t(
'invite.inviteTo',
{ conferenceName: this.props._conference.room }) } >
<div className = 'invite-dialog'>
<ShareLinkForm toCopy = { this.props.conferenceUrl } />
<PasswordContainer
conference = { this.props._conference.conference }
locked = { this.props._conference.locked }
password = { this.props._conference.password }
showPasswordEdit = { this.props._isModerator } />
</div>
</Dialog>
);
}
}
/**
* Maps (parts of) the Redux state to the associated InviteDialog's props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: Object,
* _isModerator: boolean
* }}
*/
function _mapStateToProps(state) {
const { role }
= getLocalParticipant(state['features/base/participants']);
return {
_conference: state['features/base/conference'],
_isModerator: role === PARTICIPANT_ROLE.MODERATOR
};
}
export default translate(connect(_mapStateToProps)(InviteDialog));
@@ -0,0 +1,48 @@
import React, { Component } from 'react';
import { translate } from '../../base/i18n';
/**
* A React Component for displaying the conference lock state.
*/
class LockStatePanel extends Component {
/**
* LockStatePanel component's property types.
*
* @static
*/
static propTypes = {
/**
* Whether or not the conference is currently locked.
*/
locked: React.PropTypes.bool,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const [ lockStateClass, lockIconClass, lockTextKey ] = this.props.locked
? [ 'is-locked', 'icon-security-locked', 'invite.locked' ]
: [ 'is-unlocked', 'icon-security', 'invite.unlocked' ];
return (
<div className = { `lock-state ${lockStateClass}` }>
<span className = { lockIconClass } />
<span>
{ this.props.t(lockTextKey) }
</span>
</div>
);
}
}
export default translate(LockStatePanel);
@@ -0,0 +1,147 @@
import React, { Component } from 'react';
import { translate } from '../../base/i18n';
import { LOCKED_LOCALLY } from '../../room-lock';
import AddPasswordForm from './AddPasswordForm';
import LockStatePanel from './LockStatePanel';
import RemovePasswordForm from './RemovePasswordForm';
/**
* React component for displaying the current room lock state as well as
* exposing features to modify the room lock.
*/
class PasswordContainer extends Component {
/**
* PasswordContainer component's property types.
*
* @static
*/
static propTypes = {
/**
* The JitsiConference for which to display a lock state and change the
* password.
*
* @type {JitsiConference}
*/
conference: React.PropTypes.object,
/**
* The value for how the conference is locked (or undefined if not
* locked) as defined by room-lock constants.
*/
locked: React.PropTypes.string,
/**
* The current known password for the JitsiConference.
*/
password: React.PropTypes.string,
/**
* Whether or not the password editing components should be displayed.
*/
showPasswordEdit: React.PropTypes.bool,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func
}
/**
* Initializes a new PasswordContainer instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
isEditingPassword: false
};
this._onTogglePasswordEdit = this._onTogglePasswordEdit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<div className = 'password-overview'>
<div className = 'password-overview-status'>
<LockStatePanel locked = { Boolean(this.props.locked) } />
{ this._renderShowPasswordLink() }
</div>
{ this._renderPasswordEdit() }
</div>
);
}
/**
* Toggles the display of the ReactElements used to edit the password.
*
* @private
* @returns {void}
*/
_onTogglePasswordEdit() {
this.setState({
isEditingPassword: !this.state.isEditingPassword
});
}
/**
* Creates a ReactElement used for setting or removing a password.
*
* @private
* @returns {ReactElement|null}
*/
_renderPasswordEdit() {
if (!this.state.isEditingPassword) {
return null;
}
return this.props.locked
? <RemovePasswordForm
conference = { this.props.conference }
lockedLocally = { this.props.locked === LOCKED_LOCALLY }
password = { this.props.password } />
: <AddPasswordForm conference = { this.props.conference } />;
}
/**
* Creates a ReactElement that toggles displaying password edit components.
*
* @private
* @returns {ReactElement|null}
*/
_renderShowPasswordLink() {
if (!this.props.showPasswordEdit) {
return null;
}
let toggleStatusKey;
if (this.state.isEditingPassword) {
toggleStatusKey = 'invite.hidePassword';
} else if (this.props.locked) {
toggleStatusKey = 'invite.showPassword';
} else {
toggleStatusKey = 'invite.addPassword';
}
return (
<a
className = 'password-overview-toggle-edit'
onClick = { this._onTogglePasswordEdit }>
{ this.props.t(toggleStatusKey) }
</a>
);
}
}
export default translate(PasswordContainer);
@@ -0,0 +1,117 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { setPassword } from '../../base/conference';
import { translate } from '../../base/i18n';
/**
* A React Component for removing a lock from a JitsiConference.
*/
class RemovePasswordForm extends Component {
/**
* RemovePasswordForm component's property types.
*
* @static
*/
static propTypes = {
/**
* The JitsiConference on which remove a lock.
*
* @type {JitsiConference}
*/
conference: React.PropTypes.object,
/**
* Invoked to send a password removal request.
*/
dispatch: React.PropTypes.func,
/**
* Whether or not the room lock, if any, was set by the local user.
*/
lockedLocally: React.PropTypes.bool,
/**
* The current known password for the JitsiConference.
*/
password: React.PropTypes.string,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func
}
/**
* Initializes a new RemovePasswordForm instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @private
* @returns {ReactElement}
*/
render() {
return (
<div className = 'remove-password'>
<div className = 'remove-password-description'>
{ this._getPasswordPreviewText() }
</div>
<a
className = 'remove-password-link'
id = 'inviteDialogRemovePassword'
onClick = { this._onClick }>
{ this.props.t('dialog.removePassword') }
</a>
</div>
);
}
/**
* Creates a ReactElement for displaying the current password.
*
* @private
* @returns {ReactElement}
*/
_getPasswordPreviewText() {
return (
<span>
<span>
{ `${this.props.t('dialog.currentPassword')} ` }
</span>
<span className = 'remove-password-current'>
{ this.props.lockedLocally
? this.props.password
: this.props.t('passwordSetRemotely') }
</span>
</span>
);
}
/**
* Dispatches a request to remove any set password on the JitsiConference.
*
* @private
* @returns {void}
*/
_onClick() {
const conference = this.props.conference;
this.props.dispatch(setPassword(
conference,
conference.lock,
''
));
}
}
export default translate(connect()(RemovePasswordForm));
@@ -0,0 +1,110 @@
import React, { Component } from 'react';
import { translate } from '../../base/i18n';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* A React Component for displaying a value with a copy button that can be
* clicked to copy the value onto the clipboard.
*/
class ShareLinkForm extends Component {
/**
* ShareLinkForm component's property types.
*
* @static
*/
static propTypes = {
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func,
/**
* The value to be displayed and copied onto the clipboard.
*/
toCopy: React.PropTypes.string
}
/**
* Initializes a new ShareLinkForm instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this._inputElement = null;
this._onClick = this._onClick.bind(this);
this._setInput = this._setInput.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const inputValue = this.props.toCopy
|| this.props.t('inviteUrlDefaultMsg');
// FIXME input is used here instead of atlaskit field-text because
// field-text does not currently support readonly
return (
<div className = 'form-control'>
<label className = 'form-control__label'>
{ this.props.t('dialog.shareLink') }
</label>
<div className = 'form-control__container'>
<input
className = 'input-control inviteLink'
id = 'inviteLinkRef'
readOnly = { true }
ref = { this._setInput }
type = 'text'
value = { inputValue } />
<button
className =
'button-control button-control_light copyInviteLink'
onClick = { this._onClick }
type = 'button'>
{ this.props.t('dialog.copy') }
</button>
</div>
</div>
);
}
/**
* Copies the passed in value to the clipboard.
*
* @private
* @returns {void}
*/
_onClick() {
try {
this._inputElement.select();
document.execCommand('copy');
this._inputElement.blur();
} catch (err) {
logger.error('error when copying the text', err);
}
}
/**
* Sets the internal reference to the DOM element for the input field so it
* may be accessed directly.
*
* @param {Object} element - DOM element for the component's input.
* @private
* @returns {void}
*/
_setInput(element) {
this._inputElement = element;
}
}
export default translate(ShareLinkForm);
@@ -0,0 +1 @@
export { default as InviteDialog } from './InviteDialog';
+2
View File
@@ -0,0 +1,2 @@
export * from './actions';
export * from './components';
@@ -3,6 +3,8 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import AKFieldText from '@atlaskit/field-text';
import UIEvents from '../../../../service/UI/UIEvents';
import { setPassword } from '../../base/conference';
import { Dialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
@@ -109,9 +111,9 @@ class PasswordRequiredPrompt extends Component {
// password required will be received and the room again
// will be marked as locked.
if (!this.state.password || this.state.password === '') {
// XXX temporary solution till we move the whole invite logic
// in react
APP.conference.invite.setLockedFromElsewhere(false);
// XXX temporary solution while some components are not listening
// for lock state updates in redux
APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK, false);
}
this.props.dispatch(setPassword(
+12
View File
@@ -0,0 +1,12 @@
/**
* The room lock state where the password was set by the current user.
*
* @type {string}
*/
export const LOCKED_LOCALLY = 'LOCKED_LOCALLY';
/**
* The room lock state where the password was set by a remote user.
* @type {string}
*/
export const LOCKED_REMOTELY = 'LOCKED_REMOTELY';
+1
View File
@@ -1,4 +1,5 @@
export * from './actions';
export * from './components';
export * from './constants';
import './middleware';
+56 -5
View File
@@ -1,8 +1,16 @@
/* global APP */
import JitsiMeetJS from '../base/lib-jitsi-meet';
const logger = require('jitsi-meet-logger').getLogger(__filename);
import { CONFERENCE_FAILED } from '../base/conference';
import UIEvents from '../../../service/UI/UIEvents';
import {
CONFERENCE_FAILED,
LOCK_STATE_CHANGED,
SET_PASSWORD_FAILED
} from '../base/conference';
import JitsiMeetJS from '../base/lib-jitsi-meet';
import { MiddlewareRegistry } from '../base/redux';
import { _showPasswordDialog } from './actions';
/**
@@ -20,17 +28,60 @@ MiddlewareRegistry.register(store => next => action => {
if (action.conference
&& JitsiConferenceErrors.PASSWORD_REQUIRED === action.error) {
// XXX temporary solution till we move the whole invite
// logic in react
// XXX temporary solution while some components are not listening
// for lock state updates in redux
if (typeof APP !== 'undefined') {
APP.conference.invite.setLockedFromElsewhere(true);
APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK, true);
}
store.dispatch(_showPasswordDialog(action.conference));
}
break;
}
case LOCK_STATE_CHANGED: {
// TODO Remove this logic when all components interested in the lock
// state change event are moved into react/redux.
if (typeof APP !== 'undefined') {
APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK, action.locked);
}
break;
}
case SET_PASSWORD_FAILED:
return _notifySetPasswordError(store, next, action);
}
return next(action);
});
/**
* Handles errors that occur when a password is failed to be set.
*
* @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 SET_PASSWORD_ERROR which has the
* error type that should be handled.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified action.
*/
function _notifySetPasswordError(store, next, action) {
if (typeof APP !== 'undefined') {
// TODO remove this logic when displaying of error messages on web is
// handled through react/redux
if (action.error
=== JitsiMeetJS.errors.conference.PASSWORD_NOT_SUPPORTED) {
logger.warn('room passwords not supported');
APP.UI.messageHandler.showError(
'dialog.warning', 'dialog.passwordNotSupported');
} else {
logger.warn('setting password failed', action.error);
APP.UI.messageHandler.showError(
'dialog.lockTitle', 'dialog.lockMessage');
}
}
return next(action);
}
@@ -322,7 +322,7 @@ function _mapStateToProps(state) {
* @protected
* @type {boolean}
*/
_locked: conference.locked
_locked: Boolean(conference.locked)
};
}
@@ -223,7 +223,6 @@ function _mapDispatchToProps(dispatch: Function): Object {
* @returns {{
* _alwaysVisible: boolean,
* _audioMuted: boolean,
* _locked: boolean,
* _subjectSlideIn: boolean,
* _videoMuted: boolean
* }}
@@ -4,6 +4,8 @@ import React from 'react';
import UIEvents from '../../../service/UI/UIEvents';
import { openInviteDialog } from '../invite';
declare var APP: Object;
declare var config: Object;
declare var JitsiMeetJS: Object;
@@ -222,7 +224,7 @@ export default {
id: 'toolbar_button_link',
onClick() {
JitsiMeetJS.analytics.sendEvent('toolbar.invite.clicked');
APP.UI.emitEvent(UIEvents.INVITE_CLICKED);
APP.store.dispatch(openInviteDialog());
},
tooltipKey: 'toolbar.invite'
},