From 928181cd7a59727ae7575cc2e4db744c9398d17b Mon Sep 17 00:00:00 2001 From: virtuacoplenny Date: Wed, 28 Jun 2017 20:35:43 -0700 Subject: [PATCH] feat(display-name): convert to React (#1672) * feat(display-name): convert to React - Create a new React Component for displaying and updating display names on small videos - The updating of the Component is defined in the parent class SmallVideo, which children will get access to through prototype copying - Create a new actionType and middleware so name changes that occur in DisplayName can be propogated to outside redux - Update the local video's DisplayName when a conference is joined or else the component may keep an undefined user id * squash: query for the container, not the el owned by react --- conference.js | 7 +- modules/UI/UI.js | 4 + modules/UI/shared_video/SharedVideo.js | 26 +- modules/UI/videolayout/LocalVideo.js | 122 ++------- modules/UI/videolayout/RemoteVideo.js | 35 +-- modules/UI/videolayout/SmallVideo.js | 46 +++- .../features/base/participants/actionTypes.js | 12 + react/features/base/participants/actions.js | 25 ++ react/features/base/participants/constants.js | 7 + .../features/base/participants/middleware.js | 20 ++ .../display-name/components/DisplayName.js | 239 ++++++++++++++++++ .../features/display-name/components/index.js | 1 + react/features/display-name/index.js | 1 + .../filmstrip/components/Filmstrip.web.js | 1 + 14 files changed, 396 insertions(+), 150 deletions(-) create mode 100644 react/features/display-name/components/DisplayName.js create mode 100644 react/features/display-name/components/index.js create mode 100644 react/features/display-name/index.js diff --git a/conference.js b/conference.js index d7301cf7e..d3b6568b9 100644 --- a/conference.js +++ b/conference.js @@ -37,6 +37,7 @@ import { } from './react/features/base/lib-jitsi-meet'; import { localParticipantRoleChanged, + MAX_DISPLAY_NAME_LENGTH, participantJoined, participantLeft, participantRoleChanged, @@ -103,12 +104,6 @@ const commands = { SHARED_VIDEO: "shared-video" }; -/** - * Max length of the display names. If we receive longer display name the - * additional chars are going to be cut. - */ -const MAX_DISPLAY_NAME_LENGTH = 50; - /** * Open Connection. When authentication failed it shows auth dialog. * @param roomName the room name to use diff --git a/modules/UI/UI.js b/modules/UI/UI.js index 6204f7453..46f8cc374 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -253,6 +253,10 @@ UI.initConference = function () { UI.mucJoined = function () { VideoLayout.mucJoined(); + + // Update local video now that a conference is joined a user ID should be + // set. + UI.changeDisplayName('localVideoContainer', APP.settings.getDisplayName()); }; /*** diff --git a/modules/UI/shared_video/SharedVideo.js b/modules/UI/shared_video/SharedVideo.js index 7c5ffb2ee..e768a3ff2 100644 --- a/modules/UI/shared_video/SharedVideo.js +++ b/modules/UI/shared_video/SharedVideo.js @@ -659,6 +659,10 @@ SharedVideoThumb.prototype.createContainer = function (spanId) { avatar.src = "https://img.youtube.com/vi/" + this.url + "/0.jpg"; container.appendChild(avatar); + const displayNameContainer = document.createElement('div'); + displayNameContainer.className = 'displayNameContainer'; + container.appendChild(displayNameContainer); + var remotes = document.getElementById('filmstripRemoteVideosContainer'); return remotes.appendChild(container); }; @@ -696,23 +700,11 @@ SharedVideoThumb.prototype.setDisplayName = function(displayName) { return; } - var nameSpan = $('#' + this.videoSpanId + '>span.displayname'); - - // If we already have a display name for this video. - if (nameSpan.length > 0) { - if (displayName && displayName.length > 0) { - $('#' + this.videoSpanId + '_name').text(displayName); - } - } else { - nameSpan = document.createElement('span'); - nameSpan.className = 'displayname'; - $('#' + this.videoSpanId)[0].appendChild(nameSpan); - - if (displayName && displayName.length > 0) - $(nameSpan).text(displayName); - nameSpan.id = this.videoSpanId + '_name'; - } - + this.updateDisplayName({ + displayName: displayName || '', + elementID: `${this.videoSpanId}_name`, + participantID: this.id + }); }; /** diff --git a/modules/UI/videolayout/LocalVideo.js b/modules/UI/videolayout/LocalVideo.js index bd7c9825b..7bb98f9bc 100644 --- a/modules/UI/videolayout/LocalVideo.js +++ b/modules/UI/videolayout/LocalVideo.js @@ -47,116 +47,38 @@ LocalVideo.prototype.setDisplayName = function(displayName) { return; } - var nameSpan = $('#' + this.videoSpanId + ' .displayname'); - var defaultLocalDisplayName = APP.translation.generateTranslationHTML( - interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME); - - var meHTML; - // If we already have a display name for this video. - if (nameSpan.length > 0) { - if (nameSpan.text() !== displayName) { - if (displayName && displayName.length > 0) { - meHTML = APP.translation.generateTranslationHTML("me"); - $('#localDisplayName').html( - `${UIUtil.escapeHtml(displayName)} (${meHTML})` - ); - $('#editDisplayName').val( - `${UIUtil.escapeHtml(displayName)}` - ); - } else { - $('#localDisplayName').html(defaultLocalDisplayName); - } - } - this.updateView(); - } else { - nameSpan = document.createElement('span'); - nameSpan.className = 'displayname'; - document.getElementById(this.videoSpanId) - .appendChild(nameSpan); - - - if (displayName && displayName.length > 0) { - meHTML = APP.translation.generateTranslationHTML("me"); - nameSpan.innerHTML = UIUtil.escapeHtml(displayName) + meHTML; - } - else { - nameSpan.innerHTML = defaultLocalDisplayName; - } - - - nameSpan.id = 'localDisplayName'; - //translates popover of edit button - APP.translation.translateElement($("a.displayname")); - - var editableText = document.createElement('input'); - editableText.className = 'editdisplayname'; - editableText.type = 'text'; - editableText.id = 'editDisplayName'; - - if (displayName && displayName.length) { - editableText.value = displayName; - } - - editableText.setAttribute('style', 'display:none;'); - editableText.setAttribute('data-i18n', - '[placeholder]defaultNickname'); - editableText.setAttribute("data-i18n-options", - JSON.stringify({name: "Jane Pink"})); - APP.translation.translateElement($(editableText)); - - this.container - .appendChild(editableText); - - var self = this; - $('#localVideoContainer .displayname') - .bind("click", function (e) { - let $editDisplayName = $('#editDisplayName'); - - e.preventDefault(); - e.stopPropagation(); - // we set display to be hidden - self.hideDisplayName = true; - // update the small video vide to hide the display name - self.updateView(); - // disables further updates in the thumbnail to stay in the - // edit mode - self.disableUpdateView = true; - - $editDisplayName.show(); - $editDisplayName.focus(); - $editDisplayName.select(); - - $editDisplayName.one("focusout", function () { - self.emitter.emit(UIEvents.NICKNAME_CHANGED, this.value); - $editDisplayName.hide(); - // stop editing, display displayName and resume updating - // the thumbnail - self.hideDisplayName = false; - self.disableUpdateView = false; - self.updateView(); - }); - - $editDisplayName.on('keydown', function (e) { - if (e.keyCode === 13) { - e.preventDefault(); - $('#editDisplayName').hide(); - // focusout handler will save display name - } - }); - }); - } + this.updateDisplayName({ + allowEditing: true, + displayName: displayName, + displayNameSuffix: interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME, + elementID: 'localDisplayName', + participantID: this.id + }); }; LocalVideo.prototype.changeVideo = function (stream) { this.videoStream = stream; let localVideoClick = (event) => { + // TODO Checking the classList is a workround to allow events to bubble + // into the DisplayName component if it was clicked. React's synthetic + // events will fire after jQuery handlers execute, so stop propogation + // at this point will prevent DisplayName from getting click events. + // This workaround should be removeable once LocalVideo is a React + // Component because then the components share the same eventing system. + const { classList } = event.target; + const clickedOnDisplayName = classList.contains('displayname') + || classList.contains('editdisplayname'); + // FIXME: with Temasys plugin event arg is not an event, but // the clicked object itself, so we have to skip this call - if (event.stopPropagation) { + if (event.stopPropagation && !clickedOnDisplayName) { event.stopPropagation(); } - this.VideoLayout.handleVideoThumbClicked(this.id); + + if (!clickedOnDisplayName) { + this.VideoLayout.handleVideoThumbClicked(this.id); + } }; let localVideoContainerSelector = $('#localVideoContainer'); diff --git a/modules/UI/videolayout/RemoteVideo.js b/modules/UI/videolayout/RemoteVideo.js index 42ae6542d..016dd6654 100644 --- a/modules/UI/videolayout/RemoteVideo.js +++ b/modules/UI/videolayout/RemoteVideo.js @@ -516,6 +516,7 @@ RemoteVideo.prototype.remove = function () { this.removeAudioLevelIndicator(); this.removeConnectionIndicator(); + this.removeDisplayName(); // Make sure that the large video is updated if are removing its // corresponding small video. this.VideoLayout.updateAfterThumbRemoved(this.id); @@ -640,31 +641,11 @@ RemoteVideo.prototype.setDisplayName = function(displayName) { return; } - var nameSpan = $('#' + this.videoSpanId + ' .displayname'); - - // If we already have a display name for this video. - if (nameSpan.length > 0) { - if (displayName && displayName.length > 0) { - var displaynameSpan = $('#' + this.videoSpanId + '_name'); - if (displaynameSpan.text() !== displayName) - displaynameSpan.text(displayName); - } - else - $('#' + this.videoSpanId + '_name').text( - interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME); - } else { - nameSpan = document.createElement('span'); - nameSpan.className = 'displayname'; - $('#' + this.videoSpanId)[0] - .appendChild(nameSpan); - - if (displayName && displayName.length > 0) { - $(nameSpan).text(displayName); - } else { - nameSpan.innerHTML = interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME; - } - nameSpan.id = this.videoSpanId + '_name'; - } + this.updateDisplayName({ + displayName: displayName || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME, + elementID: `${this.videoSpanId}_name`, + participantID: this.id + }); }; /** @@ -707,6 +688,10 @@ RemoteVideo.createContainer = function (spanId) { overlay.className = "videocontainer__hoverOverlay"; container.appendChild(overlay); + const displayNameContainer = document.createElement('div'); + displayNameContainer.className = 'displayNameContainer'; + container.appendChild(displayNameContainer); + var remotes = document.getElementById('filmstripRemoteVideosContainer'); return remotes.appendChild(container); }; diff --git a/modules/UI/videolayout/SmallVideo.js b/modules/UI/videolayout/SmallVideo.js index d0b18d802..211b3d05d 100644 --- a/modules/UI/videolayout/SmallVideo.js +++ b/modules/UI/videolayout/SmallVideo.js @@ -3,13 +3,17 @@ /* eslint-disable no-unused-vars */ import React from 'react'; import ReactDOM from 'react-dom'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { i18next } from '../../../react/features/base/i18n'; import { AudioLevelIndicator } from '../../../react/features/audio-level-indicator'; import { ConnectionIndicator } from '../../../react/features/connection-indicator'; -/* eslint-enable no-unused-vars */ +import { DisplayName } from '../../../react/features/display-name'; +/* eslint-disable no-unused-vars */ const logger = require("jitsi-meet-logger").getLogger(__filename); @@ -483,7 +487,45 @@ SmallVideo.prototype.$avatar = function () { * the video thumbnail */ SmallVideo.prototype.$displayName = function () { - return $('#' + this.videoSpanId + ' .displayname'); + return $('#' + this.videoSpanId + ' .displayNameContainer'); +}; + +/** + * Creates or updates the participant's display name that is shown over the + * video preview. + * + * @returns {void} + */ +SmallVideo.prototype.updateDisplayName = function (props) { + const displayNameContainer + = this.container.querySelector('.displayNameContainer'); + + if (displayNameContainer) { + /* jshint ignore:start */ + ReactDOM.render( + + + + + , + displayNameContainer); + /* jshint ignore:end */ + } +}; + +/** + * Removes the component responsible for showing the participant's display name, + * if its container is present. + * + * @returns {void} + */ +SmallVideo.prototype.removeDisplayName = function () { + const displayNameContainer + = this.container.querySelector('.displayNameContainer'); + + if (displayNameContainer) { + ReactDOM.unmountComponentAtNode(displayNameContainer); + } }; /** diff --git a/react/features/base/participants/actionTypes.js b/react/features/base/participants/actionTypes.js index 6a6785e2c..83f4bbc66 100644 --- a/react/features/base/participants/actionTypes.js +++ b/react/features/base/participants/actionTypes.js @@ -10,6 +10,18 @@ */ export const DOMINANT_SPEAKER_CHANGED = Symbol('DOMINANT_SPEAKER_CHANGED'); +/** + * Create an action for when the local participant's display name is updated. + * + * { + * type: PARTICIPANT_DISPLAY_NAME_CHANGED, + * id: string, + * name: string + * } + */ +export const PARTICIPANT_DISPLAY_NAME_CHANGED + = Symbol('PARTICIPANT_DISPLAY_NAME_CHANGED'); + /** * Action to signal that ID of participant has changed. This happens when * local participant joins a new conference or quits one. diff --git a/react/features/base/participants/actions.js b/react/features/base/participants/actions.js index 79661af65..d8f9bd80f 100644 --- a/react/features/base/participants/actions.js +++ b/react/features/base/participants/actions.js @@ -1,11 +1,13 @@ import { DOMINANT_SPEAKER_CHANGED, + PARTICIPANT_DISPLAY_NAME_CHANGED, PARTICIPANT_ID_CHANGED, PARTICIPANT_JOINED, PARTICIPANT_LEFT, PARTICIPANT_UPDATED, PIN_PARTICIPANT } from './actionTypes'; +import { MAX_DISPLAY_NAME_LENGTH } from './constants'; import { getLocalParticipant } from './functions'; /** @@ -123,6 +125,29 @@ export function localParticipantLeft() { }; } +/** + * Action to signal that a participant's display name has changed. + * + * @param {string} id - The id of the participant being changed. + * @param {string} displayName - The new display name. + * @returns {{ + * type: PARTICIPANT_DISPLAY_NAME_CHANGED, + * id: string, + * name: string + * }} + */ +export function participantDisplayNameChanged(id, displayName = '') { + // FIXME Do not use this action over participantUpdated. This action exists + // as a a bridge for local name updates. Once other components responsible + // for updating the local user's display name are in react/redux, this + // action should be replaceable with the participantUpdated action. + return { + type: PARTICIPANT_DISPLAY_NAME_CHANGED, + id, + name: displayName.substr(0, MAX_DISPLAY_NAME_LENGTH) + }; +} + /** * Action to signal that a participant has joined. * diff --git a/react/features/base/participants/constants.js b/react/features/base/participants/constants.js index db2e02aa3..fa1a4d7ca 100644 --- a/react/features/base/participants/constants.js +++ b/react/features/base/participants/constants.js @@ -6,6 +6,13 @@ */ export const LOCAL_PARTICIPANT_DEFAULT_ID = 'local'; +/** + * Max length of the display names. + * + * @type {string} + */ +export const MAX_DISPLAY_NAME_LENGTH = 50; + /** * The set of possible XMPP MUC roles for conference participants. * diff --git a/react/features/base/participants/middleware.js b/react/features/base/participants/middleware.js index 93018af41..6598a53e5 100644 --- a/react/features/base/participants/middleware.js +++ b/react/features/base/participants/middleware.js @@ -1,3 +1,5 @@ +import UIEvents from '../../../../service/UI/UIEvents'; + import { CONFERENCE_JOINED, CONFERENCE_LEFT @@ -5,7 +7,11 @@ import { import { MiddlewareRegistry } from '../redux'; import { localParticipantIdChanged } from './actions'; +import { PARTICIPANT_DISPLAY_NAME_CHANGED } from './actionTypes'; import { LOCAL_PARTICIPANT_DEFAULT_ID } from './constants'; +import { getLocalParticipant } from './functions'; + +declare var APP: Object; /** * Middleware that captures CONFERENCE_JOINED and CONFERENCE_LEFT actions and @@ -23,6 +29,20 @@ MiddlewareRegistry.register(store => next => action => { case CONFERENCE_LEFT: store.dispatch(localParticipantIdChanged(LOCAL_PARTICIPANT_DEFAULT_ID)); break; + + // TODO Remove this middleware when the local display name update flow is + // fully brought into redux. + case PARTICIPANT_DISPLAY_NAME_CHANGED: { + if (typeof APP !== 'undefined') { + const participant = getLocalParticipant(store.getState()); + + if (participant && participant.id === action.id) { + APP.UI.emitEvent(UIEvents.NICKNAME_CHANGED, action.name); + } + } + + break; + } } return next(action); diff --git a/react/features/display-name/components/DisplayName.js b/react/features/display-name/components/DisplayName.js new file mode 100644 index 000000000..9b00c893d --- /dev/null +++ b/react/features/display-name/components/DisplayName.js @@ -0,0 +1,239 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '../../base/i18n'; +import { participantDisplayNameChanged } from '../../base/participants'; + +/** + * React {@code Component} for displaying and editing a participant's name. + * + * @extends Component + */ +class DisplayName extends Component { + /** + * {@code DisplayName}'s property types. + * + * @static + */ + static propTypes = { + /** + * Whether or not the display name should be editable on click. + */ + allowEditing: React.PropTypes.bool, + + /** + * Invoked to update the participant's display name. + */ + dispatch: React.PropTypes.func, + + /** + * The participant's current display name. + */ + displayName: React.PropTypes.string, + + /** + * A string to append to the displayName, if provided. + */ + displayNameSuffix: React.PropTypes.string, + + /** + * The ID attribute to add to the component. Useful for global querying + * for the component by legacy components and torture tests. + */ + elementID: React.PropTypes.string, + + /** + * The ID of the participant whose name is being displayed. + */ + participantID: React.PropTypes.string, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * Initializes a new {@code DisplayName} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + /** + * The current value of the display name in the edit field. + * + * @type {string} + */ + editDisplayNameValue: '', + + /** + * Whether or not the component should be displaying an editable + * input. + * + * @type {boolean} + */ + isEditing: false + }; + + /** + * The internal reference to the HTML element backing the React + * {@code Component} input with id {@code editDisplayName}. It is + * necessary for automatically selecting the display name input field + * when starting to edit the display name. + * + * @private + * @type {HTMLInputElement} + */ + this._nameInput = null; + + // Bind event handlers so they are only bound once for every instance. + this._onChange = this._onChange.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onStartEditing = this._onStartEditing.bind(this); + this._onSubmit = this._onSubmit.bind(this); + this._setNameInputRef = this._setNameInputRef.bind(this); + } + + /** + * Automatically selects the input field's value after starting to edit the + * display name. + * + * @inheritdoc + * @returns {void} + */ + componentDidUpdate(previousProps, previousState) { + if (!previousState.isEditing && this.state.isEditing) { + this._nameInput.select(); + } + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + allowEditing, + displayName, + displayNameSuffix, + elementID, + t + } = this.props; + + if (allowEditing && this.state.isEditing) { + return ( + + ); + } + + const suffix + = displayName && displayNameSuffix ? ` (${displayNameSuffix})` : ''; + + return ( + + { `${displayName || displayNameSuffix || ''}${suffix}` } + + ); + } + + /** + * Updates the internal state of the display name entered into the edit + * field. + * + * @param {Object} event - DOM Event for value change. + * @private + * @returns {void} + */ + _onChange(event) { + this.setState({ + editDisplayNameValue: event.target.value + }); + } + + /** + * Submits the editted display name update if the enter key is pressed. + * + * @param {Event} event - Key down event object. + * @private + * @returns {void} + */ + _onKeyDown(event) { + if (event.key === 'Enter') { + this._onSubmit(); + } + } + + /** + * Updates the component to display an editable input field and sets the + * initial value to the current display name. + * + * @private + * @returns {void} + */ + _onStartEditing() { + if (this.props.allowEditing) { + this.setState({ + isEditing: true, + editDisplayNameValue: this.props.displayName || '' + }); + } + } + + /** + * Dispatches an action to update the display name if any change has + * occurred after editing. Clears any temporary state used to keep track + * of pending display name changes and exits editing mode. + * + * @param {Event} event - Key down event object. + * @private + * @returns {void} + */ + _onSubmit() { + const { editDisplayNameValue } = this.state; + const { dispatch, participantID } = this.props; + + dispatch(participantDisplayNameChanged( + participantID, editDisplayNameValue)); + + this.setState({ + isEditing: false, + editDisplayNameValue: '' + }); + + this._nameInput = null; + } + + /** + * Sets the internal reference to the HTML element backing the React + * {@code Component} input with id {@code editDisplayName}. + * + * @param {HTMLInputElement} element - The DOM/HTML element for this + * {@code Component}'s input. + * @private + * @returns {void} + */ + _setNameInputRef(element) { + this._nameInput = element; + } +} + +export default translate(connect()(DisplayName)); diff --git a/react/features/display-name/components/index.js b/react/features/display-name/components/index.js new file mode 100644 index 000000000..be74b9fa7 --- /dev/null +++ b/react/features/display-name/components/index.js @@ -0,0 +1 @@ +export { default as DisplayName } from './DisplayName'; diff --git a/react/features/display-name/index.js b/react/features/display-name/index.js new file mode 100644 index 000000000..07635cbbc --- /dev/null +++ b/react/features/display-name/index.js @@ -0,0 +1 @@ +export * from './components'; diff --git a/react/features/filmstrip/components/Filmstrip.web.js b/react/features/filmstrip/components/Filmstrip.web.js index 50cf0cfe0..2c9aa6153 100644 --- a/react/features/filmstrip/components/Filmstrip.web.js +++ b/react/features/filmstrip/components/Filmstrip.web.js @@ -40,6 +40,7 @@ export default class Filmstrip extends Component { = 'connection-indicator-container' />
+