init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
import React, { useEffect } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createInviteDialogEvent } from '../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../analytics/functions';
import { IReduxState } from '../../../../app/types';
import { getInviteURL } from '../../../../base/connection/functions';
import { translate } from '../../../../base/i18n/functions';
import { JitsiRecordingConstants } from '../../../../base/lib-jitsi-meet';
import Dialog from '../../../../base/ui/components/web/Dialog';
import { StatusCode } from '../../../../base/util/uri';
import { isDynamicBrandingDataLoaded } from '../../../../dynamic-branding/functions.any';
import { getActiveSession } from '../../../../recording/functions';
import { updateDialInNumbers } from '../../../actions.web';
import {
_getDefaultPhoneNumber,
getInviteText,
getInviteTextiOS,
isAddPeopleEnabled,
isDialOutEnabled,
isSharingEnabled,
sharingFeatures
} from '../../../functions';
import CopyMeetingLinkSection from './CopyMeetingLinkSection';
import DialInLimit from './DialInLimit';
import DialInSection from './DialInSection';
import InviteByEmailSection from './InviteByEmailSection';
import InviteContactsSection from './InviteContactsSection';
import LiveStreamSection from './LiveStreamSection';
interface IProps extends WithTranslation {
/**
* The object representing the dialIn feature.
*/
_dialIn: any;
/**
* Whether or not dial in number should be visible.
*/
_dialInVisible: boolean;
/**
* Whether or not email sharing features should be visible.
*/
_emailSharingVisible: boolean;
/**
* The meeting invitation text.
*/
_invitationText: string;
/**
* The custom no new-lines meeting invitation text for iOS default email.
* Needed because of this mailto: iOS issue: https://developer.apple.com/forums/thread/681023.
*/
_invitationTextiOS: string;
/**
* An alternate app name to be displayed in the email subject.
*/
_inviteAppName?: string | null;
/**
* Whether or not invite contacts should be visible.
*/
_inviteContactsVisible: boolean;
/**
* The current url of the conference to be copied onto the clipboard.
*/
_inviteUrl: string;
/**
* Whether the dial in limit has been exceeded.
*/
_isDialInOverLimit?: boolean;
/**
* The current known URL for a live stream in progress.
*/
_liveStreamViewURL?: string;
/**
* The default phone number.
*/
_phoneNumber?: string | null;
/**
* Whether or not url sharing button should be visible.
*/
_urlSharingVisible: boolean;
/**
* Method to update the dial in numbers.
*/
updateNumbers: Function;
}
/**
* Invite More component.
*
* @returns {React$Element<any>}
*/
function AddPeopleDialog({
_dialIn,
_dialInVisible,
_urlSharingVisible,
_emailSharingVisible,
_invitationText,
_invitationTextiOS,
_inviteAppName,
_inviteContactsVisible,
_inviteUrl,
_isDialInOverLimit,
_liveStreamViewURL,
_phoneNumber,
t,
updateNumbers
}: IProps) {
/**
* Updates the dial-in numbers.
*/
useEffect(() => {
if (!_dialIn.numbers) {
updateNumbers();
}
}, []);
/**
* Sends analytics events when the dialog opens/closes.
*
* @returns {void}
*/
useEffect(() => {
sendAnalytics(createInviteDialogEvent(
'opened', 'dialog'));
return () => {
sendAnalytics(createInviteDialogEvent(
'closed', 'dialog'));
};
}, []);
const inviteSubject = t('addPeople.inviteMoreMailSubject', {
appName: _inviteAppName ?? interfaceConfig.APP_NAME
});
return (
<Dialog
cancel = {{ hidden: true }}
ok = {{ hidden: true }}
titleKey = 'addPeople.inviteMorePrompt'>
<div className = 'invite-more-dialog'>
{ _inviteContactsVisible && <InviteContactsSection /> }
{_urlSharingVisible ? <CopyMeetingLinkSection url = { _inviteUrl } /> : null}
{
_emailSharingVisible
? <InviteByEmailSection
inviteSubject = { inviteSubject }
inviteText = { _invitationText }
inviteTextiOS = { _invitationTextiOS } />
: null
}
<div className = 'invite-more-dialog separator' />
{
_liveStreamViewURL
&& <LiveStreamSection liveStreamViewURL = { _liveStreamViewURL } />
}
{
_phoneNumber
&& _dialInVisible
&& <DialInSection phoneNumber = { _phoneNumber } />
}
{
!_phoneNumber && _dialInVisible && _isDialInOverLimit && <DialInLimit />
}
</div>
</Dialog>
);
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code AddPeopleDialog} component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The properties explicitly passed to the component.
* @private
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
const currentLiveStreamingSession
= getActiveSession(state, JitsiRecordingConstants.mode.STREAM);
const { iAmRecorder, inviteAppName } = state['features/base/config'];
const addPeopleEnabled = isAddPeopleEnabled(state);
const dialOutEnabled = isDialOutEnabled(state);
const hideInviteContacts = iAmRecorder || (!addPeopleEnabled && !dialOutEnabled);
const dialIn = state['features/invite']; // @ts-ignore
const phoneNumber = dialIn?.numbers ? _getDefaultPhoneNumber(dialIn.numbers) : undefined;
const isDialInOverLimit = dialIn?.error?.status === StatusCode.PaymentRequired;
return {
_dialIn: dialIn,
_dialInVisible: isSharingEnabled(sharingFeatures.dialIn),
_urlSharingVisible: isDynamicBrandingDataLoaded(state) && isSharingEnabled(sharingFeatures.url),
_emailSharingVisible: isSharingEnabled(sharingFeatures.email),
_invitationText: getInviteText({ state,
phoneNumber,
t: ownProps.t }),
_invitationTextiOS: getInviteTextiOS({ state,
phoneNumber,
t: ownProps.t }),
_inviteAppName: inviteAppName,
_inviteContactsVisible: interfaceConfig.ENABLE_DIAL_OUT && !hideInviteContacts,
_inviteUrl: getInviteURL(state),
_isDialInOverLimit: isDialInOverLimit,
_liveStreamViewURL: currentLiveStreamingSession?.liveStreamViewURL,
_phoneNumber: phoneNumber
};
}
/**
* Maps dispatching of some action to React component props.
*
* @param {Function} dispatch - Redux action dispatcher.
* @returns {IProps}
*/
const mapDispatchToProps = {
updateNumbers: () => updateDialInNumbers()
};
export default translate(
connect(mapStateToProps, mapDispatchToProps)(AddPeopleDialog)
);

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import CopyButton from '../../../../base/buttons/CopyButton.web';
import { getDecodedURI } from '../../../../base/util/uri';
interface IProps {
/**
* The URL of the conference.
*/
url: string;
}
const useStyles = makeStyles()(theme => {
return {
label: {
display: 'block',
marginBottom: theme.spacing(2)
}
};
});
/**
* Component meant to enable users to copy the conference URL.
*
* @returns {React$Element<any>}
*/
function CopyMeetingLinkSection({ url }: IProps) {
const { classes } = useStyles();
const { t } = useTranslation();
return (
<>
<p className = { classes.label }>{t('addPeople.shareLink')}</p>
<CopyButton
accessibilityText = { t('addPeople.accessibilityLabel.meetingLink', { url: getDecodedURI(url) }) }
className = 'invite-more-dialog-conference-url'
displayedText = { getDecodedURI(url) }
id = 'add-people-copy-link-button'
textOnCopySuccess = { t('addPeople.linkCopied') }
textOnHover = { t('addPeople.copyLink') }
textToCopy = { url } />
</>
);
}
export default CopyMeetingLinkSection;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import { UPGRADE_OPTIONS_LINK, UPGRADE_OPTIONS_TEXT } from '../../../constants';
const useStyles = makeStyles()(theme => {
return {
limitContainer: {
backgroundColor: theme.palette.warning01,
borderRadius: '6px',
padding: '8px 16px'
},
limitInfo: {
color: theme.palette.text.primary,
...theme.typography.bodyShortRegular
},
link: {
color: `${theme.palette.text.primary} !important`,
fontWeight: 'bold',
textDecoration: 'underline'
}
};
});
/**
* Component that displays a message when the dial in limit is reached.
* * @param {Function} t - Function which translate strings.
*
* @returns {ReactElement}
*/
const DialInLimit: React.FC<WithTranslation> = ({ t }) => {
const { classes } = useStyles();
return (
<div className = { classes.limitContainer }>
<span className = { classes.limitInfo }>
<b>{ `${t('info.dialInNumber')} ` }</b>
{ `${t('info.reachedLimit')} `}
{ `${t('info.upgradeOptions')} ` }
<a
className = { classes.link }
href = { UPGRADE_OPTIONS_LINK }
rel = 'noopener noreferrer'
target = '_blank'>
{ `${UPGRADE_OPTIONS_TEXT}` }
</a>
.
</span>
</div>
);
};
export default translate(DialInLimit);

View File

@@ -0,0 +1,138 @@
import React, { useEffect, useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconCheck, IconCopy } from '../../../../base/icons/svg';
import Tooltip from '../../../../base/tooltip/components/Tooltip';
import { copyText } from '../../../../base/util/copyText.web';
import { showSuccessNotification } from '../../../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../../notifications/constants';
import { _formatConferenceIDPin } from '../../../_utils';
let mounted: boolean;
/**
* The type of the React {@code Component} props of {@link DialInNumber}.
*/
interface IProps extends WithTranslation {
/**
* The numeric identifier for the current conference, used after dialing a
* the number to join the conference.
*/
conferenceID: string | number;
/**
* The phone number to dial to begin the process of dialing into a
* conference.
*/
phoneNumber: string;
}
/**
* Component responsible for displaying a telephone number and
* conference ID for dialing into a conference and copying them to clipboard.
*
* @returns {ReactNode}
*/
function DialInNumber({ conferenceID, phoneNumber, t }: IProps) {
const dispatch = useDispatch();
const [ isClicked, setIsClicked ] = useState(false);
const dialInLabel = t('info.dialInNumber');
const passcode = t('info.dialInConferenceID');
const conferenceIDPin = `${_formatConferenceIDPin(conferenceID)}#`;
const textToCopy = `${dialInLabel} ${phoneNumber} ${passcode} ${conferenceIDPin}`;
useEffect(() => {
mounted = true;
return () => {
mounted = false;
};
}, []);
/**
* Copies the conference ID and phone number to the clipboard.
*
* @returns {void}
*/
function _onCopyText() {
copyText(textToCopy);
dispatch(showSuccessNotification({
titleKey: 'dialog.copied'
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
setIsClicked(true);
setTimeout(() => {
// avoid: Can't perform a React state update on an unmounted component
if (mounted) {
setIsClicked(false);
}
}, 2500);
}
/**
* Copies the conference invitation to the clipboard.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
function _onCopyTextKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
_onCopyText();
}
}
/**
* Renders section that shows the phone number and conference ID
* and give user the ability to copy them to the clipboard.
*
* @returns {ReactNode}
*/
return (
<div className = 'dial-in-number'>
<p>
<span className = 'phone-number'>
<span className = 'info-label'>
{ t('info.dialInNumber') }
</span>
<span className = 'spacer'>&nbsp;</span>
<span className = 'info-value'>
{ phoneNumber }
</span>
</span>
<br />
<span className = 'conference-id'>
<span className = 'info-label'>
{ t('info.dialInConferenceID') }
</span>
<span className = 'spacer'>&nbsp;</span>
<span className = 'info-value'>
{ `${_formatConferenceIDPin(conferenceID)}#` }
</span>
</span>
</p>
<Tooltip
content = { t('info.copyNumber') }
position = 'top'>
<button
aria-label = { t('info.copyNumber') }
className = 'dial-in-copy invisible-button'
// eslint-disable-next-line react/jsx-no-bind
onClick = { _onCopyText }
// eslint-disable-next-line react/jsx-no-bind
onKeyPress = { _onCopyTextKeyPress }>
<Icon src = { isClicked ? IconCheck : IconCopy } />
</button>
</Tooltip>
</div>
);
}
export default translate(DialInNumber);

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../../app/types';
import { getDialInfoPageURL, hasMultipleNumbers } from '../../../functions';
import DialInNumber from './DialInNumber';
interface IProps {
/**
* The phone number to dial to begin the process of dialing into a
* conference.
*/
phoneNumber: string;
}
const useStyles = makeStyles()(theme => {
return {
container: {
'& .info-label': {
...theme.typography.bodyLongBold
}
},
link: {
...theme.typography.bodyLongRegular,
color: theme.palette.link01,
'&:hover': {
color: theme.palette.link01Hover
}
}
};
});
/**
* Returns a ReactElement for showing how to dial into the conference, if
* dialing in is available.
*
* @private
* @returns {null|ReactElement}
*/
function DialInSection({
phoneNumber
}: IProps) {
const { classes, cx } = useStyles();
const conferenceID = useSelector((state: IReduxState) => state['features/invite'].conferenceID);
const dialInfoPageUrl: string = useSelector(getDialInfoPageURL);
const showMoreNumbers = useSelector((state: IReduxState) => hasMultipleNumbers(state['features/invite'].numbers));
const { t } = useTranslation();
return (
<div className = { classes.container }>
<DialInNumber
conferenceID = { conferenceID ?? '' }
phoneNumber = { phoneNumber } />
{showMoreNumbers ? <a
className = { cx('more-numbers', classes.link) }
href = { dialInfoPageUrl }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('info.moreNumbers') }
</a> : null}
</div>
);
}
export default DialInSection;

View File

@@ -0,0 +1,33 @@
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../analytics/functions';
import { translate } from '../../../../base/i18n/functions';
import { IconAddUser } from '../../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../../base/toolbox/components/AbstractButton';
import { beginAddPeople } from '../../../actions.any';
/**
* Implementation of a button for opening invite people dialog.
*/
class InviteButton extends AbstractButton<AbstractButtonProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.invite';
override icon = IconAddUser;
override label = 'toolbar.invite';
override tooltip = 'toolbar.invite';
/**
* Handles clicking / pressing the button, and opens the appropriate dialog.
*
* @protected
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
sendAnalytics(createToolbarEvent('invite'));
dispatch(beginAddPeople());
}
}
export default translate(connect()(InviteButton));

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { isIosMobileBrowser } from '../../../../base/environment/utils';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import {
IconCheck,
IconCopy,
IconEnvelope,
IconGoogle,
IconOffice365,
IconYahoo
} from '../../../../base/icons/svg';
import Tooltip from '../../../../base/tooltip/components/Tooltip';
import { copyText } from '../../../../base/util/copyText.web';
import { showSuccessNotification } from '../../../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../../notifications/constants';
let mounted: boolean;
interface IProps extends WithTranslation {
/**
* The encoded invitation subject.
*/
inviteSubject: string;
/**
* The encoded invitation text to be sent.
*/
inviteText: string;
/**
* The encoded no new-lines iOS invitation text to be sent on default mail.
*/
inviteTextiOS: string;
}
const useStyles = makeStyles()(theme => {
return {
container: {
marginTop: theme.spacing(4)
},
label: {
marginBottom: theme.spacing(2)
},
iconRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
iconContainer: {
display: 'block',
padding: theme.spacing(2),
cursor: 'pointer'
}
};
});
/**
* Component that renders email invite options.
*
* @returns {ReactNode}
*/
function InviteByEmailSection({ inviteSubject, inviteText, inviteTextiOS, t }: IProps) {
const dispatch = useDispatch();
const { classes } = useStyles();
const [ isClicked, setIsClicked ] = useState(false);
const encodedInviteSubject = encodeURIComponent(inviteSubject);
const encodedInviteText = encodeURIComponent(inviteText);
const encodedInviteTextiOS = encodeURIComponent(inviteTextiOS);
const encodedDefaultEmailText = isIosMobileBrowser() ? encodedInviteTextiOS : encodedInviteText;
useEffect(() => {
mounted = true;
return () => {
mounted = false;
};
}, []);
/**
* Copies the conference invitation to the clipboard.
*
* @returns {void}
*/
function _onCopyText() {
copyText(inviteText);
dispatch(showSuccessNotification({
titleKey: 'dialog.copied'
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
setIsClicked(true);
setTimeout(() => {
// avoid: Can't perform a React state update on an unmounted component
if (mounted) {
setIsClicked(false);
}
}, 2500);
}
/**
* Copies the conference invitation to the clipboard.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
function _onCopyTextKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
_onCopyText();
}
}
/**
* Renders clickable elements that each open an email client
* containing a conference invite.
*
* @returns {ReactNode}
*/
function renderEmailIcons() {
const PROVIDER_MAPPING = [
{
icon: IconEnvelope,
tooltipKey: 'addPeople.defaultEmail',
url: `mailto:?subject=${encodedInviteSubject}&body=${encodedDefaultEmailText}`
},
{
icon: IconGoogle,
tooltipKey: 'addPeople.googleEmail',
url: `https://mail.google.com/mail/?view=cm&fs=1&su=${encodedInviteSubject}&body=${encodedInviteText}`
},
{
icon: IconOffice365,
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) => (
<Tooltip
content = { t(tooltipKey) }
key = { idx }
position = 'top'>
<a
aria-label = { t(tooltipKey) }
className = { classes.iconContainer }
href = { url }
rel = 'noopener noreferrer'
target = '_blank'>
<Icon src = { icon } />
</a>
</Tooltip>
))
}
</>
);
}
return (
<>
<div className = { classes.container }>
<p className = { classes.label }>{t('addPeople.shareInvite')}</p>
<div className = { classes.iconRow }>
<Tooltip
content = { t('addPeople.copyInvite') }
position = 'top'>
<div
aria-label = { t('addPeople.copyInvite') }
className = { classes.iconContainer }
// eslint-disable-next-line react/jsx-no-bind
onClick = { _onCopyText }
// eslint-disable-next-line react/jsx-no-bind
onKeyPress = { _onCopyTextKeyPress }
role = 'button'
tabIndex = { 0 }>
<Icon src = { isClicked ? IconCheck : IconCopy } />
</div>
</Tooltip>
{renderEmailIcons()}
</div>
</div>
</>
);
}
export default translate(InviteByEmailSection);

View File

@@ -0,0 +1,517 @@
import { Theme } from '@mui/material';
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../../app/types';
import Avatar from '../../../../base/avatar/components/Avatar';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconEnvelope, IconPhoneRinging, IconUser } from '../../../../base/icons/svg';
import MultiSelectAutocomplete from '../../../../base/react/components/web/MultiSelectAutocomplete';
import Button from '../../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../../base/ui/constants.any';
import { isVpaasMeeting } from '../../../../jaas/functions';
import { hideAddPeopleDialog } from '../../../actions.web';
import { INVITE_TYPES } from '../../../constants';
import { IInviteSelectItem, IInvitee } from '../../../types';
import AbstractAddPeopleDialog, {
IProps as AbstractProps,
IState,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractAddPeopleDialog';
const styles = (theme: Theme) => {
return {
formWrap: {
marginTop: theme.spacing(2)
},
inviteButtons: {
display: 'flex',
justifyContent: 'end',
marginTop: theme.spacing(2),
'& .invite-button': {
marginLeft: theme.spacing(2)
}
}
};
};
interface IProps extends AbstractProps {
/**
* The {@link JitsiMeetConference} which will be used to invite "room" participants.
*/
_conference?: Object;
/**
* Whether the meeting belongs to JaaS user.
*/
_isVpaas?: boolean;
/**
* Css classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The redux {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
/**
* Invoked to obtain translated strings.
*/
t: Function;
}
/**
* Form that enables inviting others to the call.
*/
class InviteContactsForm extends AbstractAddPeopleDialog<IProps, IState> {
_multiselect: MultiSelectAutocomplete | null = null;
_resourceClient: {
makeQuery: (query: string) => Promise<Array<any>>;
parseResults: Function;
};
_translations: {
[key: string]: string;
_addPeopleEnabled: string;
_dialOutEnabled: string;
_sipInviteEnabled: string;
};
override state = {
addToCallError: false,
addToCallInProgress: false,
inviteItems: [] as IInviteSelectItem[]
};
/**
* Initializes a new {@code AddPeopleDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onClearItems = this._onClearItems.bind(this);
this._onClearItemsKeyPress = this._onClearItemsKeyPress.bind(this);
this._onItemSelected = this._onItemSelected.bind(this);
this._onSelectionChange = this._onSelectionChange.bind(this);
this._onSubmit = this._onSubmit.bind(this);
this._onSubmitKeyPress = this._onSubmitKeyPress.bind(this);
this._parseQueryResults = this._parseQueryResults.bind(this);
this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._resourceClient = {
makeQuery: this._query,
parseResults: this._parseQueryResults
};
const { t } = props;
this._translations = {
_dialOutEnabled: t('addPeople.phoneNumbers'),
_addPeopleEnabled: t('addPeople.contacts'),
_sipInviteEnabled: t('addPeople.sipAddresses')
};
}
/**
* React Component method that executes once component is updated.
*
* @param {Props} prevProps - The props object before the update.
* @param {State} prevState - The state object before the update.
* @returns {void}
*/
override componentDidUpdate(prevProps: IProps, prevState: IState) {
/**
* 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}
*/
override render() {
const {
_addPeopleEnabled,
_dialOutEnabled,
_isVpaas,
_sipInviteEnabled,
t
} = this.props;
const classes = withStyles.getClasses(this.props);
let isMultiSelectDisabled = this.state.addToCallInProgress;
const loadingMessage = 'addPeople.searching';
const noMatches = 'addPeople.noResults';
const features: { [key: string]: boolean; } = {
_dialOutEnabled,
_addPeopleEnabled,
_sipInviteEnabled
};
const computedPlaceholder = Object.keys(features)
.filter(v => Boolean(features[v]))
.map(v => this._translations[v])
.join(', ');
const placeholder = computedPlaceholder ? `${t('dialog.add')} ${computedPlaceholder}` : t('addPeople.disabled');
if (!computedPlaceholder) {
isMultiSelectDisabled = true;
}
return (
<div
className = { classes.formWrap }
onKeyDown = { this._onKeyDown }>
<MultiSelectAutocomplete
id = 'invite-contacts-input'
isDisabled = { isMultiSelectDisabled }
loadingMessage = { t(loadingMessage) }
noMatchesFound = { t(noMatches) }
onItemSelected = { this._onItemSelected }
onSelectionChange = { this._onSelectionChange }
placeholder = { placeholder }
ref = { this._setMultiSelectElement }
resourceClient = { this._resourceClient }
shouldFitContainer = { true }
shouldFocus = { true }
showSupportLink = { !_isVpaas } />
{ this._renderFormActions() }
</div>
);
}
/**
* Callback invoked when a selection has been made but before it has been
* set as selected.
*
* @param {IInviteSelectItem} item - The item that has just been selected.
* @private
* @returns {Object} The item to display as selected in the input.
*/
_onItemSelected(item: IInviteSelectItem) {
if (item.item.type === INVITE_TYPES.PHONE) {
item.content = item.item.number;
}
return item;
}
/**
* Handles a selection change.
*
* @param {Array<IInviteSelectItem>} selectedItems - The list of selected items.
* @private
* @returns {void}
*/
_onSelectionChange(selectedItems: IInviteSelectItem[]) {
this.setState({
inviteItems: selectedItems
});
}
/**
* Submits the selection for inviting.
*
* @private
* @returns {void}
*/
_onSubmit() {
const { inviteItems } = this.state;
const invitees = inviteItems.map(({ item }) => item);
this._invite(invitees)
.then((invitesLeftToSend: IInvitee[]) => {
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);
}
}
})
.finally(() => this.props.dispatch(hideAddPeopleDialog()));
}
/**
* KeyPress handler for accessibility.
*
* @param {KeyboardEvent} e - The key event to handle.
*
* @returns {void}
*/
_onSubmitKeyPress(e: React.KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this._onSubmit();
}
}
/**
* Handles 'Enter' key in the form to trigger the invite.
*
* @param {KeyboardEvent} event - The key event.
* @returns {void}
*/
_onKeyDown(event: React.KeyboardEvent) {
const { inviteItems } = this.state;
if (event.key === 'Enter') {
event.preventDefault();
if (!this._isAddDisabled() && inviteItems.length) {
this._onSubmit();
}
}
}
/**
* Returns the avatar component for a user.
*
* @param {any} user - The user.
* @param {string} className - The CSS class for the avatar component.
* @private
* @returns {ReactElement}
*/
_getAvatar(user: any, className = 'avatar-small') {
const defaultIcon = user.type === INVITE_TYPES.EMAIL ? IconEnvelope : IconUser;
return (
<Avatar
className = { className }
defaultIcon = { defaultIcon }
size = { 32 }
status = { user.status }
url = { user.avatar } />
);
}
/**
* 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: IInvitee[] = []) {
const { t, _dialOutEnabled } = this.props;
const userTypes = [ INVITE_TYPES.USER, INVITE_TYPES.EMAIL, INVITE_TYPES.VIDEO_ROOM, INVITE_TYPES.ROOM ];
const users = response.filter(item => userTypes.includes(item.type));
const userDisplayItems: any = [];
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: INVITE_TYPES.PHONE,
number: phone
},
tag: {
elemBefore: tagAvatar
},
value: phone
});
}
}
const numbers = response.filter(item => item.type === INVITE_TYPES.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
};
});
const sipAddresses = response.filter(item => item.type === INVITE_TYPES.SIP);
const sipDisplayItems = sipAddresses.map(sip => {
return {
filterValues: [
sip.address
],
content: sip.address,
description: '',
item: sip,
value: sip.address
};
});
return [
...userDisplayItems,
...numberDisplayItems,
...sipDisplayItems
];
}
/**
* Clears the selected items from state and form.
*
* @returns {void}
*/
_onClearItems() {
if (this._multiselect) {
this._multiselect.setSelectedItems([]);
}
this.setState({ inviteItems: [] });
}
/**
* Clears the selected items from state and form.
*
* @param {KeyboardEvent} e - The key event to handle.
*
* @returns {void}
*/
_onClearItemsKeyPress(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this._onClearItems();
}
}
/**
* Renders the add/cancel actions for the form.
*
* @returns {ReactElement|null}
*/
_renderFormActions() {
const { inviteItems } = this.state;
const { t } = this.props;
const classes = withStyles.getClasses(this.props);
if (!inviteItems.length) {
return null;
}
return (
<div className = { classes.inviteButtons }>
<Button
aria-label = { t('dialog.Cancel') }
className = 'invite-button'
label = { t('dialog.Cancel') }
onClick = { this._onClearItems }
onKeyPress = { this._onClearItemsKeyPress }
role = 'button'
type = { BUTTON_TYPES.SECONDARY } />
<Button
aria-label = { t('addPeople.add') }
className = 'invite-button'
disabled = { this._isAddDisabled() }
label = { t('addPeople.add') }
onClick = { this._onSubmit }
onKeyPress = { this._onSubmitKeyPress }
role = 'button' />
</div>
);
}
/**
* Renders a telephone icon.
*
* @private
* @returns {ReactElement}
*/
_renderTelephoneIcon() {
return (
<Icon src = { IconPhoneRinging } />
);
}
/**
* Sets the instance variable for the multi select component
* element so it can be accessed directly.
*
* @param {MultiSelectAutocomplete} element - The DOM element for the component's dialog.
* @private
* @returns {void}
*/
_setMultiSelectElement(element: MultiSelectAutocomplete) {
this._multiselect = element;
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code AddPeopleDialog}'s props.
*
* @param {IReduxState} state - The Redux state.
* @private
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
_isVpaas: isVpaasMeeting(state)
};
}
export default translate(connect(_mapStateToProps)(withStyles(InviteContactsForm, styles)));

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import InviteContactsForm from './InviteContactsForm';
/**
* Component that represents the invitation section of the {@code AddPeopleDialog}.
*
* @returns {ReactElement$<any>}
*/
function InviteContactsSection() {
const { t } = useTranslation();
return (
<>
<span>{t('addPeople.addContacts')}</span>
<InviteContactsForm />
<div className = 'invite-more-dialog separator' />
</>
);
}
export default InviteContactsSection;

View File

@@ -0,0 +1,110 @@
/* eslint-disable react/jsx-no-bind */
import React, { useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconCheck, IconCopy } from '../../../../base/icons/svg';
import { copyText } from '../../../../base/util/copyText.web';
interface IProps extends WithTranslation {
/**
* The current known URL for a live stream in progress.
*/
liveStreamViewURL: string;
}
/**
* Section of the {@code AddPeopleDialog} that renders the
* live streaming url, allowing a copy action.
*
* @returns {React$Element<any>}
*/
function LiveStreamSection({ liveStreamViewURL, t }: IProps) {
const [ isClicked, setIsClicked ] = useState(false);
const [ isHovered, setIsHovered ] = useState(false);
/**
* Click handler for the element.
*
* @returns {void}
*/
async function onClick() {
setIsHovered(false);
const isCopied = await copyText(liveStreamViewURL);
if (isCopied) {
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<any>}
*/
function renderLinkContent() {
if (isClicked) {
return (
<>
<div className = 'invite-more-dialog stream-text selected'>
{t('addPeople.linkCopied')}
</div>
<Icon src = { IconCheck } />
</>
);
}
return (
<>
<div className = 'invite-more-dialog stream-text'>
{isHovered ? t('addPeople.copyStream') : liveStreamViewURL}
</div>
<Icon src = { IconCopy } />
</>
);
}
return (
<>
<span>{t('addPeople.shareStream')}</span>
<div
className = { `invite-more-dialog stream${isClicked ? ' clicked' : ''}` }
onClick = { onClick }
onMouseOut = { onHoverOut }
onMouseOver = { onHoverIn }>
{ renderLinkContent() }
</div>
<div className = 'invite-more-dialog separator' />
</>
);
}
export default translate(LiveStreamSection);