diff --git a/conference.js b/conference.js index 9f0152443..233f7cc3d 100644 --- a/conference.js +++ b/conference.js @@ -2,7 +2,6 @@ const logger = require("jitsi-meet-logger").getLogger(__filename); import {openConnection} from './connection'; -import Invite from './modules/UI/invite/Invite'; import ContactList from './modules/UI/side_pannels/contactlist/ContactList'; import AuthHandler from './modules/UI/authentication/AuthHandler'; @@ -28,7 +27,8 @@ import { conferenceFailed, conferenceJoined, conferenceLeft, - EMAIL_COMMAND + EMAIL_COMMAND, + lockStateChanged } from './react/features/base/conference'; import { updateDeviceList @@ -373,10 +373,9 @@ function createLocalTracks(options, checkForPermissionPrompt) { } class ConferenceConnector { - constructor(resolve, reject, invite) { + constructor(resolve, reject) { this._resolve = resolve; this._reject = reject; - this._invite = invite; this.reconnectTimeout = null; room.on(ConferenceEvents.CONFERENCE_JOINED, this._handleConferenceJoined.bind(this)); @@ -411,14 +410,17 @@ class ConferenceConnector { // not enough rights to create conference case ConferenceErrors.AUTHENTICATION_REQUIRED: - // schedule reconnect to check if someone else created the room - this.reconnectTimeout = setTimeout(function () { - room.join(); - }, 5000); + { + // schedule reconnect to check if someone else created the room + this.reconnectTimeout = setTimeout(function () { + room.join(); + }, 5000); - // notify user that auth is required - AuthHandler.requireAuth( - room, this._invite.getRoomLocker().password); + const { password } + = APP.store.getState()['features/base/conference']; + + AuthHandler.requireAuth(room, password); + } break; case ConferenceErrors.RESERVATION_ERROR: @@ -630,8 +632,7 @@ export default { // XXX The API will take care of disconnecting from the XMPP // server (and, thus, leaving the room) on unload. return new Promise((resolve, reject) => { - (new ConferenceConnector( - resolve, reject, this.invite)).connect(); + (new ConferenceConnector(resolve, reject)).connect(); }); }); }, @@ -985,7 +986,6 @@ export default { room = connection.initJitsiConference(APP.conference.roomName, this._getConferenceOptions()); this._setLocalAudioVideoStreams(localTracks); - this.invite = new Invite(room); this._room = room; // FIXME do not use this _setupLocalParticipantProperties(); @@ -1449,6 +1449,10 @@ export default { APP.UI.changeDisplayName(id, formattedDisplayName); }); + room.on(ConferenceEvents.LOCK_STATE_CHANGED, (...args) => { + APP.store.dispatch(lockStateChanged(room, ...args)); + }); + room.on(ConferenceEvents.PARTICIPANT_PROPERTY_CHANGED, (participant, name, oldValue, newValue) => { if (name === "raisedHand") { diff --git a/css/_variables.scss b/css/_variables.scss index 351806e4a..aeaaecf29 100644 --- a/css/_variables.scss +++ b/css/_variables.scss @@ -83,6 +83,8 @@ $rateStarSize: 34px; * Modals */ $modalButtonFontSize: 14px; +$modalMockAKInputBackground: #fafbfc; +$modalMockAKInputBorder: 1px solid #f4f5f7; $modalTextColor: #333; /** diff --git a/css/modals/_dialog.scss b/css/modals/_dialog.scss index ef0c87959..c8ade0853 100644 --- a/css/modals/_dialog.scss +++ b/css/modals/_dialog.scss @@ -79,6 +79,12 @@ .modal-dialog-form { color: $modalTextColor; + + .input-control { + background: $modalMockAKInputBackground; + border: $modalMockAKInputBorder; + color: inherit; + } } .modal-dialog-footer { font-size: $modalButtonFontSize; diff --git a/css/modals/invite/_invite.scss b/css/modals/invite/_invite.scss index d3b1acc9c..cb04334b9 100644 --- a/css/modals/invite/_invite.scss +++ b/css/modals/invite/_invite.scss @@ -4,4 +4,44 @@ */ #inviteDialogRemovePassword { cursor: hand; -} \ No newline at end of file +} + +.invite-dialog { + .form-control { + padding: 0; + } + + .inviteLink { + color: $readOnlyInputColor; + } + + .lock-state { + display: flex; + } + + .password-overview { + .form-control { + margin-top: 10px; + } + + .password-overview-toggle-edit, + .remove-password-link { + cursor: pointer; + text-decoration: none; + } + + .password-overview-status, + .remove-password { + display: flex; + justify-content: space-between; + } + + .remove-password { + margin-top: 15px; + } + } + + .remove-password-current { + color: $inputControlEmColor; + } +} diff --git a/lang/main.json b/lang/main.json index c2b2e23f0..6ef74e6c3 100644 --- a/lang/main.json +++ b/lang/main.json @@ -153,8 +153,7 @@ "cameraAndMic": "Camera and microphone", "moderator": "MODERATOR", "password": "SET PASSWORD", - "audioVideo": "AUDIO AND VIDEO", - "setPasswordLabel": "Lock your room with a password." + "audioVideo": "AUDIO AND VIDEO" }, "profile": { "title": "Profile", @@ -225,8 +224,6 @@ "connecting": "Connecting", "copy": "Copy", "error": "Error", - "roomLocked": "This call is locked. New callers must have the link and enter the password to join", - "addPassword": "Add a password", "createPassword": "Create password", "detectext": "Error when trying to detect desktopsharing extension.", "failtoinstall": "Failed to install desktop sharing extension", @@ -429,5 +426,13 @@ "noOtherDevices": "No other devices available", "selectADevice": "Select a device", "testAudio": "Test sound" + }, + "invite": { + "addPassword": "Add password", + "hidePassword": "Hide password", + "inviteTo": "Invite people to __conferenceName__", + "locked": "This call is locked. New callers must have the link and enter the password to join.", + "showPassword": "Show password", + "unlocked": "This call is unlocked. Any new caller with the link may join the call." } } diff --git a/modules/UI/invite/Invite.js b/modules/UI/invite/Invite.js deleted file mode 100644 index b5127d7b6..000000000 --- a/modules/UI/invite/Invite.js +++ /dev/null @@ -1,187 +0,0 @@ -/* global JitsiMeetJS, APP */ -const logger = require("jitsi-meet-logger").getLogger(__filename); - -import InviteDialogView from './InviteDialogView'; -import createRoomLocker from './RoomLocker'; -import UIEvents from '../../../service/UI/UIEvents'; - -const ConferenceEvents = JitsiMeetJS.events.conference; - -/** - * Invite module - * Constructor takes conference object giving - * ability to subscribe on its events - */ -class Invite { - constructor(conference) { - this.conference = conference; - this.inviteUrl = APP.ConferenceUrl.getInviteUrl(); - this.createRoomLocker(conference); - this.registerListeners(); - } - - /** - * Registering listeners. - * Primarily listeners for conference events. - */ - registerListeners() { - - this.conference.on(ConferenceEvents.LOCK_STATE_CHANGED, - (locked, error) => { - - logger.log("Received channel password lock change: ", locked, - error); - - if (!locked) { - this.getRoomLocker().resetPassword(); - } - - this.setLockedFromElsewhere(locked); - }); - - this.conference.on(ConferenceEvents.USER_ROLE_CHANGED, (id) => { - if (APP.conference.isLocalId(id) - && this.isModerator !== this.conference.isModerator()) { - - this.setModerator(this.conference.isModerator()); - } - }); - - APP.UI.addListener( UIEvents.INVITE_CLICKED, - () => { this.openLinkDialog(); }); - } - - /** - * Updates the view. - * If dialog hasn't been defined - - * creates it and updates - */ - updateView() { - if (!this.view) { - this.initDialog(); - } - - this.view.updateView(); - } - - /** - * Room locker factory - * @param room - * @returns {Object} RoomLocker - * @factory - */ - createRoomLocker(room = this.conference) { - let roomLocker = createRoomLocker(room); - this.roomLocker = roomLocker; - return this.getRoomLocker(); - } - - /** - * Room locker getter - * @returns {Object} RoomLocker - */ - getRoomLocker() { - return this.roomLocker; - } - - /** - * Opens the invite link dialog. - */ - openLinkDialog () { - if (!this.view) { - this.initDialog(); - } - - this.view.open(); - } - - /** - * Dialog initialization. - * creating view object using as a model this module - */ - initDialog() { - this.view = new InviteDialogView(this); - } - - /** - * Password getter - * @returns {String} password - */ - getPassword() { - return this.getRoomLocker().password; - } - - /** - * Switches between the moderator view and normal view. - * - * @param isModerator indicates if the participant is moderator - */ - setModerator(isModerator) { - this.isModerator = isModerator; - - this.updateView(); - } - - /** - * Allows to unlock the room. - * If the current user is moderator. - */ - setRoomUnlocked() { - if (this.isModerator) { - this.getRoomLocker().lock().then(() => { - APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK); - this.updateView(); - }); - } - } - - /** - * Allows to lock the room if - * the current user is moderator. - * Takes the password. - * @param {String} newPass - */ - setRoomLocked(newPass) { - let isModerator = this.isModerator; - if (isModerator && (newPass || !this.getRoomLocker().isLocked)) { - this.getRoomLocker().lock(newPass).then(() => { - APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK); - this.updateView(); - }); - } - } - - /** - * Helper method for encoding - * Invite URL - * @returns {string} - */ - getEncodedInviteUrl() { - return encodeURI(this.inviteUrl); - } - - /** - * Is locked flag. - * Delegates to room locker - * @returns {Boolean} isLocked - */ - isLocked() { - return this.getRoomLocker().isLocked; - } - - /** - * Set flag locked from elsewhere to room locker. - * @param isLocked - */ - setLockedFromElsewhere(isLocked) { - let roomLocker = this.getRoomLocker(); - let oldLockState = roomLocker.isLocked; - if (oldLockState !== isLocked) { - roomLocker.lockedElsewhere = isLocked; - APP.UI.emitEvent(UIEvents.TOGGLE_ROOM_LOCK); - this.updateView(); - } - } -} - -export default Invite; diff --git a/modules/UI/invite/InviteDialogView.js b/modules/UI/invite/InviteDialogView.js deleted file mode 100644 index 51e339d85..000000000 --- a/modules/UI/invite/InviteDialogView.js +++ /dev/null @@ -1,378 +0,0 @@ -/* global $, APP, JitsiMeetJS */ -const logger = require("jitsi-meet-logger").getLogger(__filename); - -/** - * Substate for password - * @type {{LOCKED: string, UNLOCKED: string}} - */ -const States = { - LOCKED: 'locked', - UNLOCKED: 'unlocked' -}; - -/** - * Class representing view for Invite dialog - * @class InviteDialogView - */ -export default class InviteDialogView { - constructor(model) { - this.unlockHint = "unlockHint"; - this.lockHint = "lockHint"; - this.model = model; - - if (this.model.inviteUrl === null) { - this.inviteAttributes = `data-i18n="[value]inviteUrlDefaultMsg"`; - } else { - this.inviteAttributes - = `value="${this.model.getEncodedInviteUrl()}"`; - } - - this.initDialog(); - } - - /** - * Initialization of dialog property - */ - initDialog() { - let dialog = {}; - dialog.closeFunction = this.closeFunction.bind(this); - dialog.submitFunction = this.submitFunction.bind(this); - dialog.loadedFunction = this.loadedFunction.bind(this); - - dialog.titleKey = "dialog.shareLink"; - this.dialog = dialog; - - this.dialog.states = this.getStates(); - } - - /** - * Event handler for submitting dialog - * @param e - * @param v - */ - submitFunction(e, v) { - if (v && this.model.inviteUrl) { - JitsiMeetJS.analytics.sendEvent('toolbar.invite.button'); - } else { - JitsiMeetJS.analytics.sendEvent('toolbar.invite.cancel'); - } - } - - /** - * Event handler for load dialog - * @param event - */ - loadedFunction(event) { - if (this.model.inviteUrl) { - document.getElementById('inviteLinkRef').select(); - } else { - if (event && event.target) { - $(event.target).find('button[value=true]') - .prop('disabled', true); - } - } - } - - /** - * Event handler for closing dialog - * @param e - * @param v - * @param m - * @param f - */ - closeFunction(e, v, m, f) { - $(document).off('click', '.copyInviteLink', this.copyToClipboard); - - if(!v && !m && !f) - JitsiMeetJS.analytics.sendEvent('toolbar.invite.close'); - } - - /** - * Returns all states of the dialog - * @returns {{}} - */ - getStates() { - let { - titleKey - } = this.dialog; - let doneMsg = APP.translation.generateTranslationHTML('dialog.done'); - let states = {}; - let buttons = {}; - buttons[`${doneMsg}`] = true; - - states[States.UNLOCKED] = { - titleKey, - html: this.getShareLinkBlock() + this.getAddPasswordBlock(), - buttons - }; - states[States.LOCKED] = { - titleKey, - html: this.getShareLinkBlock() + this.getPasswordBlock(), - buttons - }; - - return states; - } - - /** - * Layout for invite link input - * @returns {string} - */ - getShareLinkBlock() { - let classes = 'button-control button-control_light copyInviteLink'; - return ( - `
- -
- - -
-

- - -

-

- - -

-
` - ); - } - - /** - * Layout for adding password input - * @returns {string} - */ - getAddPasswordBlock() { - let html; - - if (this.model.isModerator) { - html = (` -
- -
- - -
-
- `); - } else { - html = ''; - } - - return html; - } - - /** - * Layout for password (when room is locked) - * @returns {string} - */ - getPasswordBlock() { - let password = this.model.getPassword(); - let { isModerator } = this.model; - - if (isModerator) { - return (` -
- -
-

- - - ${password} - -

- -
-
- `); - } else { - return (` -
-

A participant protected this call with a password.

-
- `); - } - - } - - - - /** - * Opening the dialog - */ - open() { - let { - submitFunction, - loadedFunction, - closeFunction - } = this.dialog; - - let states = this.getStates(); - let initial = this.model.roomLocked ? States.LOCKED : States.UNLOCKED; - - APP.UI.messageHandler.openDialogWithStates(states, { - submit: submitFunction, - loaded: loadedFunction, - close: closeFunction, - size: 'medium' - }); - $.prompt.goToState(initial); - - this.registerListeners(); - this.updateView(); - } - - /** - * Setting event handlers - * used in dialog - */ - registerListeners() { - const ENTER_KEY = 13; - let addPasswordBtn = '#addPasswordBtn'; - let copyInviteLink = '.copyInviteLink'; - let newPasswordInput = '#newPasswordInput'; - let removePassword = '#inviteDialogRemovePassword'; - - $(document).on('click', copyInviteLink, this.copyToClipboard); - $(removePassword).on('click', () => { - this.model.setRoomUnlocked(); - }); - let boundSetPassword = this._setPassword.bind(this); - $(document).on('click', addPasswordBtn, boundSetPassword); - let boundDisablePass = this.disableAddPassIfInputEmpty.bind(this); - $(document).on('keypress', newPasswordInput, boundDisablePass); - - // We need to handle keydown event because impromptu - // is listening to it too for closing the dialog - $(newPasswordInput).on('keydown', (e) => { - if (e.keyCode === ENTER_KEY) { - e.stopPropagation(); - this._setPassword(); - } - }); - } - - /** - * Marking room as locked - * @private - */ - _setPassword() { - let $passInput = $('#newPasswordInput'); - let newPass = $passInput.val(); - - if(newPass) { - this.model.setRoomLocked(newPass); - } - } - - /** - * Checking input and if it's empty then - * disable add pass button - */ - disableAddPassIfInputEmpty() { - let $passInput = $('#newPasswordInput'); - let $addPassBtn = $('#addPasswordBtn'); - - if(!$passInput.val()) { - $addPassBtn.prop('disabled', true); - } else { - $addPassBtn.prop('disabled', false); - } - } - - /** - * Copying text to clipboard - */ - copyToClipboard() { - $('.inviteLink').each(function () { - let $el = $(this).closest('.jqistate'); - - // TOFIX: We can select only visible elements - if($el.css('display') === 'block') { - this.select(); - - try { - document.execCommand('copy'); - this.blur(); - } - catch (err) { - logger.error('error when copy the text'); - } - } - }); - } - - /** - * Method syncing the view and the model - */ - updateView() { - let pass = this.model.getPassword(); - let { isModerator } = this.model; - if (this.model.getRoomLocker().lockedElsewhere || !pass) { - $('#inviteDialogPassword').attr("data-i18n", "passwordSetRemotely"); - APP.translation.translateElement($('#inviteDialogPassword')); - } else { - $('#inviteDialogPassword').removeAttr("data-i18n"); - $('#inviteDialogPassword').text(pass); - } - - // if we are not moderator we cannot remove password - if (isModerator) - $('#inviteDialogRemovePassword').show(); - else - $('#inviteDialogRemovePassword').hide(); - - $('#newPasswordInput').val(''); - this.disableAddPassIfInputEmpty(); - - this.updateInviteLink(); - - $.prompt.goToState( - (this.model.isLocked()) - ? States.LOCKED - : States.UNLOCKED); - - let roomLocked = `.${this.lockHint}`; - let roomUnlocked = `.${this.unlockHint}`; - - let showDesc = this.model.isLocked() ? roomLocked : roomUnlocked; - let hideDesc = !this.model.isLocked() ? roomLocked : roomUnlocked; - - $(showDesc).show(); - $(hideDesc).hide(); - } - - /** - * Updates invite link - */ - updateInviteLink() { - // If the invite dialog has been already opened we update the - // information. - let inviteLink = document.querySelectorAll('.inviteLink'); - let list = Array.from(inviteLink); - list.forEach((inviteLink) => { - inviteLink.value = this.model.inviteUrl; - inviteLink.select(); - }); - - $('#inviteLinkRef').parent() - .find('button[value=true]').prop('disabled', false); - } -} \ No newline at end of file diff --git a/modules/UI/invite/RoomLocker.js b/modules/UI/invite/RoomLocker.js deleted file mode 100644 index 59f43e331..000000000 --- a/modules/UI/invite/RoomLocker.js +++ /dev/null @@ -1,103 +0,0 @@ -/* global APP, JitsiMeetJS */ -const logger = require("jitsi-meet-logger").getLogger(__filename); - -/** - * Show notification that user cannot set password for the conference - * because server doesn't support that. - */ -function notifyPasswordNotSupported () { - logger.warn('room passwords not supported'); - APP.UI.messageHandler.showError( - "dialog.warning", "dialog.passwordNotSupported"); -} - -/** - * Show notification that setting password for the conference failed. - * @param {Error} err error - */ -function notifyPasswordFailed(err) { - logger.warn('setting password failed', err); - APP.UI.messageHandler.showError( - "dialog.lockTitle", "dialog.lockMessage"); -} - -const ConferenceErrors = JitsiMeetJS.errors.conference; - -/** - * Create new RoomLocker for the conference. - * It allows to set or remove password for the conference, - * or ask for required password. - * @returns {RoomLocker} - */ -export default function createRoomLocker (room) { - let password; - /** - * If the room was locked from someone other than us, we indicate it with - * this property in order to have correct roomLocker state of isLocked. - * @type {boolean} whether room is locked, but not from us. - */ - let lockedElsewhere = false; - - /** - * @class RoomLocker - */ - return { - get isLocked () { - return !!password || lockedElsewhere; - }, - - get password () { - return password; - }, - - /** - * Allows to set new password - * @param newPass - * @returns {Promise.} - */ - lock (newPass) { - return room.lock(newPass).then(() => { - password = newPass; - // If the password is undefined this means that we're removing - // it for everyone. - if (!password) - lockedElsewhere = false; - }).catch(function (err) { - logger.error(err); - if (err === ConferenceErrors.PASSWORD_NOT_SUPPORTED) { - notifyPasswordNotSupported(); - } else { - notifyPasswordFailed(err); - } - throw err; - }); - }, - - /** - * Sets that the room is locked from another user, not us. - * @param {boolean} value locked/unlocked state - */ - set lockedElsewhere (value) { - lockedElsewhere = value; - }, - - /** - * Whether room is locked from someone else. - * @returns {boolean} whether room is not locked locally, - * but it is still locked. - */ - get lockedElsewhere () { - return lockedElsewhere; - }, - - /** - * Reset the password. Can be useful when room - * has been unlocked from elsewhere and we can use - * this method for sync the pass - */ - resetPassword() { - password = null; - }, - - }; -} diff --git a/modules/UI/side_pannels/contactlist/ContactList.js b/modules/UI/side_pannels/contactlist/ContactList.js index 42e494d3a..51a0274be 100644 --- a/modules/UI/side_pannels/contactlist/ContactList.js +++ b/modules/UI/side_pannels/contactlist/ContactList.js @@ -19,14 +19,14 @@ class ContactList { } /** - * Is locked flag. - * Delegates to Invite module - * TO FIX: find a better way to access the IS LOCKED state of the invite. + * Returns true if the current conference is locked. * * @returns {Boolean} */ isLocked() { - return APP.conference.invite.isLocked(); + const conference = APP.store.getState()['features/base/conference']; + + return conference.locked; } /** diff --git a/modules/UI/side_pannels/contactlist/ContactListView.js b/modules/UI/side_pannels/contactlist/ContactListView.js index 93c22dbb7..f290d65b9 100644 --- a/modules/UI/side_pannels/contactlist/ContactListView.js +++ b/modules/UI/side_pannels/contactlist/ContactListView.js @@ -1,6 +1,8 @@ /* global $, APP, interfaceConfig */ const logger = require("jitsi-meet-logger").getLogger(__filename); +import { openInviteDialog } from '../../../../react/features/invite'; + import Avatar from '../../avatar/Avatar'; import UIEvents from '../../../../service/UI/UIEvents'; import UIUtil from '../../util/UIUtil'; @@ -96,7 +98,7 @@ var ContactListView = { this.model = model; this.addInviteButton(); this.registerListeners(); - this.toggleLock(); + this.setLockDisplay(false); }, /** * Adds layout for invite button @@ -108,8 +110,9 @@ var ContactListView = { .insertAdjacentHTML('afterend', this.getInviteButtonLayout()); APP.translation.translateElement($(container)); + $(document).on('click', '#addParticipantsBtn', () => { - APP.UI.emitEvent(UIEvents.INVITE_CLICKED); + APP.store.dispatch(openInviteDialog()); }); }, /** @@ -125,8 +128,8 @@ var ContactListView = { return ( `
-
${lockedHtml} @@ -158,7 +161,7 @@ var ContactListView = { let displayNameChange = this.onDisplayNameChange.bind(this); APP.UI.addListener( UIEvents.TOGGLE_ROOM_LOCK, - this.toggleLock.bind(this)); + this.setLockDisplay.bind(this)); APP.UI.addListener( UIEvents.CONTACT_ADDED, this.onAddContact.bind(this)); @@ -167,16 +170,15 @@ var ContactListView = { APP.UI.addListener(UIEvents.DISPLAY_NAME_CHANGED, displayNameChange); }, /** - * Updating the view according the model - * @param type {String} type of change - * @returns {Promise} + * Updates the view according to the passed in lock state. + * + * @param {boolean} isLocked - True if the locked UI state should display. */ - toggleLock() { - let isLocked = this.model.isLocked(); - let showKey = isLocked ? this.lockKey : this.unlockKey; - let hideKey = !isLocked ? this.lockKey : this.unlockKey; - let showId = `contactList${showKey}`; - let hideId = `contactList${hideKey}`; + setLockDisplay(isLocked) { + const showKey = isLocked ? this.lockKey : this.unlockKey; + const hideKey = !isLocked ? this.lockKey : this.unlockKey; + const showId = `contactList${showKey}`; + const hideId = `contactList${hideKey}`; $(`#${showId}`).show(); $(`#${hideId}`).hide(); diff --git a/react/features/base/conference/actionTypes.js b/react/features/base/conference/actionTypes.js index ce576ec4b..f04ce7c46 100644 --- a/react/features/base/conference/actionTypes.js +++ b/react/features/base/conference/actionTypes.js @@ -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. diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index 876879d5f..2696e2f67 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -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(); diff --git a/react/features/base/conference/reducer.js b/react/features/base/conference/reducer.js index c594762fa..3008ae298 100644 --- a/react/features/base/conference/reducer.js +++ b/react/features/base/conference/reducer.js @@ -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; } diff --git a/react/features/invite/actions.js b/react/features/invite/actions.js new file mode 100644 index 000000000..3ca5f7ca1 --- /dev/null +++ b/react/features/invite/actions.js @@ -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()) + })); + }; +} diff --git a/react/features/invite/components/AddPasswordForm.js b/react/features/invite/components/AddPasswordForm.js new file mode 100644 index 000000000..ce5e62f65 --- /dev/null +++ b/react/features/invite/components/AddPasswordForm.js @@ -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 ( +
+
+ + +
+
+ ); + } + + /** + * 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)); diff --git a/react/features/invite/components/InviteDialog.js b/react/features/invite/components/InviteDialog.js new file mode 100644 index 000000000..ddb8c10f6 --- /dev/null +++ b/react/features/invite/components/InviteDialog.js @@ -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 ( + +
+ + +
+
+ ); + } +} + +/** + * 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)); diff --git a/react/features/invite/components/LockStatePanel.js b/react/features/invite/components/LockStatePanel.js new file mode 100644 index 000000000..1f701f124 --- /dev/null +++ b/react/features/invite/components/LockStatePanel.js @@ -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 ( +
+ + + { this.props.t(lockTextKey) } + +
+ ); + } +} + +export default translate(LockStatePanel); diff --git a/react/features/invite/components/PasswordContainer.js b/react/features/invite/components/PasswordContainer.js new file mode 100644 index 000000000..9c044ddac --- /dev/null +++ b/react/features/invite/components/PasswordContainer.js @@ -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 ( +
+
+ + { this._renderShowPasswordLink() } +
+ { this._renderPasswordEdit() } +
+ ); + } + + /** + * 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 + ? + : ; + } + + /** + * 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 ( + + { this.props.t(toggleStatusKey) } + + ); + } +} + +export default translate(PasswordContainer); diff --git a/react/features/invite/components/RemovePasswordForm.js b/react/features/invite/components/RemovePasswordForm.js new file mode 100644 index 000000000..bcf82adfb --- /dev/null +++ b/react/features/invite/components/RemovePasswordForm.js @@ -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 ( +
+
+ { this._getPasswordPreviewText() } +
+ + { this.props.t('dialog.removePassword') } + +
+ ); + } + + /** + * Creates a ReactElement for displaying the current password. + * + * @private + * @returns {ReactElement} + */ + _getPasswordPreviewText() { + return ( + + + { `${this.props.t('dialog.currentPassword')} ` } + + + { this.props.lockedLocally + ? this.props.password + : this.props.t('passwordSetRemotely') } + + + ); + } + + /** + * 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)); diff --git a/react/features/invite/components/ShareLinkForm.js b/react/features/invite/components/ShareLinkForm.js new file mode 100644 index 000000000..f2c52deeb --- /dev/null +++ b/react/features/invite/components/ShareLinkForm.js @@ -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 ( +
+ +
+ + +
+
+ ); + } + + /** + * 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); diff --git a/react/features/invite/components/index.js b/react/features/invite/components/index.js new file mode 100644 index 000000000..36cdc6dc8 --- /dev/null +++ b/react/features/invite/components/index.js @@ -0,0 +1 @@ +export { default as InviteDialog } from './InviteDialog'; diff --git a/react/features/invite/index.js b/react/features/invite/index.js new file mode 100644 index 000000000..3c46ed49d --- /dev/null +++ b/react/features/invite/index.js @@ -0,0 +1,2 @@ +export * from './actions'; +export * from './components'; diff --git a/react/features/room-lock/components/PasswordRequiredPrompt.web.js b/react/features/room-lock/components/PasswordRequiredPrompt.web.js index 0d42014f9..9ead15c95 100644 --- a/react/features/room-lock/components/PasswordRequiredPrompt.web.js +++ b/react/features/room-lock/components/PasswordRequiredPrompt.web.js @@ -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( diff --git a/react/features/room-lock/constants.js b/react/features/room-lock/constants.js new file mode 100644 index 000000000..0ca678407 --- /dev/null +++ b/react/features/room-lock/constants.js @@ -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'; diff --git a/react/features/room-lock/index.js b/react/features/room-lock/index.js index a640fd3bf..d7ebc6536 100644 --- a/react/features/room-lock/index.js +++ b/react/features/room-lock/index.js @@ -1,4 +1,5 @@ export * from './actions'; export * from './components'; +export * from './constants'; import './middleware'; diff --git a/react/features/room-lock/middleware.js b/react/features/room-lock/middleware.js index 10a8fb6e5..7b392b994 100644 --- a/react/features/room-lock/middleware.js +++ b/react/features/room-lock/middleware.js @@ -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); +} diff --git a/react/features/toolbox/components/Toolbox.native.js b/react/features/toolbox/components/Toolbox.native.js index 6a3be5fce..a2aa182bd 100644 --- a/react/features/toolbox/components/Toolbox.native.js +++ b/react/features/toolbox/components/Toolbox.native.js @@ -322,7 +322,7 @@ function _mapStateToProps(state) { * @protected * @type {boolean} */ - _locked: conference.locked + _locked: Boolean(conference.locked) }; } diff --git a/react/features/toolbox/components/Toolbox.web.js b/react/features/toolbox/components/Toolbox.web.js index 49cd770d5..da1cc4316 100644 --- a/react/features/toolbox/components/Toolbox.web.js +++ b/react/features/toolbox/components/Toolbox.web.js @@ -223,7 +223,6 @@ function _mapDispatchToProps(dispatch: Function): Object { * @returns {{ * _alwaysVisible: boolean, * _audioMuted: boolean, - * _locked: boolean, * _subjectSlideIn: boolean, * _videoMuted: boolean * }} diff --git a/react/features/toolbox/defaultToolbarButtons.js b/react/features/toolbox/defaultToolbarButtons.js index 7082c4ee5..3b0c92d4c 100644 --- a/react/features/toolbox/defaultToolbarButtons.js +++ b/react/features/toolbox/defaultToolbarButtons.js @@ -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' }, diff --git a/service/UI/UIEvents.js b/service/UI/UIEvents.js index 8720e50b7..d11d499ed 100644 --- a/service/UI/UIEvents.js +++ b/service/UI/UIEvents.js @@ -22,10 +22,6 @@ export default { VIDEO_MUTED: "UI.video_muted", ETHERPAD_CLICKED: "UI.etherpad_clicked", SHARED_VIDEO_CLICKED: "UI.start_shared_video", - /** - * Indicates that an invite button has been clicked. - */ - INVITE_CLICKED: "UI.invite_clicked", /** * Updates shared video with params: url, state, time(optional) * Where url is the video link, state is stop/start/pause and time is the