This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import { createInviteDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { getMeetingRegion } from '../../../base/config/functions.any';
|
||||
import { showErrorNotification, showNotification } from '../../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
|
||||
import { INotificationProps } from '../../../notifications/types';
|
||||
import { invite } from '../../actions.any';
|
||||
import { INVITE_TYPES } from '../../constants';
|
||||
import {
|
||||
getInviteResultsForQuery,
|
||||
getInviteTypeCounts,
|
||||
isAddPeopleEnabled,
|
||||
isDialOutEnabled,
|
||||
isSipInviteEnabled
|
||||
} from '../../functions';
|
||||
import logger from '../../logger';
|
||||
import { IInviteSelectItem, IInvitee } from '../../types';
|
||||
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* Whether or not to show Add People functionality.
|
||||
*/
|
||||
_addPeopleEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The app id of the user.
|
||||
*/
|
||||
_appId: string;
|
||||
|
||||
/**
|
||||
* Whether or not call flows are enabled.
|
||||
*/
|
||||
_callFlowsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The URL for validating if a phone number can be called.
|
||||
*/
|
||||
_dialOutAuthUrl: string;
|
||||
|
||||
/**
|
||||
* Whether or not to show Dial Out functionality.
|
||||
*/
|
||||
_dialOutEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The URL for validating if an outbound destination is allowed.
|
||||
*/
|
||||
_dialOutRegionUrl: string;
|
||||
|
||||
/**
|
||||
* The JWT token.
|
||||
*/
|
||||
_jwt: string;
|
||||
|
||||
/**
|
||||
* The query types used when searching people.
|
||||
*/
|
||||
_peopleSearchQueryTypes: Array<string>;
|
||||
|
||||
/**
|
||||
* The localStorage key holding the alternative token for people directory.
|
||||
*/
|
||||
_peopleSearchTokenLocation: string;
|
||||
|
||||
/**
|
||||
* The URL pointing to the service allowing for people search.
|
||||
*/
|
||||
_peopleSearchUrl: string;
|
||||
|
||||
/**
|
||||
* The region where we connected to.
|
||||
*/
|
||||
_region: string;
|
||||
|
||||
/**
|
||||
* Whether or not to allow sip invites.
|
||||
*/
|
||||
_sipInviteEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
|
||||
/**
|
||||
* Indicating that an error occurred when adding people to the call.
|
||||
*/
|
||||
addToCallError: boolean;
|
||||
|
||||
/**
|
||||
* Indicating that we're currently adding the new people to the
|
||||
* call.
|
||||
*/
|
||||
addToCallInProgress: boolean;
|
||||
|
||||
/**
|
||||
* The list of invite items.
|
||||
*/
|
||||
inviteItems: Array<IInvitee | IInviteSelectItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements an abstract dialog to invite people to the conference.
|
||||
*/
|
||||
export default class AbstractAddPeopleDialog<P extends IProps, S extends IState> extends Component<P, S> {
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
this._query = this._query.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the notification display name for the invitee.
|
||||
*
|
||||
* @param {IInvitee} invitee - The invitee object.
|
||||
* @returns {string}
|
||||
*/
|
||||
_getDisplayName(invitee: IInvitee) {
|
||||
if (invitee.type === INVITE_TYPES.PHONE) {
|
||||
return invitee.number;
|
||||
}
|
||||
|
||||
if (invitee.type === INVITE_TYPES.SIP) {
|
||||
return invitee.address;
|
||||
}
|
||||
|
||||
return invitee.name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite people and numbers to the conference. The logic works by inviting
|
||||
* numbers, people/rooms, sip endpoints and videosipgw in parallel. All invitees are
|
||||
* stored in an array. As each invite succeeds, the invitee is removed
|
||||
* from the array. After all invites finish, close the modal if there are
|
||||
* no invites left to send. If any are left, that means an invite failed
|
||||
* and an error state should display.
|
||||
*
|
||||
* @param {Array<IInvitee>} invitees - The items to be invited.
|
||||
* @returns {Promise<Array<any>>}
|
||||
*/
|
||||
_invite(invitees: IInvitee[]) {
|
||||
const inviteTypeCounts = getInviteTypeCounts(invitees);
|
||||
|
||||
sendAnalytics(createInviteDialogEvent(
|
||||
'clicked', 'inviteButton', {
|
||||
...inviteTypeCounts,
|
||||
inviteAllowed: this._isAddDisabled()
|
||||
}));
|
||||
|
||||
if (this._isAddDisabled()) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
addToCallInProgress: true
|
||||
});
|
||||
|
||||
const { _callFlowsEnabled, dispatch } = this.props;
|
||||
|
||||
return dispatch(invite(invitees))
|
||||
.then((invitesLeftToSend: IInvitee[]) => {
|
||||
this.setState({
|
||||
addToCallInProgress: false
|
||||
});
|
||||
|
||||
// If any invites are left that means something failed to send
|
||||
// so treat it as an error.
|
||||
if (invitesLeftToSend.length) {
|
||||
const erroredInviteTypeCounts
|
||||
= getInviteTypeCounts(invitesLeftToSend);
|
||||
|
||||
logger.error(`${invitesLeftToSend.length} invites failed`,
|
||||
erroredInviteTypeCounts);
|
||||
|
||||
sendAnalytics(createInviteDialogEvent(
|
||||
'error', 'invite', {
|
||||
...erroredInviteTypeCounts
|
||||
}));
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'addPeople.failedToAdd'
|
||||
}));
|
||||
} else if (!_callFlowsEnabled) {
|
||||
const invitedCount = invitees.length;
|
||||
let notificationProps: INotificationProps | undefined;
|
||||
|
||||
if (invitedCount >= 3) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
name: this._getDisplayName(invitees[0]),
|
||||
count: `${invitedCount - 1}`
|
||||
},
|
||||
titleKey: 'notify.invitedThreePlusMembers'
|
||||
};
|
||||
} else if (invitedCount === 2) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
first: this._getDisplayName(invitees[0]),
|
||||
second: this._getDisplayName(invitees[1])
|
||||
},
|
||||
titleKey: 'notify.invitedTwoMembers'
|
||||
};
|
||||
} else if (invitedCount) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
name: this._getDisplayName(invitees[0])
|
||||
},
|
||||
titleKey: 'notify.invitedOneMember'
|
||||
};
|
||||
}
|
||||
|
||||
if (notificationProps) {
|
||||
dispatch(
|
||||
showNotification(notificationProps, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
}
|
||||
}
|
||||
|
||||
return invitesLeftToSend;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the Add button should be disabled.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - True to indicate that the Add button should
|
||||
* be disabled, false otherwise.
|
||||
*/
|
||||
_isAddDisabled() {
|
||||
return !this.state.inviteItems.length
|
||||
|| this.state.addToCallInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a people and phone number search request.
|
||||
*
|
||||
* @param {string} query - The search text.
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_query(query = '') {
|
||||
const {
|
||||
_addPeopleEnabled: addPeopleEnabled,
|
||||
_appId: appId,
|
||||
_dialOutAuthUrl: dialOutAuthUrl,
|
||||
_dialOutRegionUrl: dialOutRegionUrl,
|
||||
_dialOutEnabled: dialOutEnabled,
|
||||
_jwt: jwt,
|
||||
_peopleSearchQueryTypes: peopleSearchQueryTypes,
|
||||
_peopleSearchUrl: peopleSearchUrl,
|
||||
_peopleSearchTokenLocation: peopleSearchTokenLocation,
|
||||
_region: region,
|
||||
_sipInviteEnabled: sipInviteEnabled
|
||||
} = this.props;
|
||||
const options = {
|
||||
addPeopleEnabled,
|
||||
appId,
|
||||
dialOutAuthUrl,
|
||||
dialOutEnabled,
|
||||
dialOutRegionUrl,
|
||||
jwt,
|
||||
peopleSearchQueryTypes,
|
||||
peopleSearchUrl,
|
||||
peopleSearchTokenLocation,
|
||||
region,
|
||||
sipInviteEnabled
|
||||
};
|
||||
|
||||
return getInviteResultsForQuery(query, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _addPeopleEnabled: boolean,
|
||||
* _dialOutAuthUrl: string,
|
||||
* _dialOutEnabled: boolean,
|
||||
* _jwt: string,
|
||||
* _peopleSearchQueryTypes: Array<string>,
|
||||
* _peopleSearchUrl: string
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
callFlowsEnabled,
|
||||
dialOutAuthUrl,
|
||||
dialOutRegionUrl,
|
||||
peopleSearchQueryTypes,
|
||||
peopleSearchUrl,
|
||||
peopleSearchTokenLocation
|
||||
} = state['features/base/config'];
|
||||
|
||||
return {
|
||||
_addPeopleEnabled: isAddPeopleEnabled(state),
|
||||
_appId: state['features/base/jwt']?.tenant ?? '',
|
||||
_callFlowsEnabled: callFlowsEnabled ?? false,
|
||||
_dialOutAuthUrl: dialOutAuthUrl ?? '',
|
||||
_dialOutRegionUrl: dialOutRegionUrl ?? '',
|
||||
_dialOutEnabled: isDialOutEnabled(state),
|
||||
_jwt: state['features/base/jwt'].jwt ?? '',
|
||||
_peopleSearchQueryTypes: peopleSearchQueryTypes ?? [],
|
||||
_peopleSearchUrl: peopleSearchUrl ?? '',
|
||||
_peopleSearchTokenLocation: peopleSearchTokenLocation ?? '',
|
||||
_region: getMeetingRegion(state),
|
||||
_sipInviteEnabled: isSipInviteEnabled(state)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import { matchesProperty, sortBy } from 'lodash-es';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import AlertDialog from '../../../../base/dialog/components/native/AlertDialog';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloseCircle,
|
||||
IconEnvelope,
|
||||
IconPhoneRinging,
|
||||
IconSearch,
|
||||
IconShare
|
||||
} from '../../../../base/icons/svg';
|
||||
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
|
||||
import AvatarListItem from '../../../../base/react/components/native/AvatarListItem';
|
||||
import { Item } from '../../../../base/react/types';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
|
||||
import Input from '../../../../base/ui/components/native/Input';
|
||||
import HeaderNavigationButton
|
||||
from '../../../../mobile/navigation/components/HeaderNavigationButton';
|
||||
import { beginShareRoom } from '../../../../share-room/actions';
|
||||
import { INVITE_TYPES } from '../../../constants';
|
||||
import { IInviteSelectItem, IInvitee } from '../../../types';
|
||||
import AbstractAddPeopleDialog, {
|
||||
type IProps as AbstractProps,
|
||||
type IState as AbstractState,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractAddPeopleDialog';
|
||||
|
||||
import styles, { AVATAR_SIZE } from './styles';
|
||||
|
||||
interface IProps extends AbstractProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* True if the invite dialog should be open, false otherwise.
|
||||
*/
|
||||
_isVisible: boolean;
|
||||
|
||||
/**
|
||||
* Default prop for navigation between screen components(React Navigation).
|
||||
*/
|
||||
navigation: any;
|
||||
|
||||
/**
|
||||
* Theme used for styles.
|
||||
*/
|
||||
theme: Object;
|
||||
}
|
||||
|
||||
interface IState extends AbstractState {
|
||||
|
||||
/**
|
||||
* Boolean to show if an extra padding needs to be added to the bottom bar.
|
||||
*/
|
||||
bottomPadding: boolean;
|
||||
|
||||
/**
|
||||
* State variable to keep track of the search field value.
|
||||
*/
|
||||
fieldValue: string;
|
||||
|
||||
/**
|
||||
* True if a search is in progress, false otherwise.
|
||||
*/
|
||||
searchInprogress: boolean;
|
||||
|
||||
/**
|
||||
* An array of items that are selectable on this dialog. This is usually
|
||||
* populated by an async search.
|
||||
*/
|
||||
selectableItems: Array<Object>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a special dialog to invite people from a directory service.
|
||||
*/
|
||||
class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
|
||||
/**
|
||||
* Default state object to reset the state to when needed.
|
||||
*/
|
||||
defaultState = {
|
||||
addToCallError: false,
|
||||
addToCallInProgress: false,
|
||||
bottomPadding: false,
|
||||
fieldValue: '',
|
||||
inviteItems: [],
|
||||
searchInprogress: false,
|
||||
selectableItems: []
|
||||
};
|
||||
|
||||
/**
|
||||
* TimeoutID to delay the search for the time the user is probably typing.
|
||||
*/
|
||||
|
||||
/* eslint-disable-next-line no-undef */
|
||||
searchTimeout: number;
|
||||
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = this.defaultState;
|
||||
|
||||
this._keyExtractor = this._keyExtractor.bind(this);
|
||||
this._renderInvitedItem = this._renderInvitedItem.bind(this);
|
||||
this._renderItem = this._renderItem.bind(this);
|
||||
this._renderSeparator = this._renderSeparator.bind(this);
|
||||
this._onClearField = this._onClearField.bind(this);
|
||||
this._onInvite = this._onInvite.bind(this);
|
||||
this._onPressItem = this._onPressItem.bind(this);
|
||||
this._onShareMeeting = this._onShareMeeting.bind(this);
|
||||
this._onTypeQuery = this._onTypeQuery.bind(this);
|
||||
this._renderShareMeetingButton = this._renderShareMeetingButton.bind(this);
|
||||
this._renderIcon = this._renderIcon.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
disabled = { this._isAddDisabled() }
|
||||
label = { t('inviteDialog.send') }
|
||||
style = { styles.sendBtn }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#componentDidUpdate}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
disabled = { this._isAddDisabled() }
|
||||
label = { t('inviteDialog.send') }
|
||||
onPress = { this._onInvite }
|
||||
style = { styles.sendBtn }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
|
||||
if (prevProps._isVisible !== this.props._isVisible) {
|
||||
// Clear state
|
||||
this._clearState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_addPeopleEnabled,
|
||||
_dialOutEnabled
|
||||
} = this.props;
|
||||
const { inviteItems, selectableItems } = this.state;
|
||||
|
||||
let placeholderKey = 'searchPlaceholder';
|
||||
|
||||
if (!_addPeopleEnabled) {
|
||||
placeholderKey = 'searchCallOnlyPlaceholder';
|
||||
} else if (!_dialOutEnabled) {
|
||||
placeholderKey = 'searchPeopleOnlyPlaceholder';
|
||||
}
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
footerComponent = { this._renderShareMeetingButton }
|
||||
hasExtraHeaderHeight = { true }
|
||||
style = { styles.addPeopleContainer }>
|
||||
<Input
|
||||
autoFocus = { false }
|
||||
clearable = { true }
|
||||
customStyles = {{ container: styles.customContainer }}
|
||||
icon = { this._renderIcon }
|
||||
onChange = { this._onTypeQuery }
|
||||
placeholder = { this.props.t(`inviteDialog.${placeholderKey}`) }
|
||||
value = { this.state.fieldValue } />
|
||||
{ Boolean(inviteItems.length) && <View style = { styles.invitedList }>
|
||||
<FlatList
|
||||
data = { inviteItems }
|
||||
horizontal = { true }
|
||||
keyExtractor = { this._keyExtractor }
|
||||
renderItem = { this._renderInvitedItem as any } />
|
||||
</View> }
|
||||
<View style = { styles.resultList }>
|
||||
<FlatList
|
||||
ItemSeparatorComponent = { this._renderSeparator }
|
||||
data = { selectableItems }
|
||||
extraData = { inviteItems }
|
||||
keyExtractor = { this._keyExtractor }
|
||||
renderItem = { this._renderItem as any } />
|
||||
</View>
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the dialog content.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_clearState() {
|
||||
this.setState(this.defaultState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object capable of being rendered by an {@code AvatarListItem}.
|
||||
*
|
||||
* @param {Object} flatListItem - An item of the data array of the {@code FlatList}.
|
||||
* @returns {?Object}
|
||||
*/
|
||||
_getRenderableItem(flatListItem: any) {
|
||||
const { item } = flatListItem;
|
||||
|
||||
switch (item.type) {
|
||||
|
||||
// isCORSAvatarURL in this case is false
|
||||
case INVITE_TYPES.PHONE:
|
||||
return {
|
||||
avatar: IconPhoneRinging,
|
||||
key: item.number,
|
||||
title: item.number
|
||||
};
|
||||
case INVITE_TYPES.USER:
|
||||
return {
|
||||
avatar: item.avatar,
|
||||
key: item.id || item.user_id,
|
||||
title: item.name
|
||||
};
|
||||
case INVITE_TYPES.EMAIL:
|
||||
return {
|
||||
avatar: item.avatar || IconEnvelope,
|
||||
key: item.id || item.user_id,
|
||||
title: item.name
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key extractor for the flatlist.
|
||||
*
|
||||
* @param {Object} item - The flatlist item that we need the key to be
|
||||
* generated for.
|
||||
* @returns {string}
|
||||
*/
|
||||
_keyExtractor(item: any) {
|
||||
if (item.type === INVITE_TYPES.USER || item.type === INVITE_TYPES.EMAIL) {
|
||||
return item.id || item.user_id;
|
||||
}
|
||||
|
||||
return item.number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to clear the text field.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClearField() {
|
||||
this.setState({
|
||||
fieldValue: ''
|
||||
});
|
||||
|
||||
// Clear search results
|
||||
this._onTypeQuery('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites the selected entries.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onInvite() {
|
||||
// @ts-ignore
|
||||
this._invite(this.state.inviteItems)
|
||||
.then((invitesLeftToSend: IInvitee[]) => {
|
||||
if (invitesLeftToSend.length) {
|
||||
this.setState({
|
||||
inviteItems: invitesLeftToSend
|
||||
});
|
||||
this._showFailedInviteAlert();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to prepare a callback for the onPress event of the touchable.
|
||||
*
|
||||
* @param {Item} item - The item on which onPress was invoked.
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onPressItem(item: Item) {
|
||||
return () => {
|
||||
const { inviteItems } = this.state;
|
||||
const finderKey = item.type === INVITE_TYPES.PHONE ? 'number' : 'user_id';
|
||||
|
||||
if (inviteItems.find(
|
||||
matchesProperty(finderKey, item[finderKey as keyof typeof item]))) {
|
||||
// Item is already selected, need to unselect it.
|
||||
this.setState({
|
||||
inviteItems: inviteItems.filter(
|
||||
(element: any) => item[finderKey as keyof typeof item] !== element[finderKey])
|
||||
});
|
||||
} else {
|
||||
// Item is not selected yet, need to add to the list.
|
||||
// @ts-ignore
|
||||
const items = inviteItems.concat(item);
|
||||
|
||||
this.setState({
|
||||
inviteItems: sortBy(items, [ 'name', 'number' ])
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the system share sheet to share the meeting information.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onShareMeeting() {
|
||||
if (this.state.inviteItems.length > 0) {
|
||||
// The use probably intended to invite people.
|
||||
this._onInvite();
|
||||
} else {
|
||||
this.props.dispatch(beginShareRoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the typing event of the text field on the dialog and performs the
|
||||
* search.
|
||||
*
|
||||
* @param {string} query - The query that is typed in the field.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onTypeQuery(query: string) {
|
||||
this.setState({
|
||||
fieldValue: query
|
||||
});
|
||||
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.setState({
|
||||
searchInprogress: true
|
||||
}, () => {
|
||||
this._performSearch(query);
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual search.
|
||||
*
|
||||
* @param {string} query - The query to search for.
|
||||
* @returns {void}
|
||||
*/
|
||||
_performSearch(query: string) {
|
||||
this._query(query).then(results => {
|
||||
this.setState({
|
||||
selectableItems: sortBy(results, [ 'name', 'number' ])
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({
|
||||
searchInprogress: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single item in the invited {@code FlatList}.
|
||||
*
|
||||
* @param {Object} flatListItem - An item of the data array of the
|
||||
* {@code FlatList}.
|
||||
* @param {number} index - The index of the currently rendered item.
|
||||
* @returns {ReactElement<any>}
|
||||
*/
|
||||
_renderInvitedItem(flatListItem: any, index: number): ReactElement | null {
|
||||
const { item } = flatListItem;
|
||||
const renderableItem = this._getRenderableItem(flatListItem);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress = { this._onPressItem(item) } >
|
||||
<View
|
||||
pointerEvents = 'box-only'
|
||||
style = { styles.itemWrapper as ViewStyle }>
|
||||
<AvatarListItem
|
||||
avatarOnly = { true }
|
||||
avatarSize = { AVATAR_SIZE }
|
||||
avatarStatus = { item.status }
|
||||
avatarStyle = { styles.avatar }
|
||||
avatarTextStyle = { styles.avatarText }
|
||||
item = { renderableItem as any }
|
||||
key = { index }
|
||||
linesStyle = { styles.itemLinesStyle }
|
||||
titleStyle = { styles.itemText } />
|
||||
<Icon
|
||||
src = { IconCloseCircle }
|
||||
style = { styles.unselectIcon } />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single item in the search result {@code FlatList}.
|
||||
*
|
||||
* @param {Object} flatListItem - An item of the data array of the
|
||||
* {@code FlatList}.
|
||||
* @param {number} index - The index of the currently rendered item.
|
||||
* @returns {?ReactElement<*>}
|
||||
*/
|
||||
_renderItem(flatListItem: any, index: number): ReactElement | null {
|
||||
const { item } = flatListItem;
|
||||
const { inviteItems } = this.state;
|
||||
let selected: IInvitee | IInviteSelectItem | undefined | boolean = false;
|
||||
const renderableItem = this._getRenderableItem(flatListItem);
|
||||
|
||||
if (!renderableItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case INVITE_TYPES.PHONE:
|
||||
selected = inviteItems.find(matchesProperty('number', item.number));
|
||||
break;
|
||||
case INVITE_TYPES.USER:
|
||||
case INVITE_TYPES.EMAIL:
|
||||
selected = item.id
|
||||
? inviteItems.find(matchesProperty('id', item.id))
|
||||
: inviteItems.find(matchesProperty('user_id', item.user_id));
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress = { this._onPressItem(item) } >
|
||||
<View
|
||||
pointerEvents = 'box-only'
|
||||
style = { styles.itemWrapper as ViewStyle }>
|
||||
<AvatarListItem
|
||||
avatarSize = { AVATAR_SIZE }
|
||||
avatarStatus = { item.status }
|
||||
avatarStyle = { styles.avatar }
|
||||
avatarTextStyle = { styles.avatarText }
|
||||
item = { renderableItem as any }
|
||||
key = { index }
|
||||
linesStyle = { styles.itemLinesStyle }
|
||||
titleStyle = { styles.itemText } />
|
||||
{ selected && <Icon
|
||||
src = { IconCheck }
|
||||
style = { styles.selectedIcon } /> }
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the item separator.
|
||||
*
|
||||
* @returns {?ReactElement<*>}
|
||||
*/
|
||||
_renderSeparator() {
|
||||
return (
|
||||
<View style = { styles.separator } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a button to share the meeting info.
|
||||
*
|
||||
* @returns {React#Element<*>}
|
||||
*/
|
||||
_renderShareMeetingButton() {
|
||||
return (
|
||||
<SafeAreaView
|
||||
style = { [
|
||||
styles.bottomBar as ViewStyle,
|
||||
this.state.bottomPadding ? styles.extraBarPadding : null
|
||||
] }>
|
||||
<TouchableOpacity
|
||||
onPress = { this._onShareMeeting }>
|
||||
<Icon
|
||||
src = { IconShare }
|
||||
style = { styles.shareIcon } />
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an icon.
|
||||
*
|
||||
* @returns {React#Element<*>}
|
||||
*/
|
||||
_renderIcon() {
|
||||
if (this.state.searchInprogress) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
color = { BaseTheme.palette.icon01 }
|
||||
size = 'small' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
src = { IconSearch }
|
||||
style = { styles.searchIcon } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert telling the user that some invitees were failed to be
|
||||
* invited.
|
||||
*
|
||||
* NOTE: We're using an Alert here because we're on a modal and it makes
|
||||
* using our dialogs a tad more difficult.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_showFailedInviteAlert() {
|
||||
this.props.dispatch(openDialog(AlertDialog, {
|
||||
contentKey: {
|
||||
key: 'inviteDialog.alertText'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {any} _ownProps - Component's own props.
|
||||
* @returns {{
|
||||
* _isVisible: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AddPeopleDialog));
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BoxModel } from '../../../../base/styles/components/styles/BoxModel';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
export const AVATAR_SIZE = 40;
|
||||
export const DARK_GREY = 'rgb(28, 32, 37)';
|
||||
export const LIGHT_GREY = 'rgb(209, 219, 232)';
|
||||
|
||||
export default {
|
||||
|
||||
addPeopleContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
avatar: {
|
||||
backgroundColor: LIGHT_GREY
|
||||
},
|
||||
|
||||
customContainer: {
|
||||
marginHorizontal: BaseTheme.spacing[3],
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
avatarText: {
|
||||
color: DARK_GREY,
|
||||
fontSize: 12
|
||||
},
|
||||
|
||||
bottomBar: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
|
||||
clearButton: {
|
||||
paddingTop: 7
|
||||
},
|
||||
|
||||
clearIcon: {
|
||||
color: BaseTheme.palette.ui02,
|
||||
fontSize: 18,
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* A special padding to avoid issues on some devices (such as Android devices with custom suggestions bar).
|
||||
*/
|
||||
extraBarPadding: {
|
||||
paddingBottom: 30
|
||||
},
|
||||
|
||||
headerCloseIcon: {
|
||||
marginLeft: 12
|
||||
},
|
||||
|
||||
headerSendInvite: {
|
||||
color: BaseTheme.palette.text01,
|
||||
marginRight: 12
|
||||
},
|
||||
|
||||
invitedList: {
|
||||
padding: 3
|
||||
},
|
||||
|
||||
itemLinesStyle: {
|
||||
color: 'rgb(118, 136, 152)',
|
||||
fontSize: 13
|
||||
},
|
||||
|
||||
itemText: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
},
|
||||
|
||||
itemWrapper: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 5
|
||||
},
|
||||
|
||||
resultList: {
|
||||
flex: 1,
|
||||
padding: 5
|
||||
},
|
||||
|
||||
selectedIcon: {
|
||||
color: BaseTheme.palette.icon01,
|
||||
fontSize: 20,
|
||||
marginRight: BoxModel.margin,
|
||||
padding: 2
|
||||
},
|
||||
|
||||
separator: {
|
||||
borderBottomColor: BaseTheme.palette.ui07,
|
||||
borderBottomWidth: 1,
|
||||
marginLeft: 85
|
||||
},
|
||||
|
||||
searchIcon: {
|
||||
color: BaseTheme.palette.icon01,
|
||||
fontSize: 22
|
||||
},
|
||||
|
||||
shareIcon: {
|
||||
fontSize: 42
|
||||
},
|
||||
|
||||
unselectIcon: {
|
||||
color: BaseTheme.palette.ui01,
|
||||
fontSize: 16,
|
||||
left: AVATAR_SIZE / -3,
|
||||
position: 'relative',
|
||||
top: AVATAR_SIZE / -3
|
||||
},
|
||||
|
||||
sendBtn: {
|
||||
marginRight: BaseTheme.spacing[3]
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ phoneNumber }
|
||||
</span>
|
||||
</span>
|
||||
<br />
|
||||
<span className = 'conference-id'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInConferenceID') }
|
||||
</span>
|
||||
<span className = 'spacer'> </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);
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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)));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user