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-12 17:23:43 -07:00 committed by Lyubo Marinov
parent 4097be1908
commit 44b81b20e3
30 changed files with 903 additions and 727 deletions

View File

@ -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") {

View File

@ -83,6 +83,8 @@ $rateStarSize: 34px;
* Modals
*/
$modalButtonFontSize: 14px;
$modalMockAKInputBackground: #fafbfc;
$modalMockAKInputBorder: 1px solid #f4f5f7;
$modalTextColor: #333;
/**

View File

@ -79,6 +79,12 @@
.modal-dialog-form {
color: $modalTextColor;
.input-control {
background: $modalMockAKInputBackground;
border: $modalMockAKInputBorder;
color: inherit;
}
}
.modal-dialog-footer {
font-size: $modalButtonFontSize;

View File

@ -4,4 +4,44 @@
*/
#inviteDialogRemovePassword {
cursor: hand;
}
}
.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;
}
}

View File

@ -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."
}
}

View File

@ -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;

View File

@ -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 (
`<div class="form-control">
<label class="form-control__label" for="inviteLinkRef"
data-i18n="${this.dialog.titleKey}"></label>
<div class="form-control__container">
<input class="input-control inviteLink"
id="inviteLinkRef" type="text"
${this.inviteAttributes} readonly>
<button data-i18n="dialog.copy" class="${classes}"></button>
</div>
<p class="form-control__hint ${this.lockHint}">
<span class="icon-security-locked"></span>
<span data-i18n="dialog.roomLocked"></span>
</p>
<p class="form-control__hint ${this.unlockHint}">
<span class="icon-security"></span>
<span data-i18n="roomUnlocked"></span>
</p>
</div>`
);
}
/**
* Layout for adding password input
* @returns {string}
*/
getAddPasswordBlock() {
let html;
if (this.model.isModerator) {
html = (`
<div class="form-control">
<label class="form-control__label"
for="newPasswordInput" data-i18n="dialog.addPassword">
</label>
<div class="form-control__container">
<input class="input-control"
id="newPasswordInput"
type="text"
data-i18n="[placeholder]dialog.createPassword">
<button id="addPasswordBtn" id="inviteDialogAddPassword"
disabled data-i18n="dialog.add"
class="button-control button-control_light">
</button>
</div>
</div>
`);
} 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 (`
<div class="form-control">
<label class="form-control__label"
data-i18n="dialog.passwordLabel"></label>
<div class="form-control__container">
<p>
<span class="form-control__text"
data-i18n="dialog.currentPassword"></span>
<span id="inviteDialogPassword"
class="form-control__em">
${password}
</span>
</p>
<a class="link form-control__right"
id="inviteDialogRemovePassword"
data-i18n="dialog.removePassword"></a>
</div>
</div>
`);
} else {
return (`
<div class="form-control">
<p>A participant protected this call with a password.</p>
</div>
`);
}
}
/**
* 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);
}
}

View File

@ -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.<TResult>}
*/
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;
},
};
}

View File

@ -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;
}
/**

View File

@ -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 (
`<div class="sideToolbarBlock first">
<button id="addParticipantsBtn"
data-i18n="${key}"
<button id="addParticipantsBtn"
data-i18n="${key}"
class="${classes}"></button>
<div>
${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();

View File

@ -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.

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();

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;
}

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())
}));
};
}

View File

@ -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));

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -0,0 +1 @@
export { default as InviteDialog } from './InviteDialog';

View File

@ -0,0 +1,2 @@
export * from './actions';
export * from './components';

View File

@ -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(

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';

View File

@ -1,4 +1,5 @@
export * from './actions';
export * from './components';
export * from './constants';
import './middleware';

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);
}

View File

@ -322,7 +322,7 @@ function _mapStateToProps(state) {
* @protected
* @type {boolean}
*/
_locked: conference.locked
_locked: Boolean(conference.locked)
};
}

View File

@ -223,7 +223,6 @@ function _mapDispatchToProps(dispatch: Function): Object {
* @returns {{
* _alwaysVisible: boolean,
* _audioMuted: boolean,
* _locked: boolean,
* _subjectSlideIn: boolean,
* _videoMuted: boolean
* }}

View File

@ -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'
},

View File

@ -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