diff --git a/css/modals/invite/_invite.scss b/css/modals/invite/_invite.scss index 0b63df770a..855b7b9bb0 100644 --- a/css/modals/invite/_invite.scss +++ b/css/modals/invite/_invite.scss @@ -7,6 +7,26 @@ } .invite-dialog { + .dial-in-numbers { + .dial-in-numbers-trigger { + position: relative; + width: 100%; + + .dial-in-numbers-trigger-icon { + position: absolute; + right: 0; + top: 4px; + } + } + + .is-disabled, + .is-loading { + .dial-in-numbers-trigger-icon { + display: none; + } + } + } + .form-control { padding: 0; } @@ -20,6 +40,8 @@ } .password-overview { + margin-top: 10px; + .form-control { margin-top: 10px; } diff --git a/lang/main.json b/lang/main.json index 2ce2bb6407..b47a635e61 100644 --- a/lang/main.json +++ b/lang/main.json @@ -428,9 +428,14 @@ }, "invite": { "addPassword": "Add password", + "dialInNumbers": "Dial-in telephone numbers", + "errorFetchingNumbers": "Failed to obtain dial-in numbers", "hidePassword": "Hide password", "inviteTo": "Invite people to __conferenceName__", + "loadingNumbers": "Loading...", "locked": "This call is locked. New callers must have the link and enter the password to join.", + "noNumbers": "No numbers available", + "numbersDisabled": "Dialing in has been disabled", "showPassword": "Show password", "unlocked": "This call is unlocked. Any new caller with the link may join the call." } diff --git a/react/features/invite/actionTypes.js b/react/features/invite/actionTypes.js new file mode 100644 index 0000000000..84ead153ad --- /dev/null +++ b/react/features/invite/actionTypes.js @@ -0,0 +1,38 @@ +import { Symbol } from '../base/react'; + +/** + * The type of the action which signals an error occurred while requesting dial- + * in numbers. + * + * { + * type: UPDATE_DIAL_IN_NUMBERS_FAILED, + * error: Object + * } + */ +export const UPDATE_DIAL_IN_NUMBERS_FAILED + = Symbol('UPDATE_DIAL_IN_NUMBERS_FAILED'); + +/** + * The type of the action which signals a request for dial-in numbers has been + * started. + * + * { + * type: UPDATE_DIAL_IN_NUMBERS_REQUEST + * } + */ +export const UPDATE_DIAL_IN_NUMBERS_REQUEST + = Symbol('UPDATE_DIAL_IN_NUMBERS_REQUEST'); + +/** + * The type of the action which signals a request for dial-in numbers has + * succeeded. + * + * { + * type: UPDATE_DIAL_IN_NUMBERS_SUCCESS, + * response: Object + * } + */ +export const UPDATE_DIAL_IN_NUMBERS_SUCCESS + = Symbol('UPDATE_DIAL_IN_NUMBERS_SUCCESS'); + + diff --git a/react/features/invite/actions.js b/react/features/invite/actions.js index 2f34428cd0..64fec5dce8 100644 --- a/react/features/invite/actions.js +++ b/react/features/invite/actions.js @@ -1,9 +1,16 @@ -/* globals APP */ - import { openDialog } from '../../features/base/dialog'; +import { + UPDATE_DIAL_IN_NUMBERS_FAILED, + UPDATE_DIAL_IN_NUMBERS_REQUEST, + UPDATE_DIAL_IN_NUMBERS_SUCCESS +} from './actionTypes'; import { InviteDialog } from './components'; +declare var $: Function; +declare var APP: Object; +declare var config: Object; + /** * Opens the Invite Dialog. * @@ -11,6 +18,34 @@ import { InviteDialog } from './components'; */ export function openInviteDialog() { return openDialog(InviteDialog, { - conferenceUrl: encodeURI(APP.ConferenceUrl.getInviteUrl()) + conferenceUrl: encodeURI(APP.ConferenceUrl.getInviteUrl()), + dialInNumbersUrl: config.dialInNumbersUrl }); } + +/** + * Sends an ajax request for dial-in numbers. + * + * @param {string} dialInNumbersUrl - The endpoint for retrieving json that + * includes numbers for dialing in to a conference. + * @returns {Function} + */ +export function updateDialInNumbers(dialInNumbersUrl) { + return dispatch => { + dispatch({ + type: UPDATE_DIAL_IN_NUMBERS_REQUEST + }); + + $.getJSON(dialInNumbersUrl) + .success(response => + dispatch({ + type: UPDATE_DIAL_IN_NUMBERS_SUCCESS, + response + })) + .error(error => + dispatch({ + type: UPDATE_DIAL_IN_NUMBERS_FAILED, + error + })); + }; +} diff --git a/react/features/invite/components/DialInNumbersForm.js b/react/features/invite/components/DialInNumbersForm.js new file mode 100644 index 0000000000..0b002faec7 --- /dev/null +++ b/react/features/invite/components/DialInNumbersForm.js @@ -0,0 +1,404 @@ +import { StatelessDropdownMenu } from '@atlaskit/dropdown-menu'; +import ExpandIcon from '@atlaskit/icon/glyph/expand'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '../../base/i18n'; + +import { updateDialInNumbers } from '../actions'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +const EXPAND_ICON = ; + +/** + * React {@code Component} responsible for fetching and displaying telephone + * numbers for dialing into the conference. Also supports copying a selected + * dial-in number to the clipboard. + * + * @extends Component + */ +class DialInNumbersForm extends Component { + /** + * {@code DialInNumbersForm}'s property types. + * + * @static + */ + static propTypes = { + /** + * The redux state representing the dial-in numbers feature. + */ + _dialIn: React.PropTypes.object, + + /** + * The url for retrieving dial-in numbers. + */ + dialInNumbersUrl: React.PropTypes.string, + + /** + * Invoked to send an ajax request for dial-in numbers. + */ + dispatch: React.PropTypes.func, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + } + + /** + * Initializes a new {@code DialInNumbersForm} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + /** + * Whether or not the dropdown should be open. + * + * @type {boolean} + */ + isDropdownOpen: false, + + /** + * The dial-in number to display as currently selected in the + * dropdown. The value should be an object which has two key/value + * pairs, content and number. The value of "content" will display in + * the dropdown while the value of "number" is a substring of + * "content" which will be copied to clipboard. + * + * @type {object} + */ + selectedNumber: null + }; + + /** + * The internal reference to the DOM/HTML element backing the React + * {@code Component} input. It is necessary for the implementation of + * copying to the clipboard. + * + * @private + * @type {HTMLInputElement} + */ + this._inputElement = null; + + // Bind event handlers so they are only bound once for every instance. + this._onClick = this._onClick.bind(this); + this._onOpenChange = this._onOpenChange.bind(this); + this._onSelect = this._onSelect.bind(this); + this._setInput = this._setInput.bind(this); + } + + /** + * Dispatches a request for numbers if not already present in the redux + * store. If numbers are present, sets a default number to display in the + * dropdown trigger. + * + * @inheritdoc + * returns {void} + */ + componentDidMount() { + if (this.props._dialIn.numbers) { + this._setDefaultNumber(this.props._dialIn.numbers); + } else { + this.props.dispatch( + updateDialInNumbers(this.props.dialInNumbersUrl)); + } + } + + /** + * Monitors for number updates and sets a default number to display in the + * dropdown trigger if not already set. + * + * @inheritdoc + * returns {void} + */ + componentWillReceiveProps(nextProps) { + if (!this.state.selectedNumber && nextProps._dialIn.numbers) { + this._setDefaultNumber(nextProps._dialIn.numbers); + } + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { t, _dialIn } = this.props; + + const numbers = _dialIn.numbers; + const items = numbers ? this._formatNumbers(numbers) : []; + + const isEnabled = this._isDropdownEnabled(); + const inputWrapperClassNames + = `form-control__container ${isEnabled ? '' : 'is-disabled'} + ${_dialIn.loading ? 'is-loading' : ''}`; + + let triggerText = ''; + + if (!_dialIn.numbersEnabled) { + triggerText = t('invite.numbersDisabled'); + } else if (this.state.selectedNumber + && this.state.selectedNumber.content) { + triggerText = this.state.selectedNumber.content; + } else if (!numbers && _dialIn.loading) { + triggerText = t('invite.loadingNumbers'); + } else if (_dialIn.error) { + triggerText = t('invite.errorFetchingNumbers'); + } else { + triggerText = t('invite.noNumbers'); + } + + return ( +
+ +
+ { this._createDropdownMenu(items, triggerText) } + +
+
+ ); + } + + /** + * Creates a {@code StatelessDropdownMenu} instance. + * + * @param {Array} items - The content to display within the dropdown. + * @param {string} triggerText - The text to display within the + * trigger element. + * @returns {ReactElement} + */ + _createDropdownMenu(items, triggerText) { + return ( + + { this._createDropdownTrigger(triggerText) } + + ); + } + + /** + * Creates a React {@code Component} with a redonly HTMLInputElement as a + * trigger for displaying the dropdown menu. The {@code Component} will also + * display the currently selected number. + * + * @param {string} triggerText - Text to display in the HTMLInputElement. + * @private + * @returns {ReactElement} + */ + _createDropdownTrigger(triggerText) { + return ( +
+ + + { EXPAND_ICON } + +
+ ); + } + + /** + * Detects whether the response from dialInNumbersUrl returned an array or + * an object with dial-in numbers and calls the appropriate method to + * transform the numbers into the format expected by + * {@code StatelessDropdownMenu}. + * + * @param {Array|Object} dialInNumbers - The numbers returned from + * requesting dialInNumbersUrl. + * @private + * @returns {Array} + */ + _formatNumbers(dialInNumbers) { + if (Array.isArray(dialInNumbers)) { + return this._formatNumbersArray(dialInNumbers); + } + + return this._formatNumbersObject(dialInNumbers); + } + + /** + * Transforms the passed in numbers array into an array of objects that can + * be parsed by {@code StatelessDropdownMenu}. + * + * @param {Array} dialInNumbers - An array with dial-in numbers to + * display and copy. + * @private + * @returns {Array} + */ + _formatNumbersArray(dialInNumbers) { + return dialInNumbers.map(number => { + return { + content: number, + number + }; + }); + } + + /** + * Transforms the passed in numbers object into an array of objects that can + * be parsed by {@code StatelessDropdownMenu}. + * + * @param {Object} dialInNumbers - The numbers object to parse. The + * expected format is an object with keys being the name of the country + * and the values being an array of numbers as strings. + * @private + * @returns {Array} + */ + _formatNumbersObject(dialInNumbers) { + const phoneRegions = Object.keys(dialInNumbers); + + if (!phoneRegions.length) { + return []; + } + + const formattedNumbeers = phoneRegions.map(region => { + const numbers = dialInNumbers[region]; + + return numbers.map(number => { + return { + content: `${region}: ${number}`, + number + }; + }); + }); + + return Array.prototype.concat(...formattedNumbeers); + } + + /** + * Determines if the dropdown can be opened. + * + * @private + * @returns {boolean} True if the dropdown can be opened. + */ + _isDropdownEnabled() { + const { selectedNumber } = this.state; + + return Boolean( + this.props._dialIn.numbersEnabled + && selectedNumber + && selectedNumber.content + ); + } + + /** + * Copies part of the number displayed in the dropdown trigger into the + * clipboard. Only the value specified in selectedNumber.number, which + * should be a substring of the displayed value, will be copied. + * + * @private + * @returns {void} + */ + _onClick() { + const displayedValue = this.state.selectedNumber.content; + const desiredNumber = this.state.selectedNumber.number; + const startIndex = displayedValue.indexOf(desiredNumber); + + try { + this._input.focus(); + this._input.setSelectionRange(startIndex, displayedValue.length); + document.execCommand('copy'); + this._input.blur(); + } catch (err) { + logger.error('error when copying the text', err); + } + } + + /** + * Sets the internal state to either open or close the dropdown. If the + * dropdown is disabled, the state will always be set to false. + * + * @param {Object} dropdownEvent - The even returned from clicking on the + * dropdown trigger. + * @private + * @returns {void} + */ + _onOpenChange(dropdownEvent) { + this.setState({ + isDropdownOpen: this._isDropdownEnabled() && dropdownEvent.isOpen + }); + } + + /** + * Updates the internal state of the currently selected number. + * + * @param {Object} selection - Event from choosing an dropdown option. + * @private + * @returns {void} + */ + _onSelect(selection) { + this.setState({ + isDropdownOpen: false, + selectedNumber: selection.item + }); + } + + /** + * Updates the internal state of the currently selected number by defaulting + * to the first available number. + * + * @param {Object} dialInNumbers - The array or object of numbers to parse. + * @private + * @returns {void} + */ + _setDefaultNumber(dialInNumbers) { + const numbers = this._formatNumbers(dialInNumbers); + + this.setState({ + selectedNumber: numbers[0] + }); + } + + /** + * Sets the internal reference to the DOM/HTML element backing the React + * {@code Component} input. + * + * @param {HTMLInputElement} element - The DOM/HTML element for this + * {@code Component}'s input. + * @private + * @returns {void} + */ + _setInput(element) { + this._input = element; + } +} + +/** + * Maps (parts of) the Redux state to the associated + * {@code DialInNumbersForm}'s props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _dialIn: React.PropTypes.object + * }} + */ +function _mapStateToProps(state) { + return { + _dialIn: state['features/invite/dial-in'] + }; +} + +export default translate(connect(_mapStateToProps)(DialInNumbersForm)); diff --git a/react/features/invite/components/InviteDialog.js b/react/features/invite/components/InviteDialog.js index 97b71d6783..adf1f932e4 100644 --- a/react/features/invite/components/InviteDialog.js +++ b/react/features/invite/components/InviteDialog.js @@ -11,6 +11,7 @@ import { import PasswordContainer from './PasswordContainer'; import ShareLinkForm from './ShareLinkForm'; +import DialInNumbersForm from './DialInNumbersForm'; /** * A React {@code Component} for displaying other components responsible for @@ -40,6 +41,11 @@ class InviteDialog extends Component { */ conferenceUrl: React.PropTypes.string, + /** + * The url for retrieving dial-in numbers. + */ + dialInNumbersUrl: React.PropTypes.string, + /** * Invoked to obtain translated strings. */ @@ -75,6 +81,7 @@ class InviteDialog extends Component { titleString = { titleString }>
+ { this._renderDialInNumbersForm() } ); } + + /** + * Creates a React {@code Component} for displaying and copying to clipboard + * telephone numbers for dialing in to the conference. + * + * @private + * @returns {ReactElement|null} + */ + _renderDialInNumbersForm() { + return ( + this.props.dialInNumbersUrl + ? + : null + ); + } } /** diff --git a/react/features/invite/index.js b/react/features/invite/index.js index 3c46ed49d1..582e1f9dd4 100644 --- a/react/features/invite/index.js +++ b/react/features/invite/index.js @@ -1,2 +1,4 @@ export * from './actions'; export * from './components'; + +import './reducer'; diff --git a/react/features/invite/reducer.js b/react/features/invite/reducer.js new file mode 100644 index 0000000000..6d6cd88bd5 --- /dev/null +++ b/react/features/invite/reducer.js @@ -0,0 +1,47 @@ +import { + ReducerRegistry +} from '../base/redux'; + +import { + UPDATE_DIAL_IN_NUMBERS_FAILED, + UPDATE_DIAL_IN_NUMBERS_REQUEST, + UPDATE_DIAL_IN_NUMBERS_SUCCESS +} from './actionTypes'; + +const DEFAULT_STATE = { + numbersEnabled: true +}; + +ReducerRegistry.register( + 'features/invite/dial-in', + (state = DEFAULT_STATE, action) => { + switch (action.type) { + case UPDATE_DIAL_IN_NUMBERS_FAILED: { + return { + ...state, + error: action.error, + loading: false + }; + } + + case UPDATE_DIAL_IN_NUMBERS_REQUEST: { + return { + ...state, + error: null, + loading: true + }; + } + case UPDATE_DIAL_IN_NUMBERS_SUCCESS: { + const { numbers, numbersEnabled } = action.response; + + return { + error: null, + loading: false, + numbers, + numbersEnabled + }; + } + } + + return state; + });