diff --git a/css/main.scss b/css/main.scss index 724670224c..0326aff014 100644 --- a/css/main.scss +++ b/css/main.scss @@ -94,5 +94,7 @@ $flagsImagePath: "../images/"; @import 'prejoin'; @import 'prejoin-dialog'; @import 'country-picker'; +@import 'modals/invite/invite_more'; +@import 'modals/security/security'; /* Modules END */ diff --git a/css/modals/invite/_add-people.scss b/css/modals/invite/_add-people.scss index 9b8762f79b..ea09c367f6 100644 --- a/css/modals/invite/_add-people.scss +++ b/css/modals/invite/_add-people.scss @@ -3,6 +3,7 @@ */ .modal-dialog-form { .add-people-form-wrap { + margin-top: 8px; .error { padding-left: 5px; diff --git a/css/modals/invite/_info.scss b/css/modals/invite/_info.scss index 22187e5c03..aaf6981226 100644 --- a/css/modals/invite/_info.scss +++ b/css/modals/invite/_info.scss @@ -3,47 +3,6 @@ display: flex; font-size: 14px; - .info-dialog-action-link { - display: inline-block; - line-height: 1.5em; - - a { - cursor: pointer; - vertical-align: middle; - } - } - - .info-dialog-action-link:before { - color: $linkFontColor; - content: '\2022'; - font-size: 1.5em; - padding: 0 10px; - vertical-align: middle; - } - - .info-dialog-action-link:first-child:before { - content: ''; - padding: 0; - } - - .info-dialog-action-links { - font-weight: bold; - margin-top: 10px; - white-space: nowrap; - } - - .info-dialog-action-separator { - display: inline-block; - } - - .info-dialog-copy-element { - opacity: 0; - pointer-events: none; - position: absolute; - -webkit-user-select: text; - user-select: text; - } - .info-dialog-column { margin-right: 10px; overflow: hidden; @@ -56,52 +15,6 @@ } } - .info-dialog-conference-url, - .info-dialog-live-stream-url { - width: max-content; - width: -moz-max-content; - width: -webkit-max-content; - word-break: break-all; - max-width: 400px; - display: flex; - align-items: center; - } - - .info-dialog-dial-in { - word-break: break-all; - - .conference-id, - .phone-number { - user-select: text; - } - } - - .info-dialog-icon { - color: #6453C0; - font-size: 16px; - min-width: 30px; - } - - .info-dialog-url-text, - .info-dialog-url-text:hover { - color: inherit; - cursor: inherit; - } - - .info-dialog-url-icon { - display: inline-block; - margin-left: 5px; - - svg { - cursor: pointer; - } - } - - .info-dialog-title { - font-weight: bold; - margin-bottom: 10px; - } - .info-dialog-password, .info-password, .info-password-form { @@ -223,10 +136,4 @@ -moz-user-select: text; -webkit-user-select: text; } - - .info-dialog-url-text-unselectable { - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; - } } diff --git a/css/modals/invite/_invite_more.scss b/css/modals/invite/_invite_more.scss new file mode 100644 index 0000000000..7f4b24efac --- /dev/null +++ b/css/modals/invite/_invite_more.scss @@ -0,0 +1,252 @@ +.invite-more { + &-container { + color: #fff; + font-weight: 600; + position: absolute; + width: 100%; + text-align: center; + z-index: $zindex2; + background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + + &.elevated { + z-index: $filmstripVideosZ + 1; + } + } + + &-header { + font-size: 19px; + line-height: 28px; + margin: 24px 0 16px 0; + } + + &-button { + display: flex; + justify-content: space-between; + align-items: center; + margin: auto; + padding: 8px 16px; + width: 152px; + height: 24px; + background: #0376DA; + border-radius: 3px; + font-size: 14px; + line-height: 24px; + cursor: pointer; + + &:hover { + background: #278ADF; + } + + &-text { + font-size: 15px; + line-height: 24px; + } + } + &-dialog { + color: #fff; + font-size: 15px; + line-height: 24px; + + & > span { + font-weight: 600; + } + + &.header { + display: flex; + justify-content: space-between; + margin: 16px 16px 24px; + width: calc(100% - 32px); + color: #fff; + font-weight: 600; + font-size: 24px; + line-height: 32px; + + & > div > svg { + cursor: pointer; + fill: #A4B8D1; + } + } + + &.copy-link { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 8px 8px 16px; + margin-top: 8px; + width: calc(100% - 24px); + height: 24px; + + background: #0376DA; + border-radius: 4px; + cursor: pointer; + + &:hover { + background: #278ADF; + font-weight: 600; + } + + &-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 292px; + + &.selected { + font-weight: 600; + } + } + + &.clicked { + background: #31B76A; + } + + & > div > svg > path { + fill: #fff; + } + } + + &.separator { + margin: 24px 0 24px -20px; + padding: 0 20px; + width: 100%; + height: 1px; + background: #5E6D7A; + } + + &.email-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 8px 8px 16px; + margin-top: 24px; + width: calc(100% - 26px); + height: 22px; + + background: #2A3A4B; + border: 1px solid #5E6D7A; + border-radius: 3px; + cursor: pointer; + + &.active { + border-radius: 3px 3px 0 0; + } + } + + &.icon-container { + display: none; + + &.active { + display: flex; + width: calc(100% - 26px); + padding: 8px 8px 8px 16px; + + background: #2A3A4B; + border: 1px solid #5E6D7A; + border-top: none; + border-radius: 0 0 3px 3px; + + & > * { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + width: 40px; + border-radius: 4px; + cursor: pointer; + } + + &:hover > div:hover { + background-color: rgba(255, 255, 255, 0.2); + } + + & > :not(:last-child) { + margin-right: 16px; + } + + .copy-invite-icon > div > svg > path { + fill: #A4B8D1; + } + } + } + + &.dial-in-display { + .info-label { + color: #A4B8D1; + } + + .dial-in-copy { + display: inline-block; + vertical-align: middle; + margin-left: 21px; + cursor: pointer; + } + } + + &.invite-buttons { + width: 100%; + text-align: right; + margin-top: 8px; + + & > a { + display: inline-block; + height: 24px; + width: 48px; + border-radius: 3px; + text-align: center; + text-decoration: none; + cursor: pointer; + } + + &-cancel { + margin-right: 16px; + padding: 7px 15px; + background: #2A3A4B; + border: 1px solid #5E6D7A; + } + + &-add { + padding: 8px 16px; + background: #0376DA; + } + } + + &.stream { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 8px 8px 16px; + margin-top: 8px; + width: calc(100% - 26px); + height: 22px; + + background: #2A3A4B; + border: 1px solid #5E6D7A; + border-radius: 3px; + cursor: pointer; + + &:hover { + font-weight: 600; + } + + &-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 292px; + + &.selected { + font-weight: 600; + } + } + + &.clicked { + background: #31B76A; + border: 1px solid #31B76A; + } + + & > div > svg > path { + fill: #fff; + } + } + } +} diff --git a/css/modals/security/_security.scss b/css/modals/security/_security.scss new file mode 100644 index 0000000000..69bb64d50a --- /dev/null +++ b/css/modals/security/_security.scss @@ -0,0 +1,37 @@ +.security { + &-dialog { + color: #fff; + font-size: 15px; + line-height: 24px; + + &.password { + display: flex; + justify-content: space-between; + align-items: center; + + &-actions { + a { + cursor: pointer; + text-decoration: none; + font-size: 14px; + color: #6FB1EA; + } + + & > a + a { + margin-left: 24px; + } + } + } + } +} + +.new-toolbox .toolbox-content .toolbox-icon.security-toolbar-button, +.new-toolbox .toolbox-content .toolbox-icon.toggled.security-toolbar-button { + background: rgba(241, 173, 51, 0.7); + border: 1px solid rgba(255, 255, 255, 0.4); + + &:hover { + background: rgba(241, 173, 51, 0.7); + border: 1px solid rgba(255, 255, 255, 0.4); + } +} diff --git a/interface_config.js b/interface_config.js index 574ad95ff6..6081cc29da 100644 --- a/interface_config.js +++ b/interface_config.js @@ -48,11 +48,11 @@ var interfaceConfig = { */ TOOLBAR_BUTTONS: [ 'microphone', 'camera', 'closedcaptions', 'desktop', 'fullscreen', - 'fodeviceselection', 'hangup', 'profile', 'info', 'chat', 'recording', + 'fodeviceselection', 'hangup', 'profile', 'chat', 'recording', 'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand', 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts', 'tileview', 'videobackgroundblur', 'download', 'help', 'mute-everyone', - 'e2ee' + 'e2ee', 'security' ], SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile', 'calendar' ], diff --git a/lang/main.json b/lang/main.json index a71365ab06..8e1f6f81d2 100644 --- a/lang/main.json +++ b/lang/main.json @@ -1,21 +1,36 @@ { "addPeople": { "add": "Invite", + "addContacts": "Invite your contacts", + "copyInvite": "Copy meeting invitation", + "copyLink": "Copy meeting link", + "copyStream": "Copy live streaming link", "countryNotSupported": "We do not support this destination yet.", "countryReminder": "Calling outside the US? Please make sure you start with the country code!", + "defaultEmail": "Your Default Email", "disabled": "You can't invite people.", "failedToAdd": "Failed to add participants", "footerText": "Dialing out is disabled.", + "googleEmail": "Google Email", + "inviteMoreHeader": "You are the only one in the meeting", + "inviteMoreMailSubject": "Join {{appName}} meeting", + "inviteMorePrompt": "Invite more people", + "linkCopied": "Link copied to clipboard", "loading": "Searching for people and phone numbers", "loadingNumber": "Validating phone number", "loadingPeople": "Searching for people to invite", "noResults": "No matching search results", "noValidNumbers": "Please enter a phone number", + "outlookEmail": "Outlook Email", "searchNumbers": "Add phone numbers", "searchPeople": "Search for people", "searchPeopleAndNumbers": "Search for people or add their phone numbers", + "shareInvite": "Share meeting invitation", + "shareLink": "Share the meeting link to invite others", + "shareStream": "Share the live streaming link", "telephone": "Telephone: {{number}}", - "title": "Invite people to this meeting" + "title": "Invite people to this meeting", + "yahooEmail": "Yahoo Email" }, "audioDevices": { "bluetooth": "Bluetooth", @@ -146,6 +161,7 @@ "accessibilityLabel": { "liveStreaming": "Live Stream" }, + "add": "Add", "allow": "Allow", "alreadySharedVideoMsg": "Another participant is already sharing a video. This conference allows only one shared video at a time.", "alreadySharedVideoTitle": "Only one shared video is allowed at a time", @@ -562,7 +578,9 @@ "pullToRefresh": "Pull to refresh" }, "security": { - "insecureRoomNameWarning": "The room name is insecure. Unwanted participants may join your conference." + "about": "You can add a passcode to your meeting. Participants will need to provide the passcode before they are allowed to join the meeting.", + "insecureRoomNameWarning": "The room name is insecure. Unwanted participants may join your conference.", + "securityOptions": "Security options" }, "settings": { "calendar": { @@ -662,6 +680,7 @@ "raiseHand": "Toggle raise hand", "recording": "Toggle recording", "remoteMute": "Mute participant", + "security": "Security options", "Settings": "Toggle settings", "sharedvideo": "Toggle Youtube video sharing", "shareRoom": "Invite someone", @@ -715,6 +734,7 @@ "profile": "Edit your profile", "raiseHand": "Raise / Lower your hand", "raiseYourHand": "Raise your hand", + "security": "Security options", "Settings": "Settings", "sharedvideo": "Share a YouTube video", "shareRoom": "Invite someone", diff --git a/react/features/base/dialog/components/web/StatelessDialog.js b/react/features/base/dialog/components/web/StatelessDialog.js index 5507520842..be93e02b95 100644 --- a/react/features/base/dialog/components/web/StatelessDialog.js +++ b/react/features/base/dialog/components/web/StatelessDialog.js @@ -29,7 +29,10 @@ const OK_BUTTON_ID = 'modal-dialog-ok-button'; type Props = { ...DialogProps, - i18n: Object, + /** + * Custom dialog header that replaces the standard heading. + */ + customHeader?: React$Element | Function, /** * Disables dismissing the dialog when the blanket is clicked. Enabled @@ -43,6 +46,8 @@ type Props = { */ hideCancelButton: boolean, + i18n: Object, + /** * Whether the dialog is modal. This means clicking on the blanket will * leave the dialog open. No cancel button. @@ -106,6 +111,7 @@ class StatelessDialog extends Component { */ render() { const { + customHeader, children, t /* The following fixes a flow error: */ = _.identity, titleString, @@ -116,8 +122,11 @@ class StatelessDialog extends Component { return ( + + diff --git a/react/features/base/icons/svg/envelope.svg b/react/features/base/icons/svg/envelope.svg new file mode 100755 index 0000000000..9ef6311dcb --- /dev/null +++ b/react/features/base/icons/svg/envelope.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/google.svg b/react/features/base/icons/svg/google.svg new file mode 100755 index 0000000000..879db73263 --- /dev/null +++ b/react/features/base/icons/svg/google.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index eaa2ed93d3..0ed80e205e 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -4,6 +4,7 @@ export { default as IconAdd } from './add.svg'; export { default as IconAddPeople } from './link.svg'; export { default as IconArrowBack } from './arrow_back.svg'; export { default as IconArrowDown } from './arrow_down.svg'; +export { default as IconArrowDownSmall } from './arrow-down-small.svg'; export { default as IconArrowLeft } from './arrow-left.svg'; export { default as IconAudioOnly } from './visibility.svg'; export { default as IconAudioOnlyOff } from './visibility-off.svg'; @@ -30,18 +31,21 @@ export { default as IconDominantSpeaker } from './dominant-speaker.svg'; export { default as IconDownload } from './download.svg'; export { default as IconDragHandle } from './drag-handle.svg'; export { default as IconE2EE } from './e2ee.svg'; +export { default as IconEmail } from './envelope.svg'; export { default as IconEventNote } from './event_note.svg'; export { default as IconExclamation } from './exclamation.svg'; export { default as IconExclamationSolid } from './exclamation-solid.svg'; export { default as IconExitFullScreen } from './exit-full-screen.svg'; export { default as IconFeedback } from './feedback.svg'; export { default as IconFullScreen } from './full-screen.svg'; +export { default as IconGoogle } from './google.svg'; export { default as IconHangup } from './hangup.svg'; export { default as IconHelp } from './help.svg'; export { default as IconInfo } from './info.svg'; -export { default as IconInvite } from './invite.svg'; +export { default as IconInviteMore } from './user-plus.svg'; export { default as IconKick } from './kick.svg'; export { default as IconLiveStreaming } from './public.svg'; +export { default as IconLockPassword } from './lock.svg'; export { default as IconMenu } from './menu.svg'; export { default as IconMenuDown } from './menu-down.svg'; export { default as IconMenuThumb } from './thumb-menu.svg'; @@ -56,6 +60,7 @@ export { default as IconMuteEveryone } from './mute-everyone.svg'; export { default as IconMuteEveryoneElse } from './mute-everyone-else.svg'; export { default as IconNotificationJoin } from './navigate_next.svg'; export { default as IconOpenInNew } from './open_in_new.svg'; +export { default as IconOutlook } from './office365.svg'; export { default as IconPhone } from './phone.svg'; export { default as IconPin } from './enlarge.svg'; export { default as IconPresentation } from './presentation.svg'; @@ -79,6 +84,7 @@ export { default as IconShareVideo } from './shared-video.svg'; export { default as IconSwitchCamera } from './switch-camera.svg'; export { default as IconTileView } from './tiles-many.svg'; export { default as IconToggleRecording } from './camera-take-picture.svg'; +export { default as IconUnlockPassword } from './unlock.svg'; export { default as IconVideoQualityAudioOnly } from './AUD.svg'; export { default as IconVideoQualityHD } from './HD.svg'; export { default as IconVideoQualityLD } from './LD.svg'; @@ -87,3 +93,4 @@ export { default as IconVolume } from './volume.svg'; export { default as IconVolumeEmpty } from './volume-empty.svg'; export { default as IconVolumeOff } from './volume-off.svg'; export { default as IconWarning } from './warning.svg'; +export { default as IconYahoo } from './yahoo.svg'; diff --git a/react/features/base/icons/svg/invite.svg b/react/features/base/icons/svg/invite.svg deleted file mode 100755 index 2dd258cafe..0000000000 --- a/react/features/base/icons/svg/invite.svg +++ /dev/null @@ -1,5 +0,0 @@ - - -invite - - diff --git a/react/features/base/icons/svg/lock.svg b/react/features/base/icons/svg/lock.svg new file mode 100755 index 0000000000..2dc225a6fc --- /dev/null +++ b/react/features/base/icons/svg/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/office365.svg b/react/features/base/icons/svg/office365.svg new file mode 100755 index 0000000000..a067030ec5 --- /dev/null +++ b/react/features/base/icons/svg/office365.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/unlock.svg b/react/features/base/icons/svg/unlock.svg new file mode 100755 index 0000000000..bb2706961e --- /dev/null +++ b/react/features/base/icons/svg/unlock.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/user-plus.svg b/react/features/base/icons/svg/user-plus.svg new file mode 100755 index 0000000000..dc8e18e817 --- /dev/null +++ b/react/features/base/icons/svg/user-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/yahoo.svg b/react/features/base/icons/svg/yahoo.svg new file mode 100755 index 0000000000..96b5076fab --- /dev/null +++ b/react/features/base/icons/svg/yahoo.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/toolbox/components/AbstractToolboxItem.js b/react/features/base/toolbox/components/AbstractToolboxItem.js index e2bfad02f7..04d52b90f9 100644 --- a/react/features/base/toolbox/components/AbstractToolboxItem.js +++ b/react/features/base/toolbox/components/AbstractToolboxItem.js @@ -35,6 +35,12 @@ export type Props = { */ accessibilityLabel: string, + /** + * An extra class name to be added at the end of the element's class name + * in order to enable custom styling. + */ + customClass?: string, + /** * Whether this item is disabled or not. When disabled, clicking an the item * has no effect, and it may reflect on its style. diff --git a/react/features/base/toolbox/components/ToolboxItem.web.js b/react/features/base/toolbox/components/ToolboxItem.web.js index 7fdd225c0f..27bfe3e3ac 100644 --- a/react/features/base/toolbox/components/ToolboxItem.web.js +++ b/react/features/base/toolbox/components/ToolboxItem.web.js @@ -67,11 +67,11 @@ export default class ToolboxItem extends AbstractToolboxItem { * @returns {ReactElement} */ _renderIcon() { - const { disabled, icon, showLabel, toggled } = this.props; + const { customClass, disabled, icon, showLabel, toggled } = this.props; const iconComponent = ; const elementType = showLabel ? 'span' : 'div'; const className = `${showLabel ? 'overflow-menu-item-icon' : 'toolbox-icon'} ${ - toggled ? 'toggled' : ''} ${disabled ? 'disabled' : ''}`; + toggled ? 'toggled' : ''} ${disabled ? 'disabled' : ''} ${customClass ?? ''}`; return React.createElement(elementType, { className }, iconComponent); } diff --git a/react/features/conference/components/web/Conference.js b/react/features/conference/components/web/Conference.js index 9a98079ec8..500330f95d 100644 --- a/react/features/conference/components/web/Conference.js +++ b/react/features/conference/components/web/Conference.js @@ -25,15 +25,17 @@ import { import { maybeShowSuboptimalExperienceNotification } from '../../functions'; -import Labels from './Labels'; -import { default as Notice } from './Notice'; -import { default as Subject } from './Subject'; import { AbstractConference, abstractMapStateToProps } from '../AbstractConference'; import type { AbstractProps } from '../AbstractConference'; +import InviteMore from './InviteMore'; +import Labels from './Labels'; +import { default as Notice } from './Notice'; +import { default as Subject } from './Subject'; + declare var APP: Object; declare var config: Object; declare var interfaceConfig: Object; @@ -202,6 +204,7 @@ class Conference extends AbstractConference { +
{ hideVideoQualityLabel diff --git a/react/features/conference/components/web/InviteMore.js b/react/features/conference/components/web/InviteMore.js new file mode 100644 index 0000000000..5b1840c523 --- /dev/null +++ b/react/features/conference/components/web/InviteMore.js @@ -0,0 +1,94 @@ +// @flow + +import React from 'react'; + +import { translate } from '../../../base/i18n'; +import { Icon, IconInviteMore } from '../../../base/icons'; +import { getParticipantCount } from '../../../base/participants'; +import { connect } from '../../../base/redux'; +import { beginAddPeople } from '../../../invite'; +import { isToolboxVisible } from '../../../toolbox'; + +type Props = { + + /** + * Whether tile view is enabled. + */ + _tileViewEnabled: Boolean, + + /** + * Whether to show the option to invite more people + * instead of the subject. + */ + _visible: boolean, + + /** + * Handler to open the invite dialog. + */ + onClick: Function, + + /** + * Invoked to obtain translated strings. + */ + t: Function +} + +/** + * Represents a replacement for the subject, prompting the + * sole participant to invite more participants. + * + * @param {Object} props - The props of the component. + * @returns {React$Element} + */ +function InviteMore({ + _tileViewEnabled, + _visible, + onClick, + t +}: Props) { + return ( + _visible + ?
+
+ {t('addPeople.inviteMoreHeader')} +
+
+ +
+ {t('addPeople.inviteMorePrompt')} +
+
+
: null + ); +} + +/** + * Maps (parts of) the Redux state to the associated + * {@code Subject}'s props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {Props} + */ +function mapStateToProps(state) { + const participantCount = getParticipantCount(state); + + return { + _tileViewEnabled: state['features/video-layout'].tileViewEnabled, + _visible: isToolboxVisible(state) && participantCount === 1 + }; +} + +/** + * Maps dispatching of some action to React component props. + * + * @param {Function} dispatch - Redux action dispatcher. + * @returns {Props} + */ +const mapDispatchToProps = { + onClick: () => beginAddPeople() +}; + +export default translate(connect(mapStateToProps, mapDispatchToProps)(InviteMore)); diff --git a/react/features/conference/components/web/Subject.js b/react/features/conference/components/web/Subject.js index 8535264526..58201cdbce 100644 --- a/react/features/conference/components/web/Subject.js +++ b/react/features/conference/components/web/Subject.js @@ -75,7 +75,7 @@ function _mapStateToProps(state) { return { _showParticipantCount: participantCount > 2, _subject: getConferenceName(state), - _visible: isToolboxVisible(state) + _visible: isToolboxVisible(state) && participantCount > 1 }; } diff --git a/react/features/invite/actions.any.js b/react/features/invite/actions.any.js index fe2fc77e99..96255fce06 100644 --- a/react/features/invite/actions.any.js +++ b/react/features/invite/actions.any.js @@ -3,8 +3,8 @@ import type { Dispatch } from 'redux'; import { getInviteURL } from '../base/connection'; -import { inviteVideoRooms } from '../videosipgw'; import { getParticipants } from '../base/participants'; +import { inviteVideoRooms } from '../videosipgw'; import { ADD_PENDING_INVITE_REQUEST, diff --git a/react/features/invite/components/add-people-dialog/web/AddPeopleDialog.js b/react/features/invite/components/add-people-dialog/web/AddPeopleDialog.js index 19bfd72593..ffd1c7fe88 100644 --- a/react/features/invite/components/add-people-dialog/web/AddPeopleDialog.js +++ b/react/features/invite/components/add-people-dialog/web/AddPeopleDialog.js @@ -1,478 +1,214 @@ // @flow -import InlineMessage from '@atlaskit/inline-message'; -import React from 'react'; -import type { Dispatch } from 'redux'; +import React, { useState, useEffect } from 'react'; import { createInviteDialogEvent, sendAnalytics } from '../../../../analytics'; -import { Avatar } from '../../../../base/avatar'; -import { Dialog, hideDialog } from '../../../../base/dialog'; -import { translate, translateToHTML } from '../../../../base/i18n'; -import { Icon, IconPhone } from '../../../../base/icons'; +import { getRoomName } from '../../../../base/conference'; +import { getInviteURL } from '../../../../base/connection'; +import { Dialog } from '../../../../base/dialog'; +import { translate } from '../../../../base/i18n'; +import { JitsiRecordingConstants } from '../../../../base/lib-jitsi-meet'; import { getLocalParticipant } from '../../../../base/participants'; -import { MultiSelectAutocomplete } from '../../../../base/react'; import { connect } from '../../../../base/redux'; +import { getActiveSession } from '../../../../recording'; -import AbstractAddPeopleDialog, { - type Props as AbstractProps, - type State, - _mapStateToProps as _abstractMapStateToProps -} from '../AbstractAddPeopleDialog'; +import { updateDialInNumbers } from '../../../actions'; +import { _getDefaultPhoneNumber, getInviteText, isAddPeopleEnabled, isDialOutEnabled } from '../../../functions'; + +import CopyMeetingLinkSection from './CopyMeetingLinkSection'; +import DialInSection from './DialInSection'; +import Header from './Header'; +import InviteByEmailSection from './InviteByEmailSection'; +import InviteContactsSection from './InviteContactsSection'; +import LiveStreamSection from './LiveStreamSection'; declare var interfaceConfig: Object; -/** - * The type of the React {@code Component} props of {@link AddPeopleDialog}. - */ -type Props = AbstractProps & { +type Props = { /** - * The {@link JitsiMeetConference} which will be used to invite "room" - * participants through the SIP Jibri (Video SIP gateway). + * The name of the current conference. Used as part of inviting users. */ - _conference: Object, + _conferenceName: string, /** - * Whether to show a footer text after the search results as a last element. + * The object representing the dialIn feature. */ - _footerTextEnabled: boolean, + _dialIn: Object, /** - * The redux {@code dispatch} function. + * Whether or not invite should be hidden. */ - dispatch: Dispatch, + _hideInviteContacts: boolean, + + /** + * The current url of the conference to be copied onto the clipboard. + */ + _inviteUrl: string, + + /** + * The current known URL for a live stream in progress. + */ + _liveStreamViewURL: string, + + /** + * The redux representation of the local participant. + */ + _localParticipantName: ?string, + + /** + * The current location url of the conference. + */ + _locationUrl: Object, /** * Invoked to obtain translated strings. */ t: Function, + + /** + * Method to update the dial in numbers. + */ + updateNumbers: Function }; /** - * The dialog that allows to invite people to the call. + * Invite More component. + * + * @returns {React$Element} */ -class AddPeopleDialog extends AbstractAddPeopleDialog { - _multiselect = null; - - _resourceClient: Object; - - state = { - addToCallError: false, - addToCallInProgress: false, - inviteItems: [] - }; +function AddPeopleDialog({ + _conferenceName, + _dialIn, + _hideInviteContacts, + _inviteUrl, + _liveStreamViewURL, + _localParticipantName, + _locationUrl, + t, + updateNumbers }: Props) { + const [ phoneNumber, setPhoneNumber ] = useState(undefined); /** - * Initializes a new {@code AddPeopleDialog} instance. - * - * @param {Object} props - The read-only properties with which the new - * instance is to be initialized. + * Updates the dial-in numbers. */ - constructor(props: Props) { - super(props); - - // Bind event handlers so they are only bound once per instance. - this._onItemSelected = this._onItemSelected.bind(this); - this._onSelectionChange = this._onSelectionChange.bind(this); - this._onSubmit = this._onSubmit.bind(this); - this._parseQueryResults = this._parseQueryResults.bind(this); - this._setMultiSelectElement = this._setMultiSelectElement.bind(this); - - this._resourceClient = { - makeQuery: this._query, - parseResults: this._parseQueryResults - }; - } + useEffect(() => { + if (!_dialIn.numbers) { + updateNumbers(); + } + }, []); /** - * Sends an analytics event to record the dialog has been shown. + * Sends analytics events when the dialog opens/closes. * - * @inheritdoc * @returns {void} */ - componentDidMount() { + useEffect(() => { sendAnalytics(createInviteDialogEvent( 'invite.dialog.opened', 'dialog')); - } + + return () => { + sendAnalytics(createInviteDialogEvent( + 'invite.dialog.closed', 'dialog')); + }; + }, []); /** - * React Component method that executes once component is updated. + * Updates the phone number in the state once the dial-in numbers are fetched. * - * @param {Object} prevProps - The state object before the update. - * @param {Object} prevState - The state object before the update. * @returns {void} */ - componentDidUpdate(prevProps, prevState) { - /** - * Clears selected items from the multi select component on successful - * invite. - */ - if (prevState.addToCallError - && !this.state.addToCallInProgress - && !this.state.addToCallError - && this._multiselect) { - this._multiselect.setSelectedItems([]); + useEffect(() => { + if (!phoneNumber && _dialIn && _dialIn.numbers) { + setPhoneNumber(_getDefaultPhoneNumber(_dialIn.numbers)); } - } + }, [ _dialIn ]); - /** - * Sends an analytics event to record the dialog has been closed. - * - * @inheritdoc - * @returns {void} - */ - componentWillUnmount() { - sendAnalytics(createInviteDialogEvent( - 'invite.dialog.closed', 'dialog')); - } + const invite = getInviteText({ + _conferenceName, + _localParticipantName, + _inviteUrl, + _locationUrl, + _dialIn, + _liveStreamViewURL, + phoneNumber, + t + }); + const inviteSubject = t('addPeople.inviteMoreMailSubject', { + appName: interfaceConfig.APP_NAME + }); - /** - * Renders the content of this component. - * - * @returns {ReactElement} - */ - render() { - const { - _addPeopleEnabled, - _dialOutEnabled, - _footerTextEnabled, - t - } = this.props; - let isMultiSelectDisabled = this.state.addToCallInProgress || false; - let placeholder; - let loadingMessage; - let noMatches; - let footerText; - - if (_addPeopleEnabled && _dialOutEnabled) { - loadingMessage = 'addPeople.loading'; - noMatches = 'addPeople.noResults'; - placeholder = 'addPeople.searchPeopleAndNumbers'; - } else if (_addPeopleEnabled) { - loadingMessage = 'addPeople.loadingPeople'; - noMatches = 'addPeople.noResults'; - placeholder = 'addPeople.searchPeople'; - } else if (_dialOutEnabled) { - loadingMessage = 'addPeople.loadingNumber'; - noMatches = 'addPeople.noValidNumbers'; - placeholder = 'addPeople.searchNumbers'; - } else { - isMultiSelectDisabled = true; - noMatches = 'addPeople.noResults'; - placeholder = 'addPeople.disabled'; - } - - if (_footerTextEnabled) { - footerText = { - content:
-
- - - -
- { translateToHTML(t, 'addPeople.footerText') } -
- }; - } - - return ( - -
- { this._renderErrorMessage() } - -
-
- ); - } - - _invite: Array => Promise<*> - - _isAddDisabled: () => boolean; - - _onItemSelected: (Object) => Object; - - /** - * Callback invoked when a selection has been made but before it has been - * set as selected. - * - * @param {Object} item - The item that has just been selected. - * @private - * @returns {Object} The item to display as selected in the input. - */ - _onItemSelected(item) { - if (item.item.type === 'phone') { - item.content = item.item.number; - } - - return item; - } - - _onSelectionChange: (Map<*, *>) => void; - - /** - * Handles a selection change. - * - * @param {Map} selectedItems - The list of selected items. - * @private - * @returns {void} - */ - _onSelectionChange(selectedItems) { - this.setState({ - inviteItems: selectedItems - }); - } - - _onSubmit: () => void; - - /** - * Submits the selection for inviting. - * - * @private - * @returns {void} - */ - _onSubmit() { - const { inviteItems } = this.state; - const invitees = inviteItems.map(({ item }) => item); - - this._invite(invitees) - .then(invitesLeftToSend => { - if (invitesLeftToSend.length) { - const unsentInviteIDs - = invitesLeftToSend.map(invitee => - invitee.id || invitee.user_id || invitee.number); - const itemsToSelect - = inviteItems.filter(({ item }) => - unsentInviteIDs.includes(item.id || item.user_id || item.number)); - - if (this._multiselect) { - this._multiselect.setSelectedItems(itemsToSelect); - } - } else { - this.props.dispatch(hideDialog()); + return ( + +
+ { !_hideInviteContacts && } + + + { + _liveStreamViewURL + && + } + { + _dialIn.numbers + && } - }); - } - - _parseQueryResults: (?Array) => Array; - - /** - * Returns the avatar component for a user. - * - * @param {Object} user - The user. - * @param {string} className - The CSS class for the avatar component. - * @private - * @returns {ReactElement} - */ - _getAvatar(user, className = 'avatar-small') { - return (); - } - - /** - * Processes results from requesting available numbers and people by munging - * each result into a format {@code MultiSelectAutocomplete} can use for - * display. - * - * @param {Array} response - The response object from the server for the - * query. - * @private - * @returns {Object[]} Configuration objects for items to display in the - * search autocomplete. - */ - _parseQueryResults(response = []) { - const { t, _dialOutEnabled } = this.props; - const users = response.filter(item => item.type !== 'phone'); - const userDisplayItems = []; - - users.forEach(user => { - const { name, phone } = user; - const tagAvatar = this._getAvatar(user, 'avatar-xsmall'); - const elemAvatar = this._getAvatar(user); - - userDisplayItems.push({ - content: name, - elemBefore: elemAvatar, - item: user, - tag: { - elemBefore: tagAvatar - }, - value: user.id || user.user_id - }); - - if (phone && _dialOutEnabled) { - userDisplayItems.push({ - filterValues: [ name, phone ], - content: `${phone} (${name})`, - elemBefore: elemAvatar, - item: { - type: 'phone', - number: phone - }, - tag: { - elemBefore: tagAvatar - }, - value: phone - }); - } - }); - - const numbers = response.filter(item => item.type === 'phone'); - const telephoneIcon = this._renderTelephoneIcon(); - - const numberDisplayItems = numbers.map(number => { - const numberNotAllowedMessage - = number.allowed ? '' : t('addPeople.countryNotSupported'); - const countryCodeReminder = number.showCountryCodeReminder - ? t('addPeople.countryReminder') : ''; - const description - = `${numberNotAllowedMessage} ${countryCodeReminder}`.trim(); - - return { - filterValues: [ - number.originalEntry, - number.number - ], - content: t('addPeople.telephone', { number: number.number }), - description, - isDisabled: !number.allowed, - elemBefore: telephoneIcon, - item: number, - tag: { - elemBefore: telephoneIcon - }, - value: number.number - }; - }); - - return [ - ...userDisplayItems, - ...numberDisplayItems - ]; - } - - _query: (string) => Promise>; - - /** - * Renders the error message if the add doesn't succeed. - * - * @private - * @returns {ReactElement|null} - */ - _renderErrorMessage() { - if (!this.state.addToCallError) { - return null; - } - - const { t } = this.props; - const supportString = t('inlineDialogFailure.supportMsg'); - const supportLink = interfaceConfig.SUPPORT_URL; - const supportLinkContent - = ( - - - { supportString.padEnd(supportString.length + 1) } - - - - { t('inlineDialogFailure.support') } - - - . - - ); - - return ( -
- - { supportLinkContent } -
- ); - } - - /** - * Renders a telephone icon. - * - * @private - * @returns {ReactElement} - */ - _renderTelephoneIcon() { - return ( - - - - ); - } - - _setMultiSelectElement: (React$ElementRef<*> | null) => void; - - /** - * Sets the instance variable for the multi select component - * element so it can be accessed directly. - * - * @param {Object} element - The DOM element for the component's dialog. - * @private - * @returns {void} - */ - _setMultiSelectElement(element) { - this._multiselect = element; - } + + ); } /** - * Maps (parts of) the Redux state to the associated - * {@code AddPeopleDialog}'s props. + * Maps (parts of) the Redux state to the associated props for the + * {@code AddPeopleDialog} component. * * @param {Object} state - The Redux state. * @private - * @returns {{ - * _dialOutAuthUrl: string, - * _jwt: string, - * _peopleSearchQueryTypes: Array, - * _peopleSearchUrl: string - * }} + * @returns {Props} */ -function _mapStateToProps(state) { - const { - enableFeaturesBasedOnToken - } = state['features/base/config']; - let footerTextEnabled = false; - - if (enableFeaturesBasedOnToken) { - const { features = {} } = getLocalParticipant(state); - - if (String(features['outbound-call']) !== 'true') { - footerTextEnabled = true; - } - } +function mapStateToProps(state) { + const localParticipant = getLocalParticipant(state); + const currentLiveStreamingSession + = getActiveSession(state, JitsiRecordingConstants.mode.STREAM); + const { iAmRecorder } = state['features/base/config']; + const addPeopleEnabled = isAddPeopleEnabled(state); + const dialOutEnabled = isDialOutEnabled(state); return { - ..._abstractMapStateToProps(state), - _footerTextEnabled: footerTextEnabled + _conferenceName: getRoomName(state), + _dialIn: state['features/invite'], + _hideInviteContacts: + iAmRecorder || (!addPeopleEnabled && !dialOutEnabled), + _inviteUrl: getInviteURL(state), + _liveStreamViewURL: + currentLiveStreamingSession + && currentLiveStreamingSession.liveStreamViewURL, + _localParticipantName: localParticipant?.name, + _locationUrl: state['features/base/connection'].locationURL }; } -export default translate(connect(_mapStateToProps)(AddPeopleDialog)); +/** + * Maps dispatching of some action to React component props. + * + * @param {Function} dispatch - Redux action dispatcher. + * @returns {Props} + */ +const mapDispatchToProps = { + updateNumbers: () => updateDialInNumbers() +}; + +export default translate( + connect(mapStateToProps, mapDispatchToProps)(AddPeopleDialog) +); diff --git a/react/features/invite/components/add-people-dialog/web/CopyMeetingLinkSection.js b/react/features/invite/components/add-people-dialog/web/CopyMeetingLinkSection.js new file mode 100644 index 0000000000..e688230cfc --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/CopyMeetingLinkSection.js @@ -0,0 +1,111 @@ +// @flow + +import React, { useState } from 'react'; + +import { translate } from '../../../../base/i18n'; +import { Icon, IconCheck, IconCopy } from '../../../../base/icons'; + +import { copyText } from './utils'; + +type Props = { + + /** + * Invoked to obtain translated strings. + */ + t: Function, + + /** + * The URL of the conference. + */ + url: string +}; + +/** + * Component meant to enable users to copy the conference URL. + * + * @returns {React$Element} + */ +function CopyMeetingLinkSection({ t, url }: Props) { + const [ isClicked, setIsClicked ] = useState(false); + const [ isHovered, setIsHovered ] = useState(false); + + /** + * Click handler for the element. + * + * @returns {void} + */ + function onClick() { + setIsHovered(false); + if (copyText(url)) { + setIsClicked(true); + + setTimeout(() => { + setIsClicked(false); + }, 2500); + } + } + + /** + * Hover handler for the element. + * + * @returns {void} + */ + function onHoverIn() { + if (!isClicked) { + setIsHovered(true); + } + } + + /** + * Hover handler for the element. + * + * @returns {void} + */ + function onHoverOut() { + setIsHovered(false); + } + + /** + * Renders the content of the link based on the state. + * + * @returns {React$Element} + */ + function renderLinkContent() { + if (isClicked) { + return ( + <> +
+ {t('addPeople.linkCopied')} +
+ + + ); + } + + const displayUrl = decodeURI(url.replace(/^https?:\/\//i, '')); + + return ( + <> +
+ {isHovered ? t('addPeople.copyLink') : displayUrl} +
+ + + ); + } + + return ( + <> + {t('addPeople.shareLink')} +
+ { renderLinkContent() } +
+ + ); +} + +export default translate(CopyMeetingLinkSection); diff --git a/react/features/invite/components/info-dialog/web/DialInNumber.js b/react/features/invite/components/add-people-dialog/web/DialInNumber.js similarity index 63% rename from react/features/invite/components/info-dialog/web/DialInNumber.js rename to react/features/invite/components/add-people-dialog/web/DialInNumber.js index f76f9fb883..3cafc8d4af 100644 --- a/react/features/invite/components/info-dialog/web/DialInNumber.js +++ b/react/features/invite/components/add-people-dialog/web/DialInNumber.js @@ -3,9 +3,12 @@ import React, { Component } from 'react'; import { translate } from '../../../../base/i18n'; +import { Icon, IconCopy } from '../../../../base/icons'; import { _formatConferenceIDPin } from '../../../_utils'; +import { copyText } from './utils'; + /** * The type of the React {@code Component} props of {@link DialInNumber}. */ @@ -36,6 +39,37 @@ type Props = { * @extends Component */ class DialInNumber extends Component { + + /** + * Initializes a new DialInNumber instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + // Bind event handler so it is only bound once for every instance. + this._onCopyText = this._onCopyText.bind(this); + } + + _onCopyText: () => void; + + /** + * Copies the dial-in information to the clipboard. + * + * @returns {void} + */ + _onCopyText() { + const { conferenceID, phoneNumber, t } = this.props; + const dialInLabel = t('info.dialInNumber'); + const passcode = t('info.dialInConferenceID'); + const conferenceIDPin = `${_formatConferenceIDPin(conferenceID)}#`; + const textToCopy = `${dialInLabel} ${phoneNumber} ${passcode} ${conferenceIDPin}`; + + copyText(textToCopy); + } + /** * Implements React's {@link Component#render()}. * @@ -66,6 +100,11 @@ class DialInNumber extends Component { { `${_formatConferenceIDPin(conferenceID)}#` } + + + ); } diff --git a/react/features/invite/components/add-people-dialog/web/DialInSection.js b/react/features/invite/components/add-people-dialog/web/DialInSection.js new file mode 100644 index 0000000000..b179adcab6 --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/DialInSection.js @@ -0,0 +1,76 @@ +// @flow + +import React from 'react'; + +import { translate } from '../../../../base/i18n'; + +import { getDialInfoPageURL } from '../../../functions'; + +import DialInNumber from './DialInNumber'; + +type Props = { + + /** + * The name of the current conference. Used as part of inviting users. + */ + conferenceName: string, + + /** + * The object representing the dialIn feature. + */ + dialIn: Object, + + /** + * The current location url of the conference. + */ + locationUrl: Object, + + /** + * The phone number to dial to begin the process of dialing into a + * conference. + */ + phoneNumber: string, + + /** + * Invoked to obtain translated strings. + */ + t: Function + +}; + +/** + * Returns a ReactElement for showing how to dial into the conference, if + * dialing in is available. + * + * @private + * @returns {null|ReactElement} + */ +function DialInSection({ + conferenceName, + dialIn, + locationUrl, + phoneNumber, + t +}: Props) { + return ( + + ); +} + +export default translate(DialInSection); diff --git a/react/features/invite/components/add-people-dialog/web/Header.js b/react/features/invite/components/add-people-dialog/web/Header.js new file mode 100644 index 0000000000..a52795f4ee --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/Header.js @@ -0,0 +1,38 @@ +// @flow + +import React from 'react'; + +import { translate } from '../../../../base/i18n'; +import { Icon, IconClose } from '../../../../base/icons'; + +type Props = { + + /** + * The {@link ModalDialog} closing function. + */ + onClose: Function, + + /** + * Invoked to obtain translated strings. + */ + t: Function +}; + +/** + * Custom header of the {@code AddPeopleDialog}. + * + * @returns {React$Element} + */ +function Header({ onClose, t }: Props) { + return ( +
+ { t('addPeople.inviteMorePrompt') } + +
+ ); +} + +export default translate(Header); diff --git a/react/features/invite/components/add-people-dialog/web/InviteByEmailSection.js b/react/features/invite/components/add-people-dialog/web/InviteByEmailSection.js new file mode 100644 index 0000000000..ffc93b3deb --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/InviteByEmailSection.js @@ -0,0 +1,156 @@ +// @flow + +import React, { useState } from 'react'; +import Tooltip from '@atlaskit/tooltip'; + +import { translate } from '../../../../base/i18n'; +import { + Icon, + IconArrowDownSmall, + IconCopy, + IconEmail, + IconGoogle, + IconOutlook, + IconYahoo +} from '../../../../base/icons'; +import { openURLInBrowser } from '../../../../base/util'; + +import { copyText } from './utils'; + +type Props = { + + /** + * The encoded invitation subject. + */ + inviteSubject: string, + + /** + * The encoded invitation text to be sent. + */ + inviteText: string, + + /** + * Invoked to obtain translated strings. + */ + t: Function, +}; + +/** + * Component that renders email invite options. + * + * @returns {React$Element} + */ +function InviteByEmailSection({ inviteSubject, inviteText, t }: Props) { + const [ isActive, setIsActive ] = useState(false); + const encodedInviteSubject = encodeURIComponent(inviteSubject); + const encodedInviteText = encodeURIComponent(inviteText); + + /** + * Copies the conference invitation to the clipboard. + * + * @returns {void} + */ + function _onCopyText() { + copyText(inviteText); + } + + /** + * Opens an email provider containing the conference invite. + * + * @param {string} url - The url to be opened. + * @returns {Function} + */ + function _onSelectProvider(url) { + return function() { + openURLInBrowser(url, true); + }; + } + + /** + * Toggles the email invite drawer. + * + * @returns {void} + */ + function _onToggleActiveState() { + setIsActive(!isActive); + } + + /** + * Renders clickable elements that each open an email client + * containing a conference invite. + * + * @returns {React$Element} + */ + function renderEmailIcons() { + const PROVIDER_MAPPING = [ + { + icon: IconEmail, + tooltipKey: 'addPeople.defaultEmail', + url: `mailto:?subject=${encodedInviteSubject}&body=${encodedInviteText}` + }, + { + icon: IconGoogle, + tooltipKey: 'addPeople.googleEmail', + url: `https://mail.google.com/mail/?view=cm&fs=1&su=${encodedInviteSubject}&body=${encodedInviteText}` + }, + { + icon: IconOutlook, + tooltipKey: 'addPeople.outlookEmail', + // eslint-disable-next-line max-len + url: `https://outlook.office.com/mail/deeplink/compose?subject=${encodedInviteSubject}&body=${encodedInviteText}` + }, + { + icon: IconYahoo, + tooltipKey: 'addPeople.yahooEmail', + url: `https://compose.mail.yahoo.com/?To=&Subj=${encodedInviteSubject}&Body=${encodedInviteText}` + } + ]; + + return ( + <> + { + PROVIDER_MAPPING.map(({ icon, tooltipKey, url }, idx) => ( + +
+ +
+
+ )) + } + + ); + + } + + return ( + <> +
+
+ {t('addPeople.shareInvite')} + +
+
+ +
+ +
+
+ {renderEmailIcons()} +
+
+
+ + ); +} + +export default translate(InviteByEmailSection); diff --git a/react/features/invite/components/add-people-dialog/web/InviteContactsForm.js b/react/features/invite/components/add-people-dialog/web/InviteContactsForm.js new file mode 100644 index 0000000000..847bfb8b71 --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/InviteContactsForm.js @@ -0,0 +1,501 @@ +// @flow + +import InlineMessage from '@atlaskit/inline-message'; +import React from 'react'; +import type { Dispatch } from 'redux'; + +import { Avatar } from '../../../../base/avatar'; +import { translate, translateToHTML } from '../../../../base/i18n'; +import { Icon, IconPhone } from '../../../../base/icons'; +import { getLocalParticipant } from '../../../../base/participants'; +import { MultiSelectAutocomplete } from '../../../../base/react'; +import { connect } from '../../../../base/redux'; + +import AbstractAddPeopleDialog, { + type Props as AbstractProps, + type State, + _mapStateToProps as _abstractMapStateToProps +} from '../AbstractAddPeopleDialog'; + +declare var interfaceConfig: Object; + +type Props = AbstractProps & { + + /** + * The {@link JitsiMeetConference} which will be used to invite "room" participants. + */ + _conference: Object, + + /** + * Whether to show a footer text after the search results as a last element. + */ + _footerTextEnabled: boolean, + + /** + * The redux {@code dispatch} function. + */ + dispatch: Dispatch, + + /** + * Invoked to obtain translated strings. + */ + t: Function, +}; + +/** + * Form that enables inviting others to the call. + */ +class InviteContactsForm extends AbstractAddPeopleDialog { + _multiselect = null; + + _resourceClient: Object; + + state = { + addToCallError: false, + addToCallInProgress: false, + inviteItems: [] + }; + + /** + * Initializes a new {@code AddPeopleDialog} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: Props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onClearItems = this._onClearItems.bind(this); + this._onItemSelected = this._onItemSelected.bind(this); + this._onSelectionChange = this._onSelectionChange.bind(this); + this._onSubmit = this._onSubmit.bind(this); + this._parseQueryResults = this._parseQueryResults.bind(this); + this._setMultiSelectElement = this._setMultiSelectElement.bind(this); + this._renderFooterText = this._renderFooterText.bind(this); + + this._resourceClient = { + makeQuery: this._query, + parseResults: this._parseQueryResults + }; + } + + /** + * React Component method that executes once component is updated. + * + * @param {Object} prevProps - The state object before the update. + * @param {Object} prevState - The state object before the update. + * @returns {void} + */ + componentDidUpdate(prevProps, prevState) { + /** + * Clears selected items from the multi select component on successful + * invite. + */ + if (prevState.addToCallError + && !this.state.addToCallInProgress + && !this.state.addToCallError + && this._multiselect) { + this._multiselect.setSelectedItems([]); + } + } + + /** + * Renders the content of this component. + * + * @returns {ReactElement} + */ + render() { + const { + _addPeopleEnabled, + _dialOutEnabled, + t + } = this.props; + const footerText = this._renderFooterText(); + let isMultiSelectDisabled = this.state.addToCallInProgress; + let placeholder; + let loadingMessage; + let noMatches; + + if (_addPeopleEnabled && _dialOutEnabled) { + loadingMessage = 'addPeople.loading'; + noMatches = 'addPeople.noResults'; + placeholder = 'addPeople.searchPeopleAndNumbers'; + } else if (_addPeopleEnabled) { + loadingMessage = 'addPeople.loadingPeople'; + noMatches = 'addPeople.noResults'; + placeholder = 'addPeople.searchPeople'; + } else if (_dialOutEnabled) { + loadingMessage = 'addPeople.loadingNumber'; + noMatches = 'addPeople.noValidNumbers'; + placeholder = 'addPeople.searchNumbers'; + } else { + isMultiSelectDisabled = true; + noMatches = 'addPeople.noResults'; + placeholder = 'addPeople.disabled'; + } + + return ( +
+ { this._renderErrorMessage() } + + { this._renderFormActions() } +
+ ); + } + + _invite: Array => Promise<*> + + _isAddDisabled: () => boolean; + + _onItemSelected: (Object) => Object; + + /** + * Callback invoked when a selection has been made but before it has been + * set as selected. + * + * @param {Object} item - The item that has just been selected. + * @private + * @returns {Object} The item to display as selected in the input. + */ + _onItemSelected(item) { + if (item.item.type === 'phone') { + item.content = item.item.number; + } + + return item; + } + + _onSelectionChange: (Map<*, *>) => void; + + /** + * Handles a selection change. + * + * @param {Array} selectedItems - The list of selected items. + * @private + * @returns {void} + */ + _onSelectionChange(selectedItems) { + this.setState({ + inviteItems: selectedItems + }); + } + + _onSubmit: () => void; + + /** + * Submits the selection for inviting. + * + * @private + * @returns {void} + */ + _onSubmit() { + const { inviteItems } = this.state; + const invitees = inviteItems.map(({ item }) => item); + + this._invite(invitees) + .then(invitesLeftToSend => { + if (invitesLeftToSend.length) { + const unsentInviteIDs + = invitesLeftToSend.map(invitee => + invitee.id || invitee.user_id || invitee.number); + const itemsToSelect + = inviteItems.filter(({ item }) => + unsentInviteIDs.includes(item.id || item.user_id || item.number)); + + if (this._multiselect) { + this._multiselect.setSelectedItems(itemsToSelect); + } + } else { + // Do nothing. + } + }); + } + + _parseQueryResults: (?Array) => Array; + + /** + * Returns the avatar component for a user. + * + * @param {Object} user - The user. + * @param {string} className - The CSS class for the avatar component. + * @private + * @returns {ReactElement} + */ + _getAvatar(user, className = 'avatar-small') { + return ( + + ); + } + + /** + * Processes results from requesting available numbers and people by munging + * each result into a format {@code MultiSelectAutocomplete} can use for + * display. + * + * @param {Array} response - The response object from the server for the + * query. + * @private + * @returns {Object[]} Configuration objects for items to display in the + * search autocomplete. + */ + _parseQueryResults(response = []) { + const { t, _dialOutEnabled } = this.props; + const users = response.filter(item => item.type !== 'phone'); + const userDisplayItems = []; + + for (const user of users) { + const { name, phone } = user; + const tagAvatar = this._getAvatar(user, 'avatar-xsmall'); + const elemAvatar = this._getAvatar(user); + + userDisplayItems.push({ + content: name, + elemBefore: elemAvatar, + item: user, + tag: { + elemBefore: tagAvatar + }, + value: user.id || user.user_id + }); + + if (phone && _dialOutEnabled) { + userDisplayItems.push({ + filterValues: [ name, phone ], + content: `${phone} (${name})`, + elemBefore: elemAvatar, + item: { + type: 'phone', + number: phone + }, + tag: { + elemBefore: tagAvatar + }, + value: phone + }); + } + } + + const numbers = response.filter(item => item.type === 'phone'); + const telephoneIcon = this._renderTelephoneIcon(); + + const numberDisplayItems = numbers.map(number => { + const numberNotAllowedMessage + = number.allowed ? '' : t('addPeople.countryNotSupported'); + const countryCodeReminder = number.showCountryCodeReminder + ? t('addPeople.countryReminder') : ''; + const description + = `${numberNotAllowedMessage} ${countryCodeReminder}`.trim(); + + return { + filterValues: [ + number.originalEntry, + number.number + ], + content: t('addPeople.telephone', { number: number.number }), + description, + isDisabled: !number.allowed, + elemBefore: telephoneIcon, + item: number, + tag: { + elemBefore: telephoneIcon + }, + value: number.number + }; + }); + + return [ + ...userDisplayItems, + ...numberDisplayItems + ]; + } + + _query: (string) => Promise>; + + _renderFooterText: () => Object; + + /** + * Sets up the rendering of the footer text, if enabled. + * + * @returns {Object | undefined} + */ + _renderFooterText() { + const { _footerTextEnabled, t } = this.props; + let footerText; + + if (_footerTextEnabled) { + footerText = { + content:
+
+ + + +
+ { translateToHTML(t, 'addPeople.footerText') } +
+ }; + } + + return footerText; + } + + _onClearItems: () => void; + + /** + * Clears the selected items from state and form. + * + * @returns {void} + */ + _onClearItems() { + if (this._multiselect) { + this._multiselect.setSelectedItems([]); + } + this.setState({ inviteItems: [] }); + } + + /** + * Renders the add/cancel actions for the form. + * + * @returns {ReactElement|null} + */ + _renderFormActions() { + const { inviteItems } = this.state; + const { t } = this.props; + + if (!inviteItems.length) { + return null; + } + + return ( + + ); + } + + /** + * Renders the error message if the add doesn't succeed. + * + * @private + * @returns {ReactElement|null} + */ + _renderErrorMessage() { + if (!this.state.addToCallError) { + return null; + } + + const { t } = this.props; + const supportString = t('inlineDialogFailure.supportMsg'); + const supportLink = interfaceConfig.SUPPORT_URL; + + if (!supportLink) { + return null; + } + + const supportLinkContent = ( + + + { supportString.padEnd(supportString.length + 1) } + + + + { t('inlineDialogFailure.support') } + + + . + + ); + + return ( +
+ + { supportLinkContent } + +
+ ); + } + + /** + * Renders a telephone icon. + * + * @private + * @returns {ReactElement} + */ + _renderTelephoneIcon() { + return ( + + + + ); + } + + _setMultiSelectElement: (React$ElementRef<*> | null) => void; + + /** + * Sets the instance variable for the multi select component + * element so it can be accessed directly. + * + * @param {Object} element - The DOM element for the component's dialog. + * @private + * @returns {void} + */ + _setMultiSelectElement(element) { + this._multiselect = element; + } +} + +/** + * Maps (parts of) the Redux state to the associated + * {@code AddPeopleDialog}'s props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {Props} + */ +function _mapStateToProps(state) { + const { enableFeaturesBasedOnToken } = state['features/base/config']; + let footerTextEnabled = false; + + if (enableFeaturesBasedOnToken) { + const { features = {} } = getLocalParticipant(state); + + if (String(features['outbound-call']) !== 'true') { + footerTextEnabled = true; + } + } + + return { + ..._abstractMapStateToProps(state), + _footerTextEnabled: footerTextEnabled + }; +} + +export default translate(connect(_mapStateToProps)(InviteContactsForm)); diff --git a/react/features/invite/components/add-people-dialog/web/InviteContactsSection.js b/react/features/invite/components/add-people-dialog/web/InviteContactsSection.js new file mode 100644 index 0000000000..42966565d2 --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/InviteContactsSection.js @@ -0,0 +1,32 @@ +// @flow + +import React from 'react'; + +import { translate } from '../../../../base/i18n'; + +import InviteContactsForm from './InviteContactsForm'; + +type Props = { + + /** + * Invoked to obtain translated strings. + */ + t: Function +}; + +/** + * Component that represents the invitation section of the {@code AddPeopleDialog}. + * + * @returns {ReactElement$} + */ +function InviteContactsSection({ t }: Props) { + return ( + <> + {t('addPeople.addContacts')} + +
+ + ); +} + +export default translate(InviteContactsSection); diff --git a/react/features/invite/components/add-people-dialog/web/LiveStreamSection.js b/react/features/invite/components/add-people-dialog/web/LiveStreamSection.js new file mode 100644 index 0000000000..16a57e551d --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/LiveStreamSection.js @@ -0,0 +1,111 @@ +// @flow + +import React, { useState } from 'react'; + +import { translate } from '../../../../base/i18n'; +import { Icon, IconCheck, IconCopy } from '../../../../base/icons'; + +import { copyText } from './utils'; + +type Props = { + + /** + * The current known URL for a live stream in progress. + */ + liveStreamViewURL: string, + + /** + * Invoked to obtain translated strings. + */ + t: Function +} + +/** + * Section of the {@code AddPeopleDialog} that renders the + * live streaming url, allowing a copy action. + * + * @returns {React$Element} + */ +function LiveStreamSection({ liveStreamViewURL, t }: Props) { + const [ isClicked, setIsClicked ] = useState(false); + const [ isHovered, setIsHovered ] = useState(false); + + /** + * Click handler for the element. + * + * @returns {void} + */ + function onClick() { + setIsHovered(false); + if (copyText(liveStreamViewURL)) { + setIsClicked(true); + + setTimeout(() => { + setIsClicked(false); + }, 2500); + } + } + + /** + * Hover handler for the element. + * + * @returns {void} + */ + function onHoverIn() { + if (!isClicked) { + setIsHovered(true); + } + } + + /** + * Hover handler for the element. + * + * @returns {void} + */ + function onHoverOut() { + setIsHovered(false); + } + + /** + * Renders the content of the link based on the state. + * + * @returns {React$Element} + */ + function renderLinkContent() { + if (isClicked) { + return ( + <> +
+ {t('addPeople.linkCopied')} +
+ + + ); + } + + return ( + <> +
+ {isHovered ? t('addPeople.copyStream') : liveStreamViewURL} +
+ + + ); + } + + return ( + <> + {t('addPeople.shareStream')} +
+ { renderLinkContent() } +
+
+ + ); +} + +export default translate(LiveStreamSection); diff --git a/react/features/invite/components/add-people-dialog/web/index.js b/react/features/invite/components/add-people-dialog/web/index.js index f2e67e6310..f432fcb95a 100644 --- a/react/features/invite/components/add-people-dialog/web/index.js +++ b/react/features/invite/components/add-people-dialog/web/index.js @@ -1,3 +1,4 @@ // @flow export { default as AddPeopleDialog } from './AddPeopleDialog'; +export * from './utils'; diff --git a/react/features/invite/components/add-people-dialog/web/utils.js b/react/features/invite/components/add-people-dialog/web/utils.js new file mode 100644 index 0000000000..46f3c6505d --- /dev/null +++ b/react/features/invite/components/add-people-dialog/web/utils.js @@ -0,0 +1,23 @@ +// @flow + +/** + * Tries to copy a given text to the clipboard. + * + * @param {string} textToCopy - Text to be copied. + * @returns {boolean} + */ +export function copyText(textToCopy: string) { + const fakeTextArea = document.createElement('textarea'); + + // $FlowFixMe + document.body.appendChild(fakeTextArea); + fakeTextArea.value = textToCopy; + fakeTextArea.select(); + + const result = document.execCommand('copy'); + + // $FlowFixMe + document.body.removeChild(fakeTextArea); + + return result; +} diff --git a/react/features/invite/components/index.js b/react/features/invite/components/index.js index 132e7a3e77..b232f573a5 100644 --- a/react/features/invite/components/index.js +++ b/react/features/invite/components/index.js @@ -2,5 +2,4 @@ export * from './add-people-dialog'; export * from './dial-in-summary'; -export * from './info-dialog'; export * from './callee-info'; diff --git a/react/features/invite/components/info-dialog/index.native.js b/react/features/invite/components/info-dialog/index.native.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/react/features/invite/components/info-dialog/index.web.js b/react/features/invite/components/info-dialog/index.web.js deleted file mode 100644 index 40d5f46528..0000000000 --- a/react/features/invite/components/info-dialog/index.web.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow - -export * from './web'; diff --git a/react/features/invite/components/info-dialog/web/InfoDialog.js b/react/features/invite/components/info-dialog/web/InfoDialog.js deleted file mode 100644 index a3ec994bad..0000000000 --- a/react/features/invite/components/info-dialog/web/InfoDialog.js +++ /dev/null @@ -1,644 +0,0 @@ -// @flow - -import React, { Component } from 'react'; -import type { Dispatch } from 'redux'; - -import { setPassword } from '../../../../base/conference'; -import { getInviteURL } from '../../../../base/connection'; -import { Dialog } from '../../../../base/dialog'; -import { translate } from '../../../../base/i18n'; -import { Icon, IconInfo, IconCopy } from '../../../../base/icons'; -import { connect } from '../../../../base/redux'; -import { - isLocalParticipantModerator, - getLocalParticipant -} from '../../../../base/participants'; - -import { - _decodeRoomURI, - _getDefaultPhoneNumber, - getDialInfoPageURL, - shouldDisplayDialIn -} from '../../../functions'; -import logger from '../../../logger'; -import DialInNumber from './DialInNumber'; -import PasswordForm from './PasswordForm'; - - -/** - * The type of the React {@code Component} props of {@link InfoDialog}. - */ -type Props = { - - /** - * Whether or not the current user can modify the current password. - */ - _canEditPassword: boolean, - - /** - * The JitsiConference for which to display a lock state and change the - * password. - */ - _conference: Object, - - /** - * The name of the current conference. Used as part of inviting users. - */ - _conferenceName: string, - - /** - * The number of digits to be used in the password. - */ - _passwordNumberOfDigits: ?number, - - /** - * The current url of the conference to be copied onto the clipboard. - */ - _inviteURL: string, - - /** - * The redux representation of the local participant. - */ - _localParticipantName: ?string, - - /** - * The current location url of the conference. - */ - _locationURL: Object, - - /** - * The value for how the conference is locked (or undefined if not locked) - * as defined by room-lock constants. - */ - _locked: string, - - /** - * The current known password for the JitsiConference. - */ - _password: string, - - /** - * The object representing the dialIn feature. - */ - dialIn: Object, - - /** - * Invoked to open a dialog for adding participants to the conference. - */ - dispatch: Dispatch, - - /** - * Whether is Atlaskit InlineDialog or a normal dialog. - */ - isInlineDialog: boolean, - - /** - * The current known URL for a live stream in progress. - */ - liveStreamViewURL: string, - - /** - * Callback invoked when the dialog should be closed. - */ - onClose: Function, - - /** - * Callback invoked when a mouse-related event has been detected. - */ - onMouseOver: Function, - - /** - * Invoked to obtain translated strings. - */ - t: Function -}; - -/** - * The type of the React {@code Component} state of {@link InfoDialog}. - */ -type State = { - - /** - * Whether or not to show the password in editing mode. - */ - passwordEditEnabled: boolean, - - /** - * The conference dial-in number to display. - */ - phoneNumber: ?string -}; - -/** - * A React Component with the contents for a dialog that shows information about - * the current conference. - * - * @extends Component - */ -class InfoDialog extends Component { - _copyElement: ?Object; - _copyUrlElement: ?Object; - - /** - * Implements React's {@link Component#getDerivedStateFromProps()}. - * - * @inheritdoc - */ - static getDerivedStateFromProps(props, state) { - let phoneNumber = state.phoneNumber; - - if (!state.phoneNumber && props.dialIn.numbers) { - phoneNumber = _getDefaultPhoneNumber(props.dialIn.numbers); - } - - return { - // Exit edit mode when a password is set locally or remotely. - passwordEditEnabled: state.passwordEditEnabled && props._password - ? false : state.passwordEditEnabled, - phoneNumber - }; - } - - /** - * {@code InfoDialog} component's local state. - * - * @type {Object} - * @property {boolean} passwordEditEnabled - Whether or not to show the - * {@code PasswordForm} in its editing state. - * @property {string} phoneNumber - The number to display for dialing into - * the conference. - */ - state = { - passwordEditEnabled: false, - phoneNumber: undefined - }; - - /** - * Initializes new {@code InfoDialog} instance. - * - * @param {Object} props - The read-only properties with which the new - * instance is to be initialized. - */ - constructor(props: Props) { - super(props); - - if (props.dialIn && props.dialIn.numbers) { - this.state.phoneNumber - = _getDefaultPhoneNumber(props.dialIn.numbers); - } - - /** - * The internal reference to the DOM/HTML element backing the React - * {@code Component} text area. It is necessary for the implementation - * of copying to the clipboard. - * - * @private - * @type {HTMLTextAreaElement} - */ - this._copyElement = null; - - // Bind event handlers so they are only bound once for every instance. - this._onClickURLText = this._onClickURLText.bind(this); - this._onCopyInviteInfo = this._onCopyInviteInfo.bind(this); - this._onCopyInviteUrl = this._onCopyInviteUrl.bind(this); - this._onPasswordRemove = this._onPasswordRemove.bind(this); - this._onPasswordSubmit = this._onPasswordSubmit.bind(this); - this._onTogglePasswordEditState - = this._onTogglePasswordEditState.bind(this); - this._setCopyElement = this._setCopyElement.bind(this); - this._setCopyUrlElement = this._setCopyUrlElement.bind(this); - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { - isInlineDialog, - liveStreamViewURL, - onMouseOver, - t - } = this.props; - - const inlineDialog = ( -
-
-

- -

-
-
-
- { t('info.title') } -
-
- - { t('info.conferenceURL') } - -   - - - { decodeURI(this._getURLToDisplay()) } - - - - - -
-
- { this._renderDialInDisplay() } -
- { liveStreamViewURL && this._renderLiveStreamURL() } -
- -
-
- - { this._renderPasswordAction() } -
-
-