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,83 @@
/**
* Utility class with no dependencies. Used in components that are stripped in separate bundles
* and requires as less dependencies as possible.
*/
import { getURLWithoutParams } from '../base/connection/utils';
import { doGetJSON } from '../base/util/httpUtils';
/**
* Formats the conference pin in readable way for UI to display it.
* Formats the pin in 3 groups of digits:
* XXXX XXXX XX or XXXXX XXXXX XXX.
* The length of first and second group is Math.ceil(pin.length / 3).
*
* @param {Object} conferenceID - The conference id to format, string or number.
* @returns {string} - The formatted conference pin.
* @private
*/
export function _formatConferenceIDPin(conferenceID: Object) {
const conferenceIDStr = conferenceID.toString();
// let's split the conferenceID in 3 parts, to be easier to read
const partLen = Math.ceil(conferenceIDStr.length / 3);
return `${
conferenceIDStr.substring(0, partLen)} ${
conferenceIDStr.substring(partLen, 2 * partLen)} ${
conferenceIDStr.substring(2 * partLen, conferenceIDStr.length)}`;
}
/**
* Sends a GET request to obtain the conference ID necessary for identifying
* which conference to join after dialing the dial-in service.
* This function is used not only in the main app bundle but in separate bundles for the dial in numbers page,
* and we do want to limit the dependencies.
*
* @param {string} baseUrl - The url for obtaining the conference ID (pin) for
* dialing into a conference.
* @param {string} roomName - The conference name to find the associated
* conference ID.
* @param {string} mucURL - In which MUC the conference exists.
* @param {URL} url - The address we are loaded in.
* @returns {Promise} - The promise created by the request.
*/
export function getDialInConferenceID(
baseUrl: string,
roomName: string,
mucURL: string,
url: URL
): Promise<any> {
const separator = baseUrl.includes('?') ? '&' : '?';
const conferenceIDURL
= `${baseUrl}${separator}conference=${roomName}@${mucURL}&url=${getURLWithoutParams(url).href}`;
return doGetJSON(conferenceIDURL, true);
}
/**
* Sends a GET request for phone numbers used to dial into a conference.
* This function is used not only in the main app bundle but in separate bundles for the dial in numbers page,
* and we do want to limit the dependencies.
*
* @param {string} url - The service that returns conference dial-in numbers.
* @param {string} roomName - The conference name to find the associated
* conference ID.
* @param {string} mucURL - In which MUC the conference exists.
* @returns {Promise} - The promise created by the request. The returned numbers
* may be an array of Objects containing numbers, with keys countryCode,
* tollFree, formattedNumber or an object with countries as keys and arrays of
* phone number strings, as the second one should not be used and is deprecated.
*/
export function getDialInNumbers(
url: string,
roomName: string,
mucURL: string
): Promise<any> {
const separator = url.includes('?') ? '&' : '?';
// when roomName and mucURL are available
// provide conference when looking up dial in numbers
return doGetJSON(url + (roomName && mucURL ? `${separator}conference=${roomName}@${mucURL}` : ''), true);
}

View File

@@ -0,0 +1,72 @@
/**
* The type of redux action which will add pending invite request to the redux
* store.
*
* {
* type: ADD_PENDING_INVITE_REQUEST,
* request: Object
* }
*/
export const ADD_PENDING_INVITE_REQUEST = 'ADD_PENDING_INVITE_REQUEST';
/**
* The type of the (redux) action which signals that a click/tap has been
* performed on {@link InviteButton} and that the execution flow for
* adding/inviting people to the current conference/meeting is to begin.
*
* {
* type: BEGIN_ADD_PEOPLE
* }
*/
export const BEGIN_ADD_PEOPLE = 'BEGIN_ADD_PEOPLE';
/**
* The type of redux action which will remove pending invite requests from the
* redux store.
*
* {
* type: REMOVE_PENDING_INVITE_REQUESTS
* }
*/
export const REMOVE_PENDING_INVITE_REQUESTS
= 'REMOVE_PENDING_INVITE_REQUESTS';
/**
* The type of redux action which sets the visibility of {@code CalleeInfo}.
*
* {
* type: SET_CALLEE_INFO_VISIBLE,
* calleeInfoVisible: boolean
* }
*/
export const SET_CALLEE_INFO_VISIBLE = 'SET_CALLEE_INFO_VISIBLE';
/**
* The type of redux action to signal that the {@code AddPeopleDialog} should close.
*/
export const HIDE_ADD_PEOPLE_DIALOG = 'HIDE_ADD_PEOPLE_DIALOG';
/**
* The type of the action which signals an error occurred while requesting dial-
* in numbers.
*
* {
* type: UPDATE_DIAL_IN_NUMBERS_FAILED,
* error: Object
* }
*/
export const UPDATE_DIAL_IN_NUMBERS_FAILED
= 'UPDATE_DIAL_IN_NUMBERS_FAILED';
/**
* The type of the action which signals a request for dial-in numbers has
* succeeded.
*
* {
* type: UPDATE_DIAL_IN_NUMBERS_SUCCESS,
* conferenceID: Object,
* dialInNumbers: Object
* }
*/
export const UPDATE_DIAL_IN_NUMBERS_SUCCESS
= 'UPDATE_DIAL_IN_NUMBERS_SUCCESS';

View File

@@ -0,0 +1,297 @@
import { IStore } from '../app/types';
import { getInviteURL } from '../base/connection/functions';
import { getLocalParticipant, getParticipantCount } from '../base/participants/functions';
import { inviteVideoRooms } from '../videosipgw/actions';
import { getDialInConferenceID, getDialInNumbers } from './_utils';
import {
ADD_PENDING_INVITE_REQUEST,
BEGIN_ADD_PEOPLE,
HIDE_ADD_PEOPLE_DIALOG,
REMOVE_PENDING_INVITE_REQUESTS,
SET_CALLEE_INFO_VISIBLE,
UPDATE_DIAL_IN_NUMBERS_FAILED,
UPDATE_DIAL_IN_NUMBERS_SUCCESS
} from './actionTypes';
import { INVITE_TYPES } from './constants';
import {
invitePeopleAndChatRooms,
inviteSipEndpoints
} from './functions';
import logger from './logger';
import { IInvitee } from './types';
/**
* Creates a (redux) action to signal that a click/tap has been performed on
* {@link InviteButton} and that the execution flow for adding/inviting people
* to the current conference/meeting is to begin.
*
* @returns {{
* type: BEGIN_ADD_PEOPLE
* }}
*/
export function beginAddPeople() {
return {
type: BEGIN_ADD_PEOPLE
};
}
/**
* Creates a (redux) action to signal that the {@code AddPeopleDialog}
* should close.
*
* @returns {{
* type: HIDE_ADD_PEOPLE_DIALOG
* }}
*/
export function hideAddPeopleDialog() {
return {
type: HIDE_ADD_PEOPLE_DIALOG
};
}
/**
* Invites (i.e. Sends invites to) an array of invitees (which may be a
* combination of users, rooms, phone numbers, and video rooms.
*
* @param {Array<Object>} invitees - The recipients to send invites to.
* @param {Array<Object>} showCalleeInfo - Indicates whether the
* {@code CalleeInfo} should be displayed or not.
* @returns {Promise<Array<Object>>} A {@code Promise} resolving with an array
* of invitees who were not invited (i.e. Invites were not sent to them).
*/
export function invite(
invitees: IInvitee[],
showCalleeInfo = false) {
return (
dispatch: IStore['dispatch'],
getState: IStore['getState']): Promise<IInvitee[]> => {
const state = getState();
const participantsCount = getParticipantCount(state);
const { calleeInfoVisible } = state['features/invite'];
if (showCalleeInfo
&& !calleeInfoVisible
&& invitees.length === 1
&& invitees[0].type === INVITE_TYPES.USER
&& participantsCount === 1) {
dispatch(setCalleeInfoVisible(true, invitees[0]));
}
const { conference, password } = state['features/base/conference'];
if (typeof conference === 'undefined') {
// Only keep invitees which can get an invite request from Jitsi UI
const jitsiInvitees = invitees.filter(({ type }) => type !== INVITE_TYPES.EMAIL);
// Invite will fail before CONFERENCE_JOIN. The request will be
// cached in order to be executed on CONFERENCE_JOIN.
if (jitsiInvitees.length) {
return new Promise(resolve => {
dispatch(addPendingInviteRequest({
invitees: jitsiInvitees,
callback: (failedInvitees: any) => resolve(failedInvitees)
}));
});
}
}
let allInvitePromises: Promise<any>[] = [];
let invitesLeftToSend = [ ...invitees ];
const {
callFlowsEnabled,
inviteServiceUrl,
inviteServiceCallFlowsUrl
} = state['features/base/config'];
const inviteUrl = getInviteURL(state);
const { sipInviteUrl } = state['features/base/config'];
const { locationURL } = state['features/base/connection'];
const { jwt = '' } = state['features/base/jwt'];
const { name: displayName } = getLocalParticipant(state) ?? {};
// First create all promises for dialing out.
const phoneNumbers
= invitesLeftToSend.filter(({ type }) => type === INVITE_TYPES.PHONE);
// For each number, dial out. On success, remove the number from
// {@link invitesLeftToSend}.
const phoneInvitePromises = typeof conference === 'undefined'
? []
: phoneNumbers.map(item => {
const numberToInvite = item.number;
return conference.dial(numberToInvite)
.then(() => {
invitesLeftToSend
= invitesLeftToSend.filter(
invitee => invitee !== item);
})
.catch((error: Error) =>
logger.error('Error inviting phone number:', error));
});
allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
const usersAndRooms
= invitesLeftToSend.filter(
({ type }) => [ INVITE_TYPES.USER, INVITE_TYPES.EMAIL, INVITE_TYPES.ROOM ].includes(type));
if (usersAndRooms.length) {
// Send a request to invite all the rooms and users. On success,
// filter all rooms and users from {@link invitesLeftToSend}.
const peopleInvitePromise
= invitePeopleAndChatRooms(
(callFlowsEnabled
? inviteServiceCallFlowsUrl : inviteServiceUrl) ?? '',
inviteUrl,
usersAndRooms,
state)
.then(() => {
invitesLeftToSend
= invitesLeftToSend.filter(
({ type }) => ![ INVITE_TYPES.USER, INVITE_TYPES.EMAIL, INVITE_TYPES.ROOM ].includes(type));
})
.catch(error => {
dispatch(setCalleeInfoVisible(false));
logger.error('Error inviting people:', error);
});
allInvitePromises.push(peopleInvitePromise);
}
// Sipgw calls are fire and forget. Invite them to the conference, then
// immediately remove them from invitesLeftToSend.
const vrooms
= invitesLeftToSend.filter(({ type }) => type === INVITE_TYPES.VIDEO_ROOM);
conference
&& vrooms.length > 0
&& dispatch(inviteVideoRooms(conference, vrooms));
invitesLeftToSend
= invitesLeftToSend.filter(({ type }) => type !== INVITE_TYPES.VIDEO_ROOM);
const sipEndpoints
= invitesLeftToSend.filter(({ type }) => type === INVITE_TYPES.SIP);
conference && inviteSipEndpoints(
sipEndpoints,
// @ts-ignore
locationURL,
sipInviteUrl,
jwt,
conference.options.name,
password,
displayName
);
invitesLeftToSend
= invitesLeftToSend.filter(({ type }) => type !== INVITE_TYPES.SIP);
return (
Promise.all(allInvitePromises)
.then(() => invitesLeftToSend));
};
}
/**
* Sends AJAX requests for dial-in numbers and conference ID.
*
* @returns {Function}
*/
export function updateDialInNumbers() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { dialInConfCodeUrl, dialInNumbersUrl, hosts }
= state['features/base/config'];
const { numbersFetched } = state['features/invite'];
const mucURL = hosts?.muc;
if (numbersFetched || !dialInConfCodeUrl || !dialInNumbersUrl || !mucURL) {
// URLs for fetching dial in numbers not defined
return;
}
const { locationURL = {} } = state['features/base/connection'];
const { room = '' } = state['features/base/conference'];
Promise.all([
getDialInNumbers(dialInNumbersUrl, room, mucURL), // @ts-ignore
getDialInConferenceID(dialInConfCodeUrl, room, mucURL, locationURL)
])
.then(([ dialInNumbers, { conference, id, message, sipUri } ]) => {
if (!conference || !id) {
return Promise.reject(message);
}
dispatch({
type: UPDATE_DIAL_IN_NUMBERS_SUCCESS,
conferenceID: id,
dialInNumbers,
sipUri
});
})
.catch(error => {
dispatch({
type: UPDATE_DIAL_IN_NUMBERS_FAILED,
error
});
});
};
}
/**
* Sets the visibility of {@code CalleeInfo}.
*
* @param {boolean|undefined} [calleeInfoVisible] - If {@code CalleeInfo} is
* to be displayed/visible, then {@code true}; otherwise, {@code false} or
* {@code undefined}.
* @param {Object|undefined} [initialCalleeInfo] - Callee information.
* @returns {{
* type: SET_CALLEE_INFO_VISIBLE,
* calleeInfoVisible: (boolean|undefined),
* initialCalleeInfo
* }}
*/
export function setCalleeInfoVisible(
calleeInfoVisible: boolean,
initialCalleeInfo?: Object) {
return {
type: SET_CALLEE_INFO_VISIBLE,
calleeInfoVisible,
initialCalleeInfo
};
}
/**
* Adds pending invite request.
*
* @param {Object} request - The request.
* @returns {{
* type: ADD_PENDING_INVITE_REQUEST,
* request: Object
* }}
*/
export function addPendingInviteRequest(
request: { callback: Function; invitees: Array<Object>; }) {
return {
type: ADD_PENDING_INVITE_REQUEST,
request
};
}
/**
* Removes all pending invite requests.
*
* @returns {{
* type: REMOVE_PENDING_INVITE_REQUEST
* }}
*/
export function removePendingInviteRequests() {
return {
type: REMOVE_PENDING_INVITE_REQUESTS
};
}

View File

@@ -0,0 +1,25 @@
import { IStore } from '../app/types';
import { addPeopleFeatureControl } from '../base/participants/functions';
import { navigate } from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import { beginShareRoom } from '../share-room/actions';
export * from './actions.any';
/**
* Starts the process for inviting people. Depending on the system config it
* may use the system share sheet or the invite people dialog.
*
* @returns {Function}
*/
export function doInvitePeople() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
if (addPeopleFeatureControl(state)) {
return navigate(screen.conference.invite);
}
return dispatch(beginShareRoom());
};
}

View File

@@ -0,0 +1 @@
export * from './actions.any';

View File

@@ -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)
};
}

View File

@@ -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));

View File

@@ -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]
}
};

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);

View File

@@ -0,0 +1,167 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { MEDIA_TYPE } from '../../../base/media/constants';
import {
getParticipantDisplayName,
getParticipantPresenceStatus,
getRemoteParticipants
} from '../../../base/participants/functions';
import { Container, Text } from '../../../base/react/components/index';
import { isLocalTrackMuted } from '../../../base/tracks/functions.any';
import PresenceLabel from '../../../presence-status/components/PresenceLabel';
import { CALLING } from '../../../presence-status/constants';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link CalleeInfo}.
*/
interface IProps {
/**
* The callee's information such as display name.
*/
_callee?: {
id: string;
name: string;
status?: string;
};
_isVideoMuted: boolean;
}
/**
* Implements a React {@link Component} which depicts the establishment of a
* call with a specific remote callee.
*
* @augments Component
*/
class CalleeInfo extends Component<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
id,
name,
status = CALLING
} = this.props._callee ?? {};
const className = this.props._isVideoMuted ? 'solidBG' : '';
return (
<Container
{ ...this._style('ringing', className) }
id = 'ringOverlay'>
<Container
{ ...this._style('ringing__content') }>
<Avatar
{ ...this._style('ringing__avatar') }
participantId = { id } />
<Container { ...this._style('ringing__status') }>
<PresenceLabel
defaultPresence = { status }
{ ...this._style('ringing__text') } />
</Container>
<Container { ...this._style('ringing__name') }>
<Text
{ ...this._style('ringing__text') }>
{ name }
</Text>
</Container>
</Container>
</Container>
);
}
/**
* Attempts to convert specified CSS class names into React
* {@link Component} props {@code style} or {@code className}.
*
* @param {Array<string>} classNames - The CSS class names to convert
* into React {@code Component} props {@code style} or {@code className}.
* @returns {{
* className: string,
* style: Object
* }}
*/
_style(...classNames: Array<string | undefined>) {
let className = '';
let style: Object = {};
for (const aClassName of classNames) {
if (aClassName) {
// Attempt to convert aClassName into style.
if (styles && aClassName in styles) {
// React Native will accept an Array as the value of the
// style prop. However, I do not know about React.
style = {
...style, // @ts-ignore
...styles[aClassName]
};
} else {
// Otherwise, leave it as className.
className += `${aClassName} `;
}
}
}
// Choose which of the className and/or style props has a value and,
// consequently, must be returned.
const props = {
className: '',
style: {}
};
if (className) {
props.className = className.trim();
}
if (style) {
props.style = style;
}
return props;
}
}
/**
* Maps (parts of) the redux state to {@code CalleeInfo}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _callee: Object
* }}
*/
function _mapStateToProps(state: IReduxState) {
const _isVideoMuted
= isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
// This would be expensive for big calls but the component will be mounted only when there are up
// to 3 participants in the call.
for (const [ id, p ] of getRemoteParticipants(state)) {
if (p.botType === 'poltergeist') {
return {
_callee: {
id,
name: getParticipantDisplayName(state, id),
status: getParticipantPresenceStatus(state, id)
},
_isVideoMuted
};
}
}
return {
_callee: state['features/invite'].initialCalleeInfo,
_isVideoMuted
};
}
export default connect(_mapStateToProps)(CalleeInfo);

View File

@@ -0,0 +1,64 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import CalleeInfo from './CalleeInfo';
/**
* The type of the React {@code Component} props of {@code CalleeInfoContainer}.
*/
interface IProps {
/**
* The indicator which determines whether {@code CalleeInfo} is to be
* rendered.
*
* @private
*/
_calleeInfoVisible: boolean;
}
/**
* Implements a React {@link Component} which depicts the establishment of a
* call with a specific remote callee if there is such a remote callee.
*
* @augments Component
*/
class CalleeInfoContainer extends Component<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return this.props._calleeInfoVisible ? <CalleeInfo /> : null;
}
}
/**
* Maps parts of the redux state to {@link CalleeInfoContainer} (React
* {@code Component}) props.
*
* @param {Object} state - The redux state of which parts are to be mapped to
* {@code CalleeInfoContainer} props.
* @private
* @returns {{
* _calleeInfoVisible: boolean
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
/**
* The indicator which determines whether {@code CalleeInfo} is to be
* rendered.
*
* @private
* @type {boolean}
*/
_calleeInfoVisible: Boolean(state['features/invite'].calleeInfoVisible)
};
}
export default connect(_mapStateToProps)(CalleeInfoContainer);

View File

@@ -0,0 +1,50 @@
import { StyleSheet } from 'react-native';
import { ColorPalette } from '../../../base/styles/components/styles/ColorPalette';
import { createStyleSheet } from '../../../base/styles/functions.native';
export default createStyleSheet({
// XXX The names below were preserved for the purposes of compatibility
// with the existing CSS class names on Web.
/**
* The style of {@code CalleeInfo}.
*/
ringing: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
backgroundColor: ColorPalette.black,
flex: 0,
flexDirection: 'column',
justifyContent: 'center',
opacity: 0.8
},
'ringing__avatar': {
borderRadius: 50,
flex: 0,
height: 100,
width: 100
},
'ringing__caller-info': {
alignItems: 'center',
flex: 0,
flexDirection: 'row',
justifyContent: 'center'
},
'ringing__content': {
alignItems: 'center',
flex: 0,
flexDirection: 'column',
justifyContent: 'center'
},
/**
* The style of {@code Text} within {@code CalleeInfo}.
*/
'ringing__text': {
color: ColorPalette.white
}
});

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { isMobileBrowser } from '../../../base/environment/utils';
import i18next from '../../../base/i18n/i18next';
import { parseURLParams } from '../../../base/util/parseURLParams';
import { DIAL_IN_INFO_PAGE_PATH_NAME } from '../../constants';
import DialInSummary from '../dial-in-summary/web/DialInSummary';
import NoRoomError from './NoRoomError.web';
/**
* TODO: This seems unused, so we can drop it.
*/
document.addEventListener('DOMContentLoaded', () => {
// @ts-ignore
const { room } = parseURLParams(window.location, true, 'search');
const { href } = window.location;
const ix = href.indexOf(DIAL_IN_INFO_PAGE_PATH_NAME);
const url = (ix > 0 ? href.substring(0, ix) : href) + room;
/* eslint-disable-next-line react/no-deprecated */
ReactDOM.render(
<I18nextProvider i18n = { i18next }>
{ room
? <DialInSummary
className = 'dial-in-page'
clickableNumbers = { isMobileBrowser() }
room = { decodeURIComponent(room) }
url = { url } />
: <NoRoomError className = 'dial-in-page' /> }
</I18nextProvider>,
document.getElementById('react')
);
});
window.addEventListener('beforeunload', () => {
/* eslint-disable-next-line react/no-deprecated */
ReactDOM.unmountComponentAtNode(document.getElementById('react')!);
});

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
/**
* The type of the React {@code Component} props of {@link NoRoomError}.
*/
interface IProps {
/**
* Additional CSS classnames to append to the root of the component.
*/
className: string;
}
const NoRoomError = ({ className }: IProps) => {
const { t } = useTranslation();
return (
<div className = { className } >
<div>{t('info.noNumbers')}</div>
<div>{t('info.noRoom')}</div>
</div>
);
};
export default NoRoomError;

View File

@@ -0,0 +1,137 @@
import { Route } from '@react-navigation/native';
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { Linking, View, ViewStyle } from 'react-native';
import { WebView } from 'react-native-webview';
import { connect } from 'react-redux';
import { IStore } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { translate } from '../../../../base/i18n/functions';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../../base/react/components/native/LoadingIndicator';
import { getDialInfoPageURLForURIString } from '../../../functions';
import DialInSummaryErrorDialog from './DialInSummaryErrorDialog';
import styles, { INDICATOR_COLOR } from './styles';
interface IProps extends WithTranslation {
dispatch: IStore['dispatch'];
/**
* Default prop for navigating between screen components(React Navigation).
*/
navigation: any;
/**
* Default prop for navigating between screen components(React Navigation).
*/
route: Route<'', { summaryUrl: string; }>;
}
/**
* Implements a React native component that displays the dial in info page for a specific room.
*/
class DialInSummary extends PureComponent<IProps> {
/**
* Initializes a new instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onError = this._onError.bind(this);
this._onNavigate = this._onNavigate.bind(this);
this._renderLoading = this._renderLoading.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after mounting occurs.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { navigation, t } = this.props;
navigation.setOptions({
headerTitle: t('dialIn.screenTitle')
});
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { route } = this.props;
const summaryUrl = route.params?.summaryUrl;
return (
<JitsiScreen
style = { styles.backDrop }>
<WebView
incognito = { true }
onError = { this._onError }
onShouldStartLoadWithRequest = { this._onNavigate }
renderLoading = { this._renderLoading }
setSupportMultipleWindows = { false }
source = {{ uri: getDialInfoPageURLForURIString(summaryUrl) ?? '' }}
startInLoadingState = { true }
style = { styles.webView }
webviewDebuggingEnabled = { true } />
</JitsiScreen>
);
}
/**
* Callback to handle the error if the page fails to load.
*
* @returns {void}
*/
_onError() {
this.props.dispatch(openDialog(DialInSummaryErrorDialog));
}
/**
* Callback to intercept navigation inside the webview and make the native app handle the dial requests.
*
* NOTE: We don't navigate to anywhere else form that view.
*
* @param {any} request - The request object.
* @returns {boolean}
*/
_onNavigate(request: { url: string; }) {
const { url } = request;
const { route } = this.props;
const summaryUrl = route.params?.summaryUrl;
if (url.startsWith('tel:')) {
Linking.openURL(url);
}
return url === getDialInfoPageURLForURIString(summaryUrl);
}
/**
* Renders the loading indicator.
*
* @returns {React$Component<any>}
*/
_renderLoading() {
return (
<View style = { styles.indicatorWrapper as ViewStyle }>
<LoadingIndicator
color = { INDICATOR_COLOR }
size = 'large' />
</View>
);
}
}
export default translate(connect()(DialInSummary));

View File

@@ -0,0 +1,26 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import AlertDialog from '../../../../base/dialog/components/native/AlertDialog';
import { translate } from '../../../../base/i18n/functions';
/**
* Dialog to inform the user that we couldn't fetch the dial-in info page.
*/
class DialInSummaryErrorDialog extends Component<WithTranslation> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<AlertDialog
contentKey = 'info.dialInSummaryError' />
);
}
}
export default translate(connect()(DialInSummaryErrorDialog));

View File

@@ -0,0 +1,25 @@
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
export const INDICATOR_COLOR = BaseTheme.palette.ui07;
const WV_BACKGROUND = BaseTheme.palette.ui03;
export default {
backDrop: {
backgroundColor: WV_BACKGROUND,
flex: 1
},
indicatorWrapper: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui10,
height: '100%',
justifyContent: 'center'
},
webView: {
backgroundColor: WV_BACKGROUND,
flex: 1
}
};

View File

@@ -0,0 +1,78 @@
import { Theme } from '@mui/material';
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import { _formatConferenceIDPin } from '../../../_utils';
interface IProps extends WithTranslation {
/**
* The conference id.
*/
conferenceID?: string | number;
/**
* The conference name.
*/
conferenceName: string;
}
const useStyles = makeStyles()((theme: Theme) => {
return {
container: {
marginTop: 32,
maxWidth: 310,
padding: '16px 12px',
background: theme.palette.ui02,
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
borderRadius: 6,
'& *': {
userSelect: 'text'
}
},
confNameLabel: {
...theme.typography.heading6,
marginBottom: 18,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
},
descriptionLabel: {
...theme.typography.bodyShortRegularLarge,
marginBottom: 18
},
separator: {
width: '100%',
height: 1,
background: theme.palette.ui04,
marginBottom: 18
},
pinLabel: {
...theme.typography.heading6
}
};
});
const ConferenceID: React.FC<IProps> = ({ conferenceID, t }) => {
const { classes: styles } = useStyles();
return (
<div className = { styles.container }>
<div className = { styles.descriptionLabel }>
{ t('info.dialANumber') }
</div>
<div className = { styles.separator } />
<div className = { styles.pinLabel }>
{ `${t('info.dialInConferenceID')} ${_formatConferenceIDPin(conferenceID ?? '')}` }
</div>
</div>
);
};
export default translate(ConferenceID);

View File

@@ -0,0 +1,312 @@
import { Theme } from '@mui/material';
import clsx from 'clsx';
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { withStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import { getDialInConferenceID, getDialInNumbers } from '../../../_utils';
import ConferenceID from './ConferenceID';
import NumbersList from './NumbersList';
/**
* The type of the React {@code Component} props of {@link DialInSummary}.
*/
interface IProps extends WithTranslation {
/**
* Additional CSS classnames to append to the root of the component.
*/
className: string;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* Whether or not numbers should include links with the telephone protocol.
*/
clickableNumbers: boolean;
/**
* Whether to hide the error.
*/
hideError?: boolean;
/**
* The name of the conference to show a conferenceID for.
*/
room: string;
/**
* Whether the dial in summary container is scrollable.
*/
scrollable?: boolean;
/**
* Whether the room name should show as title.
*/
showTitle?: boolean;
/**
* The url where we were loaded.
*/
url: any;
}
/**
* The type of the React {@code Component} state of {@link DialInSummary}.
*/
type State = {
/**
* The numeric ID of the conference, used as a pin when dialing in.
*/
conferenceID: string | null;
/**
* An error message to display.
*/
error: string;
/**
* Whether or not the app is fetching data.
*/
loading: boolean;
/**
* The dial-in numbers to be displayed.
*/
numbers: Array<Object> | Object | null;
/**
* Whether or not dial-in is allowed.
*/
numbersEnabled: boolean | null;
};
const styles = (theme: Theme) => {
return {
hasNumbers: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column' as const,
background: '#1E1E1E',
color: theme.palette.text01
},
scrollable: {
height: '100dvh',
overflowY: 'scroll' as const
},
roomName: {
margin: '40px auto 8px',
...theme.typography.heading5
}
};
};
/**
* Displays a page listing numbers for dialing into a conference and pin to
* the a specific conference.
*
* @augments Component
*/
class DialInSummary extends Component<IProps, State> {
override state = {
conferenceID: null,
error: '',
loading: true,
numbers: null,
numbersEnabled: null
};
/**
* Initializes a new {@code DialInSummary} 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 for every instance.
this._onGetNumbersSuccess = this._onGetNumbersSuccess.bind(this);
this._onGetConferenceIDSuccess
= this._onGetConferenceIDSuccess.bind(this);
this._setErrorMessage = this._setErrorMessage.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const getNumbers = this._getNumbers()
.then(this._onGetNumbersSuccess)
.catch(this._setErrorMessage);
const getID = this._getConferenceID()
.then(this._onGetConferenceIDSuccess)
.catch(this._setErrorMessage);
Promise.all([ getNumbers, getID ])
.then(() => {
this.setState({ loading: false });
});
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
let className = '';
let contents;
const { conferenceID, error, loading, numbersEnabled } = this.state;
const { hideError, showTitle, room, clickableNumbers, scrollable, t } = this.props;
const classes = withStyles.getClasses(this.props);
if (loading) {
contents = '';
} else if (numbersEnabled === false) {
contents = t('info.dialInNotSupported');
} else if (error) {
if (!hideError) {
contents = error;
}
} else {
className = clsx(classes.hasNumbers, scrollable && classes.scrollable);
contents = [
conferenceID
? <>
{ showTitle && <div className = { classes.roomName }>{ room }</div> }
<ConferenceID
conferenceID = { conferenceID }
conferenceName = { room }
key = 'conferenceID' />
</> : null,
<NumbersList
clickableNumbers = { clickableNumbers }
conferenceID = { conferenceID }
key = 'numbers'
numbers = { this.state.numbers } />
];
}
return (
<div className = { className }>
{ contents }
</div>
);
}
/**
* Creates an AJAX request for the conference ID.
*
* @private
* @returns {Promise}
*/
_getConferenceID() {
const { room } = this.props;
const { dialInConfCodeUrl, hosts } = config;
const mucURL = hosts?.muc;
if (!dialInConfCodeUrl || !mucURL || !room) {
return Promise.resolve();
}
let url = this.props.url || {};
if (typeof url === 'string' || url instanceof String) {
// @ts-ignore
url = new URL(url);
}
return getDialInConferenceID(dialInConfCodeUrl, room, mucURL, url)
.catch(() => Promise.reject(this.props.t('info.genericError')));
}
/**
* Creates an AJAX request for dial-in numbers.
*
* @private
* @returns {Promise}
*/
_getNumbers() {
const { room } = this.props;
const { dialInNumbersUrl, hosts } = config;
const mucURL = hosts?.muc;
if (!dialInNumbersUrl) {
return Promise.reject(this.props.t('info.dialInNotSupported'));
}
return getDialInNumbers(dialInNumbersUrl, room, mucURL ?? '')
.catch(() => Promise.reject(this.props.t('info.genericError')));
}
/**
* Callback invoked when fetching the conference ID succeeds.
*
* @param {Object} response - The response from fetching the conference ID.
* @private
* @returns {void}
*/
_onGetConferenceIDSuccess(response = { conference: undefined,
id: undefined }) {
const { conference, id } = response;
if (!conference || !id) {
return;
}
this.setState({ conferenceID: id });
}
/**
* Callback invoked when fetching dial-in numbers succeeds. Sets the
* internal to show the numbers.
*
* @param {Array|Object} response - The response from fetching
* dial-in numbers.
* @param {Array|Object} response.numbers - The dial-in numbers.
* @param {boolean} response.numbersEnabled - Whether or not dial-in is
* enabled, old syntax that is deprecated.
* @private
* @returns {void}
*/
_onGetNumbersSuccess(
response: Array<Object> | { numbersEnabled?: boolean; }) {
this.setState({
numbersEnabled:
Boolean(Array.isArray(response)
? response.length > 0 : response.numbersEnabled),
numbers: response
});
}
/**
* Sets an error message to display on the page instead of content.
*
* @param {string} error - The error message to display.
* @private
* @returns {void}
*/
_setErrorMessage(error: string) {
this.setState({
error
});
}
}
export default translate(withStyles(DialInSummary, styles));

View File

@@ -0,0 +1,71 @@
import React, { ComponentType } from 'react';
import BaseApp from '../../../../base/app/components/BaseApp';
import { isMobileBrowser } from '../../../../base/environment/utils';
import GlobalStyles from '../../../../base/ui/components/GlobalStyles.web';
import JitsiThemeProvider from '../../../../base/ui/components/JitsiThemeProvider.web';
import { parseURLParams } from '../../../../base/util/parseURLParams';
import { DIAL_IN_INFO_PAGE_PATH_NAME } from '../../../constants';
import NoRoomError from '../../dial-in-info-page/NoRoomError.web';
import DialInSummary from './DialInSummary';
/**
* Wrapper application for prejoin.
*
* @augments BaseApp
*/
export default class DialInSummaryApp extends BaseApp<any> {
/**
* Navigates to {@link Prejoin} upon mount.
*
* @returns {void}
*/
override async componentDidMount() {
await super.componentDidMount();
// @ts-ignore
const { room } = parseURLParams(window.location, true, 'search');
const { href } = window.location;
const ix = href.indexOf(DIAL_IN_INFO_PAGE_PATH_NAME);
const url = (ix > 0 ? href.substring(0, ix) : href) + room;
super._navigate({
component: () => (<>
{room
? <DialInSummary
className = 'dial-in-page'
clickableNumbers = { isMobileBrowser() }
room = { decodeURIComponent(room) }
scrollable = { true }
showTitle = { true }
url = { url } />
: <NoRoomError className = 'dial-in-page' />}
</>)
});
}
/**
* Overrides the parent method to inject {@link AtlasKitThemeProvider} as
* the top most component.
*
* @override
*/
override _createMainElement(component: ComponentType<any>, props: Object) {
return (
<JitsiThemeProvider>
<GlobalStyles />
{super._createMainElement(component, props)}
</JitsiThemeProvider>
);
}
/**
* Renders the platform specific dialog container.
*
* @returns {React$Element}
*/
override _renderDialogContainer() {
return null;
}
}

View File

@@ -0,0 +1,210 @@
import countries from 'i18n-iso-countries';
import en from 'i18n-iso-countries/langs/en.json';
import React, { useCallback, useMemo } from 'react';
import { WithTranslation } from 'react-i18next';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconSip } from '../../../../base/icons/svg';
countries.registerLocale(en);
interface INormalizedNumber {
/**
* The country code.
*/
countryCode?: string;
/**
* The formatted number.
*/
formattedNumber: string;
/**
* Whether the number is toll-free.
*/
tollFree?: boolean;
}
interface INumbersMapping {
[countryName: string]: Array<INormalizedNumber>;
}
interface IProps extends WithTranslation {
/**
* Whether or not numbers should include links with the telephone protocol.
*/
clickableNumbers: boolean;
/**
* The conference ID for dialing in.
*/
conferenceID: number | null;
/**
* The phone numbers to display. Can be an array of number Objects or an
* object with countries as keys and an array of numbers as values.
*/
numbers: INumbersMapping | null;
}
const NumbersList: React.FC<IProps> = ({ t, conferenceID, clickableNumbers, numbers: numbersMapping }) => {
const renderFlag = useCallback((countryCode: string) => {
if (countryCode) {
return (
<td className = 'flag-cell'>
{countryCode === 'SIP' || countryCode === 'SIP_AUDIO_ONLY'
? <Icon src = { IconSip } />
: <i className = { `flag iti-flag ${countryCode}` } />
}
</td>);
}
return null;
}, []);
const renderNumberLink = useCallback((number: string) => {
if (clickableNumbers) {
// Url encode # to %23, Android phone was cutting the # after
// clicking it.
// Seems that using ',' and '%23' works on iOS and Android.
return (
<a
href = { `tel:${number},${conferenceID}%23` }
key = { number } >
{number}
</a>
);
}
return number;
}, [ conferenceID, clickableNumbers ]);
const renderNumbersList = useCallback((numbers: Array<INormalizedNumber>) => {
const numbersListItems = numbers.map(number =>
(<li
className = 'dial-in-number'
key = { number.formattedNumber }>
{renderNumberLink(number.formattedNumber)}
</li>));
return (
<ul className = 'numbers-list'>
{numbersListItems}
</ul>
);
}, []);
const renderNumbersTollFreeList = useCallback((numbers: Array<INormalizedNumber>) => {
const tollNumbersListItems = numbers.map(number =>
(<li
className = 'toll-free'
key = { number.formattedNumber }>
{number.tollFree ? t('info.dialInTollFree') : ''}
</li>));
return (
<ul className = 'toll-free-list'>
{tollNumbersListItems}
</ul>
);
}, []);
const renderNumbers = useMemo(() => {
let numbers: INumbersMapping;
if (!numbersMapping) {
return;
}
if (Array.isArray(numbersMapping)) {
numbers = numbersMapping.reduce(
(resultNumbers: any, number: any) => {
// The i18n-iso-countries package insists on upper case.
const countryCode = number.countryCode.toUpperCase();
let countryName;
if (countryCode === 'SIP') {
countryName = t('info.sip');
} else if (countryCode === 'SIP_AUDIO_ONLY') {
countryName = t('info.sipAudioOnly');
} else {
countryName = t(`countries:countries.${countryCode}`);
// Some countries have multiple names as US ['United States of America', 'USA']
// choose the first one if that is the case
if (!countryName) {
countryName = t(`countries:countries.${countryCode}.0`);
}
}
if (resultNumbers[countryName]) {
resultNumbers[countryName].push(number);
} else {
resultNumbers[countryName] = [ number ];
}
return resultNumbers;
}, {});
} else {
numbers = {};
for (const [ country, numbersArray ]
of Object.entries(numbersMapping.numbers)) {
if (Array.isArray(numbersArray)) {
/* eslint-disable arrow-body-style */
const formattedNumbers = numbersArray.map(number => ({
formattedNumber: number
}));
/* eslint-enable arrow-body-style */
numbers[country] = formattedNumbers;
}
}
}
const rows: [JSX.Element] = [] as unknown as [JSX.Element];
Object.keys(numbers).forEach((countryName: string) => {
const numbersArray: Array<INormalizedNumber> = numbers[countryName];
const countryCode = numbersArray[0].countryCode
|| countries.getAlpha2Code(countryName, 'en')?.toUpperCase()
|| countryName;
rows.push(
<>
<tr
key = { countryName }>
{renderFlag(countryCode)}
<td className = 'country' >{countryName}</td>
</tr>
<tr>
<td />
<td className = 'numbers-list-column'>
{renderNumbersList(numbersArray)}
</td>
<td className = 'toll-free-list-column' >
{renderNumbersTollFreeList(numbersArray)}
</td>
</tr>
</>
);
});
return rows;
}, [ numbersMapping ]);
return (
<table className = 'dial-in-numbers-list'>
<tbody className = 'dial-in-numbers-body'>
{renderNumbers}
</tbody>
</table>
);
};
export default translate(NumbersList);

View File

@@ -0,0 +1,60 @@
/**
* The pathName for the dialInInfo page.
*
* @type {string}
*/
export const DIAL_IN_INFO_PAGE_PATH_NAME = 'static/dialInInfo.html';
/**
* The identifier of the sound to be played when the status of an outgoing call
* is expired.
*
* @type {string}
*/
export const OUTGOING_CALL_EXPIRED_SOUND_ID
= 'OUTGOING_CALL_EXPIRED_SOUND';
/**
* The identifier of the sound to be played when the status of an outgoing call
* is rejected.
*
* @type {string}
*/
export const OUTGOING_CALL_REJECTED_SOUND_ID
= 'OUTGOING_CALL_REJECTED_SOUND';
/**
* The identifier of the sound to be played when the status of an outgoing call
* is ringing.
*
* @type {string}
*/
export const OUTGOING_CALL_RINGING_SOUND_ID = 'OUTGOING_CALL_RINGING_SOUND';
/**
* The identifier of the sound to be played when outgoing call is started.
*
* @type {string}
*/
export const OUTGOING_CALL_START_SOUND_ID = 'OUTGOING_CALL_START_SOUND';
/**
* Regex for matching sip addresses.
*/
// eslint-disable-next-line max-len
export const SIP_ADDRESS_REGEX = /^[+a-zA-Z0-9]+(?:([^\s>:@]+)(?::([^\s@>]+))?@)?([\w\-.]+)(?::(\d+))?((?:;[^\s=?>;]+(?:=[^\s?;]+)?)*)(?:\?(([^\s&=>]+=[^\s&=>]+)(&[^\s&=>]+=[^\s&=>]+)*))?$/;
/**
* Different invite types mapping.
*/
export const INVITE_TYPES = {
EMAIL: 'email',
PHONE: 'phone',
ROOM: 'room',
SIP: 'sip',
USER: 'user',
VIDEO_ROOM: 'videosipgw'
};
export const UPGRADE_OPTIONS_TEXT = 'jaas.8x8.vc';
export const UPGRADE_OPTIONS_LINK = 'https://jaas.8x8.vc/#/plan/upgrade';

View File

@@ -0,0 +1,985 @@
// @ts-expect-error
import { jitsiLocalStorage } from '@jitsi/js-utils';
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { getRoomName } from '../base/conference/functions';
import { getInviteURL } from '../base/connection/functions';
import { isIosMobileBrowser } from '../base/environment/utils';
import i18next from '../base/i18n/i18next';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
import { getLocalParticipant } from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { doGetJSON } from '../base/util/httpUtils';
import { parseURLParams } from '../base/util/parseURLParams';
import {
StatusCode,
appendURLParam,
parseURIString
} from '../base/util/uri';
import { isVpaasMeeting } from '../jaas/functions';
import { getActiveSession } from '../recording/functions';
import { getDialInConferenceID, getDialInNumbers } from './_utils';
import {
DIAL_IN_INFO_PAGE_PATH_NAME,
INVITE_TYPES,
SIP_ADDRESS_REGEX,
UPGRADE_OPTIONS_TEXT
} from './constants';
import logger from './logger';
import { IInvitee } from './types';
export const sharingFeatures = {
email: 'email',
url: 'url',
dialIn: 'dial-in',
embed: 'embed'
};
/**
* Sends an ajax request to check if the phone number can be called.
*
* @param {string} dialNumber - The dial number to check for validity.
* @param {string} dialOutAuthUrl - The endpoint to use for checking validity.
* @param {string} region - The region we are connected to.
* @returns {Promise} - The promise created by the request.
*/
export function checkDialNumber(
dialNumber: string,
dialOutAuthUrl: string,
region: string
): Promise<{ allow?: boolean; country?: string; phone?: string; }> {
const fullUrl = `${dialOutAuthUrl}?phone=${dialNumber}&region=${region}`;
return new Promise((resolve, reject) =>
fetch(fullUrl)
.then(res => {
if (res.ok) {
resolve(res.json());
} else {
reject(new Error('Request not successful!'));
}
})
.catch(reject));
}
/**
* Sends an ajax request to check if the outbound call is permitted.
*
* @param {string} dialOutRegionUrl - The config endpoint.
* @param {string} jwt - The jwt token.
* @param {string} appId - The customer id.
* @param {string} phoneNumber - The destination phone number.
* @returns {Promise} - The promise created by the request.
*/
export function checkOutboundDestination(
dialOutRegionUrl: string,
jwt: string,
appId: string,
phoneNumber: string
): Promise<any> {
return doGetJSON(dialOutRegionUrl, true, {
body: JSON.stringify({
appId,
phoneNumber
}),
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
}
});
}
/**
* Removes all non-numeric characters from a string.
*
* @param {string} text - The string from which to remove all characters except
* numbers.
* @returns {string} A string with only numbers.
*/
export function getDigitsOnly(text = ''): string {
return text.replace(/\D/g, '');
}
/**
* Type of the options to use when sending a search query.
*/
export type GetInviteResultsOptions = {
/**
* Whether or not to search for people.
*/
addPeopleEnabled: boolean;
/**
* The customer id.
*/
appId: string;
/**
* The endpoint to use for checking phone number validity.
*/
dialOutAuthUrl: string;
/**
* Whether or not to check phone numbers.
*/
dialOutEnabled: boolean;
/**
* The endpoint to use for checking dial permission to an outbound destination.
*/
dialOutRegionUrl: string;
/**
* The jwt token to pass to the search service.
*/
jwt: string;
/**
* Array with the query types that will be executed -
* "conferenceRooms" | "user" | "room".
*/
peopleSearchQueryTypes: Array<string>;
/**
* Key in localStorage holding the alternative token for people directory.
*/
peopleSearchTokenLocation?: string;
/**
* The url to query for people.
*/
peopleSearchUrl: string;
/**
* The region we are connected to.
*/
region: string;
/**
* Whether or not to check sip invites.
*/
sipInviteEnabled: boolean;
};
/**
* Combines directory search with phone number validation to produce a single
* set of invite search results.
*
* @param {string} query - Text to search.
* @param {GetInviteResultsOptions} options - Options to use when searching.
* @returns {Promise<*>}
*/
export function getInviteResultsForQuery(
query: string,
options: GetInviteResultsOptions
): Promise<any> {
const text = query.trim();
const {
addPeopleEnabled,
appId,
dialOutAuthUrl,
dialOutRegionUrl,
dialOutEnabled,
peopleSearchQueryTypes,
peopleSearchUrl,
peopleSearchTokenLocation,
region,
sipInviteEnabled,
jwt
} = options;
let peopleSearchPromise;
if (addPeopleEnabled && text) {
peopleSearchPromise = searchDirectory(
peopleSearchUrl,
jwt,
text,
peopleSearchQueryTypes,
peopleSearchTokenLocation);
} else {
peopleSearchPromise = Promise.resolve([]);
}
let hasCountryCode = text.startsWith('+');
let phoneNumberPromise;
// Phone numbers are handled a specially to enable both cases of restricting
// numbers to telephone number-y numbers and accepting any arbitrary string,
// which may be valid for SIP (jigasi) calls. If the dialOutAuthUrl is
// defined, then it is assumed the call is to a telephone number and
// some validation of the number is completed, with the + sign used as a way
// for the UI to detect and enforce the usage of a country code. If the
// dialOutAuthUrl is not defined, accept anything because this is assumed
// to be the SIP (jigasi) case.
if (dialOutEnabled && dialOutAuthUrl && isMaybeAPhoneNumber(text)) {
let numberToVerify = text;
// When the number to verify does not start with a +, we assume no
// proper country code has been entered. In such a case, prepend 1 for
// the country code. The service currently takes care of prepending the
// +.
if (!hasCountryCode && !text.startsWith('1')) {
numberToVerify = `1${numberToVerify}`;
}
// The validation service works properly when the query is digits only
// so ensure only digits get sent.
numberToVerify = getDigitsOnly(numberToVerify);
phoneNumberPromise = checkDialNumber(numberToVerify, dialOutAuthUrl, region);
} else if (dialOutEnabled && !dialOutAuthUrl) {
// fake having a country code to hide the country code reminder
hasCountryCode = true;
// With no auth url, let's say the text is a valid number
phoneNumberPromise = Promise.resolve({
allow: true,
country: '',
phone: text
});
} else {
phoneNumberPromise = Promise.resolve<{ allow?: boolean; country?: string; phone?: string; }>({});
}
return Promise.all([ peopleSearchPromise, phoneNumberPromise ])
.then(async ([ peopleResults, phoneResults ]) => {
const results: any[] = [
...peopleResults
];
/**
* This check for phone results is for the day the call to searching
* people might return phone results as well. When that day comes
* this check will make it so the server checks are honored and the
* local appending of the number is not done. The local appending of
* the phone number can then be cleaned up when convenient.
*/
const hasPhoneResult
= peopleResults.find(result => result.type === INVITE_TYPES.PHONE);
if (!hasPhoneResult && typeof phoneResults.allow === 'boolean') {
const result = {
allowed: phoneResults.allow,
country: phoneResults.country,
type: INVITE_TYPES.PHONE,
number: phoneResults.phone,
originalEntry: text,
showCountryCodeReminder: !hasCountryCode
};
if (!phoneResults.allow) {
try {
const response = await checkOutboundDestination(dialOutRegionUrl, jwt, appId, text);
result.allowed = response.allowed;
} catch (error) {
logger.error('Error checking permission to dial to outbound destination', error);
}
}
results.push(result);
}
if (sipInviteEnabled && isASipAddress(text)) {
results.push({
type: INVITE_TYPES.SIP,
address: text
});
}
return results;
});
}
/**
* Creates a custom no new lines message for iOS default mail describing how to dial in to the conference.
*
* @returns {string}
*/
export function getInviteTextiOS({
state,
phoneNumber,
t
}: { phoneNumber?: string | null; state: IReduxState; t?: Function; }) {
if (!isIosMobileBrowser()) {
return '';
}
const dialIn = state['features/invite'];
const inviteUrl = getInviteURL(state);
const localParticipant = getLocalParticipant(state);
const localParticipantName = localParticipant?.name;
const inviteURL = _decodeRoomURI(inviteUrl);
let invite = localParticipantName
? t?.('info.inviteTextiOSPersonal', { name: localParticipantName })
: t?.('info.inviteURLFirstPartGeneral');
invite += ' ';
invite += t?.('info.inviteTextiOSInviteUrl', { inviteUrl });
invite += ' ';
if (shouldDisplayDialIn(dialIn) && isSharingEnabled(sharingFeatures.dialIn)) {
invite += t?.('info.inviteTextiOSPhone', {
number: phoneNumber,
conferenceID: dialIn.conferenceID,
didUrl: getDialInfoPageURL(state)
});
}
invite += ' ';
invite += t?.('info.inviteTextiOSJoinSilent', { silentUrl: `${inviteURL}#config.startSilent=true` });
return invite;
}
/**
* Creates a message describing how to dial in to the conference.
*
* @returns {string}
*/
export function getInviteText({
state,
phoneNumber,
t
}: { phoneNumber?: string | null; state: IReduxState; t?: Function; }) {
const dialIn = state['features/invite'];
const inviteUrl = getInviteURL(state);
const currentLiveStreamingSession = getActiveSession(state, JitsiRecordingConstants.mode.STREAM);
const liveStreamViewURL = currentLiveStreamingSession?.liveStreamViewURL;
const localParticipant = getLocalParticipant(state);
const localParticipantName = localParticipant?.name;
const inviteURL = _decodeRoomURI(inviteUrl);
let invite = localParticipantName
? t?.('info.inviteURLFirstPartPersonal', { name: localParticipantName })
: t?.('info.inviteURLFirstPartGeneral');
invite += t?.('info.inviteURLSecondPart', {
url: inviteURL
});
if (liveStreamViewURL) {
const liveStream = t?.('info.inviteLiveStream', {
url: liveStreamViewURL
});
invite = `${invite}\n${liveStream}`;
}
if (shouldDisplayDialIn(dialIn) && isSharingEnabled(sharingFeatures.dialIn)) {
const dial = t?.('info.invitePhone', {
number: phoneNumber,
conferenceID: dialIn.conferenceID
});
const moreNumbers = t?.('info.invitePhoneAlternatives', {
url: getDialInfoPageURL(state),
silentUrl: `${inviteURL}#config.startSilent=true`
});
invite = `${invite}\n${dial}\n${moreNumbers}`;
}
return invite;
}
/**
* Helper for determining how many of each type of user is being invited. Used
* for logging and sending analytics related to invites.
*
* @param {Array} inviteItems - An array with the invite items, as created in
* {@link _parseQueryResults}.
* @returns {Object} An object with keys as user types and values as the number
* of invites for that type.
*/
export function getInviteTypeCounts(inviteItems: IInvitee[] = []) {
const inviteTypeCounts: any = {};
inviteItems.forEach(({ type }) => {
if (!inviteTypeCounts[type]) {
inviteTypeCounts[type] = 0;
}
inviteTypeCounts[type]++;
});
return inviteTypeCounts;
}
/**
* Sends a post request to an invite service.
*
* @param {string} inviteServiceUrl - The invite service that generates the
* invitation.
* @param {string} inviteUrl - The url to the conference.
* @param {Immutable.List} inviteItems - The list of the "user" or "room" type
* items to invite.
* @param {IReduxState} state - Global state.
* @returns {Promise} - The promise created by the request.
*/
export function invitePeopleAndChatRooms(
inviteServiceUrl: string,
inviteUrl: string,
inviteItems: Array<Object>,
state: IReduxState
): Promise<any> {
if (!inviteItems || inviteItems.length === 0) {
return Promise.resolve();
}
// Parse all the query strings of the search directory endpoint
const { jwt = '' } = state['features/base/jwt'];
const { peopleSearchTokenLocation } = state['features/base/config'];
let token = jwt;
// If token is empty, check for alternate token
if (!token && peopleSearchTokenLocation) {
token = jitsiLocalStorage.getItem(peopleSearchTokenLocation) ?? '';
}
const headers = {
...token ? { 'Authorization': `Bearer ${token}` } : {},
'Content-Type': 'application/json'
};
return fetch(
inviteServiceUrl,
{
body: JSON.stringify({
'invited': inviteItems,
'url': inviteUrl
}),
method: 'POST',
headers
}
);
}
/**
* Determines if adding people is currently enabled.
*
* @param {IReduxState} state - Current state.
* @returns {boolean} Indication of whether adding people is currently enabled.
*/
export function isAddPeopleEnabled(state: IReduxState): boolean {
const {
peopleSearchUrl,
peopleSearchTokenLocation
} = state['features/base/config'];
const hasToken = Boolean(state['features/base/jwt'].jwt || Boolean(peopleSearchTokenLocation));
return Boolean(hasToken && Boolean(peopleSearchUrl) && !isVpaasMeeting(state));
}
/**
* Determines if dial out is currently enabled or not.
*
* @param {IReduxState} state - Current state.
* @returns {boolean} Indication of whether dial out is currently enabled.
*/
export function isDialOutEnabled(state: IReduxState): boolean {
const { conference } = state['features/base/conference'];
return isJwtFeatureEnabled(state, MEET_FEATURES.OUTBOUND_CALL, false) && conference?.isSIPCallingSupported();
}
/**
* Determines if inviting sip endpoints is enabled or not.
*
* @param {IReduxState} state - Current state.
* @returns {boolean} Indication of whether sip invite is currently enabled.
*/
export function isSipInviteEnabled(state: IReduxState): boolean {
const { sipInviteUrl } = state['features/base/config'];
return isJwtFeatureEnabled(state, MEET_FEATURES.SIP_OUTBOUND_CALL, false) && Boolean(sipInviteUrl);
}
/**
* Checks whether a string looks like it could be for a phone number.
*
* @param {string} text - The text to check whether or not it could be a phone
* number.
* @private
* @returns {boolean} True if the string looks like it could be a phone number.
*/
function isMaybeAPhoneNumber(text: string): boolean {
if (!isPhoneNumberRegex().test(text)) {
return false;
}
const digits = getDigitsOnly(text);
return Boolean(digits.length);
}
/**
* Checks whether a string matches a sip address format.
*
* @param {string} text - The text to check.
* @returns {boolean} True if provided text matches a sip address format.
*/
function isASipAddress(text: string): boolean {
return SIP_ADDRESS_REGEX.test(text);
}
/**
* RegExp to use to determine if some text might be a phone number.
*
* @returns {RegExp}
*/
function isPhoneNumberRegex(): RegExp {
let regexString = '^[0-9+()-\\s]*$';
if (typeof interfaceConfig !== 'undefined') {
regexString = interfaceConfig.PHONE_NUMBER_REGEX || regexString;
}
return new RegExp(regexString);
}
/**
* Sends an ajax request to a directory service.
*
* @param {string} serviceUrl - The service to query.
* @param {string} jwt - The jwt token to pass to the search service.
* @param {string} text - Text to search.
* @param {Array<string>} queryTypes - Array with the query types that will be
* executed - "conferenceRooms" | "user" | "room" | "email".
* @param {string} peopleSearchTokenLocation - The localStorage key holding the token value for alternate auth.
* @returns {Promise} - The promise created by the request.
*/
export function searchDirectory( // eslint-disable-line max-params
serviceUrl: string,
jwt: string,
text: string,
queryTypes: Array<string> = [ 'conferenceRooms', 'user', 'room', 'email' ],
peopleSearchTokenLocation?: string
): Promise<Array<{ type: string; }>> {
const query = encodeURIComponent(text);
const queryTypesString = encodeURIComponent(JSON.stringify(queryTypes));
let token = jwt;
// If token is empty, check for alternate token
if (!token && peopleSearchTokenLocation) {
token = jitsiLocalStorage.getItem(peopleSearchTokenLocation) ?? '';
}
const headers = {
...token ? { 'Authorization': `Bearer ${token}` } : {}
};
return fetch(`${serviceUrl}?query=${query}&queryTypes=${
queryTypesString}`,
{
method: 'GET',
headers
})
.then(response => {
const jsonify = response.json();
if (response.ok) {
return jsonify;
}
return jsonify
.then(result => Promise.reject(result));
})
.catch(error => {
logger.error(
'Error searching directory:', error);
return Promise.reject(error);
});
}
/**
* Returns descriptive text that can be used to invite participants to a meeting
* (share via mobile or use it for calendar event description).
*
* @param {IReduxState} state - The current state.
* @param {string} inviteUrl - The conference/location URL.
* @param {boolean} useHtml - Whether to return html text.
* @param {boolean} skipDialIn - Whether to skip dial-in options or not.
* @returns {Promise<string>} A {@code Promise} resolving with a
* descriptive text that can be used to invite participants to a meeting.
*/
export function getShareInfoText(
state: IReduxState, inviteUrl: string, useHtml?: boolean, skipDialIn?: boolean): Promise<string> {
let roomUrl = _decodeRoomURI(inviteUrl);
if (useHtml) {
roomUrl = `<a href="${roomUrl}">${roomUrl}</a>`;
}
let infoText = i18next.t('share.mainText', { roomUrl });
const { room } = parseURIString(inviteUrl);
const { dialInConfCodeUrl, dialInNumbersUrl, hosts } = state['features/base/config'];
const { locationURL = {} } = state['features/base/connection'];
const mucURL = hosts?.muc;
if (skipDialIn || !dialInConfCodeUrl || !dialInNumbersUrl || !mucURL) {
// URLs for fetching dial in numbers not defined.
return Promise.resolve(infoText);
}
let hasPaymentError = false;
// We are requesting numbers and conferenceId directly
// not using updateDialInNumbers, because custom room
// is specified and we do not want to store the data
// in the state.
const numbersPromise = Promise.all([
getDialInNumbers(dialInNumbersUrl, room, mucURL), // @ts-ignore
getDialInConferenceID(dialInConfCodeUrl, room, mucURL, locationURL)
]).then(([ numbers, {
conference, id, message } ]) => {
if (!conference || !id) {
return Promise.reject(message);
}
return {
numbers,
conferenceID: id
};
});
return numbersPromise.then(({ conferenceID, numbers }) => {
const phoneNumber = _getDefaultPhoneNumber(numbers) || '';
return `${
i18next.t('info.dialInNumber')} ${
phoneNumber} ${
i18next.t('info.dialInConferenceID')} ${
conferenceID}#\n\n`;
})
.catch(error => {
logger.error('Error fetching numbers or conferenceID', error);
hasPaymentError = error?.status === StatusCode.PaymentRequired;
})
.then(defaultDialInNumber => {
if (hasPaymentError) {
infoText += `${
i18next.t('info.dialInNumber')} ${i18next.t('info.reachedLimit')} ${
i18next.t('info.upgradeOptions')} ${UPGRADE_OPTIONS_TEXT}`;
return infoText;
}
let dialInfoPageUrl = getDialInfoPageURL(state, room);
if (useHtml) {
dialInfoPageUrl = `<a href="${dialInfoPageUrl}">${dialInfoPageUrl}</a>`;
}
infoText += i18next.t('share.dialInfoText', {
defaultDialInNumber,
dialInfoPageUrl });
return infoText;
});
}
/**
* Generates the URL for the static dial in info page.
*
* @param {IReduxState} state - The state from the Redux store.
* @param {string?} roomName - The conference name. Optional name, if missing will be extracted from state.
* @returns {string}
*/
export function getDialInfoPageURL(state: IReduxState, roomName?: string) {
const { didPageUrl } = state['features/dynamic-branding'];
const conferenceName = roomName ?? getRoomName(state);
const { locationURL } = state['features/base/connection'];
const { href = '' } = locationURL ?? {};
const room = _decodeRoomURI(conferenceName ?? '');
const url = didPageUrl || `${href.substring(0, href.lastIndexOf('/'))}/${DIAL_IN_INFO_PAGE_PATH_NAME}`;
return appendURLParam(url, 'room', room);
}
/**
* Generates the URL for the static dial in info page.
*
* @param {string} uri - The conference URI string.
* @returns {string}
*/
export function getDialInfoPageURLForURIString(
uri?: string) {
if (!uri) {
return undefined;
}
const { protocol, host, contextRoot, room } = parseURIString(uri);
let url = `${protocol}//${host}${contextRoot}${DIAL_IN_INFO_PAGE_PATH_NAME}`;
url = appendURLParam(url, 'room', room);
const { release } = parseURLParams(uri, true, 'search');
release && (url = appendURLParam(url, 'release', release));
return url;
}
/**
* Returns whether or not dial-in related UI should be displayed.
*
* @param {Object} dialIn - Dial in information.
* @returns {boolean}
*/
export function shouldDisplayDialIn(dialIn: any) {
const { conferenceID, numbers, numbersEnabled } = dialIn;
const phoneNumber = _getDefaultPhoneNumber(numbers);
return Boolean(
conferenceID
&& numbers
&& numbersEnabled
&& phoneNumber);
}
/**
* Returns if multiple dial-in numbers are available.
*
* @param {Array<string>|Object} dialInNumbers - The array or object of
* numbers to check.
* @private
* @returns {boolean}
*/
export function hasMultipleNumbers(dialInNumbers?: { numbers: Object; } | string[]) {
if (!dialInNumbers) {
return false;
}
if (Array.isArray(dialInNumbers)) {
return dialInNumbers.length > 1;
}
// deprecated and will be removed
const { numbers } = dialInNumbers;
// eslint-disable-next-line no-confusing-arrow
return Boolean(numbers && Object.values(numbers).map(a => Array.isArray(a) ? a.length : 0)
.reduce((a, b) => a + b) > 1);
}
/**
* Sets the internal state of which dial-in number to display.
*
* @param {Array<string>|Object} dialInNumbers - The array or object of
* numbers to choose a number from.
* @private
* @returns {string|null}
*/
export function _getDefaultPhoneNumber(
dialInNumbers?: { numbers: any; } | Array<{ default: string; formattedNumber: string; }>): string | null {
if (!dialInNumbers) {
return null;
}
if (Array.isArray(dialInNumbers)) {
// new syntax follows
// find the default country inside dialInNumbers, US one
// or return the first one
const defaultNumber = dialInNumbers.find(number => number.default);
if (defaultNumber) {
return defaultNumber.formattedNumber;
}
return dialInNumbers.length > 0
? dialInNumbers[0].formattedNumber : null;
}
const { numbers } = dialInNumbers;
if (numbers && Object.keys(numbers).length > 0) {
// deprecated and will be removed
const firstRegion = Object.keys(numbers)[0];
return firstRegion && numbers[firstRegion][0];
}
return null;
}
/**
* Decodes URI only if doesn't contain a space(' ').
*
* @param {string} url - The string to decode.
* @returns {string} - It the string contains space, encoded value is '%20' returns
* same string, otherwise decoded one.
* @private
*/
export function _decodeRoomURI(url: string) {
let roomUrl = url;
// we want to decode urls when the do not contain space, ' ', which url encoded is %20
if (roomUrl && !roomUrl.includes('%20')) {
roomUrl = decodeURI(roomUrl);
}
// Handles a special case where the room name has % encoded, the decoded will have
// % followed by a char (non-digit) which is not a valid URL and room name ... so we do not
// want to show this decoded
if (roomUrl.match(/.*%[^\d].*/)) {
return url;
}
return roomUrl;
}
/**
* Returns the stored conference id.
*
* @param {IStateful} stateful - The Object or Function that can be
* resolved to a Redux state object with the toState function.
* @returns {string}
*/
export function getConferenceId(stateful: IStateful) {
return toState(stateful)['features/invite'].conferenceID;
}
/**
* Returns the default dial in number from the store.
*
* @param {IStateful} stateful - The Object or Function that can be
* resolved to a Redux state object with the toState function.
* @returns {string | null}
*/
export function getDefaultDialInNumber(stateful: IStateful) {
// @ts-ignore
return _getDefaultPhoneNumber(toState(stateful)['features/invite'].numbers);
}
/**
* Executes the dial out request.
*
* @param {string} url - The url for dialing out.
* @param {Object} body - The body of the request.
* @param {string} reqId - The unique request id.
* @returns {Object}
*/
export async function executeDialOutRequest(url: string, body: Object, reqId: string) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'request-id': reqId
},
body: JSON.stringify(body)
});
const json = await res.json();
return res.ok ? json : Promise.reject(json);
}
/**
* Executes the dial out status request.
*
* @param {string} url - The url for dialing out.
* @param {string} reqId - The unique request id used on the dial out request.
* @returns {Object}
*/
export async function executeDialOutStatusRequest(url: string, reqId: string) {
const res = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'request-id': reqId
}
});
const json = await res.json();
return res.ok ? json : Promise.reject(json);
}
/**
* Returns true if a specific sharing feature is enabled in interface configuration.
*
* @param {string} sharingFeature - The sharing feature to check.
* @returns {boolean}
*/
export function isSharingEnabled(sharingFeature: string) {
return typeof interfaceConfig === 'undefined'
|| typeof interfaceConfig.SHARING_FEATURES === 'undefined'
|| (interfaceConfig.SHARING_FEATURES.length && interfaceConfig.SHARING_FEATURES.indexOf(sharingFeature) > -1);
}
/**
* Sends a post request to an invite service.
*
* @param {Array} inviteItems - The list of the "sip" type items to invite.
* @param {URL} locationURL - The URL of the location.
* @param {string} sipInviteUrl - The invite service that generates the invitation.
* @param {string} jwt - The jwt token.
* @param {string} roomName - The name to the conference.
* @param {string} roomPassword - The password of the conference.
* @param {string} displayName - The user display name.
* @returns {Promise} - The promise created by the request.
*/
export function inviteSipEndpoints( // eslint-disable-line max-params
inviteItems: Array<{ address: string; }>,
locationURL: URL,
sipInviteUrl: string,
jwt: string,
roomName: string,
roomPassword: String,
displayName: string
): Promise<any> {
if (inviteItems.length === 0) {
return Promise.resolve();
}
const regex = new RegExp(`/${roomName}`, 'i');
const baseUrl = Object.assign(new URL(locationURL.toString()), {
pathname: locationURL.pathname.replace(regex, ''),
hash: '',
search: ''
});
return fetch(
sipInviteUrl,
{
body: JSON.stringify({
callParams: {
callUrlInfo: {
baseUrl,
callName: roomName
},
passcode: roomPassword
},
sipClientParams: {
displayName,
sipAddress: inviteItems.map(item => item.address)
}
}),
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
}
}
);
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/invite');

View File

@@ -0,0 +1,206 @@
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
import {
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED
} from '../base/participants/actionTypes';
import { pinParticipant } from '../base/participants/actions';
import { PARTICIPANT_JOINED_SOUND_ID } from '../base/participants/constants';
import {
getLocalParticipant,
getParticipantCount,
getParticipantPresenceStatus,
getRemoteParticipants
} from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import {
playSound,
registerSound,
stopSound,
unregisterSound
} from '../base/sounds/actions';
import {
CALLING,
CONNECTED_USER,
EXPIRED,
INVITED,
REJECTED,
RINGING
} from '../presence-status/constants';
import {
SET_CALLEE_INFO_VISIBLE,
UPDATE_DIAL_IN_NUMBERS_FAILED
} from './actionTypes';
import {
invite,
removePendingInviteRequests,
setCalleeInfoVisible
} from './actions';
import {
OUTGOING_CALL_EXPIRED_SOUND_ID,
OUTGOING_CALL_REJECTED_SOUND_ID,
OUTGOING_CALL_RINGING_SOUND_ID,
OUTGOING_CALL_START_SOUND_ID
} from './constants';
import logger from './logger';
import { sounds } from './sounds';
/**
* Maps the presence status with the ID of the sound that will be played when
* the status is received.
*/
const statusToRingtone = {
[CALLING]: OUTGOING_CALL_START_SOUND_ID,
[CONNECTED_USER]: PARTICIPANT_JOINED_SOUND_ID,
[EXPIRED]: OUTGOING_CALL_EXPIRED_SOUND_ID,
[INVITED]: OUTGOING_CALL_START_SOUND_ID,
[REJECTED]: OUTGOING_CALL_REJECTED_SOUND_ID,
[RINGING]: OUTGOING_CALL_RINGING_SOUND_ID
};
/**
* The middleware of the feature invite common to mobile/react-native and
* Web/React.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
let oldParticipantPresence;
const { dispatch, getState } = store;
const state = getState();
if (action.type === PARTICIPANT_UPDATED
|| action.type === PARTICIPANT_LEFT) {
oldParticipantPresence
= getParticipantPresenceStatus(state, action.participant.id);
}
if (action.type === SET_CALLEE_INFO_VISIBLE) {
if (action.calleeInfoVisible) {
dispatch(pinParticipant(getLocalParticipant(state)?.id));
} else {
// unpin participant
dispatch(pinParticipant());
}
}
const result = next(action);
switch (action.type) {
case APP_WILL_MOUNT:
for (const [ soundId, sound ] of sounds.entries()) {
dispatch(registerSound(soundId, sound.file, sound.options));
}
break;
case APP_WILL_UNMOUNT:
for (const soundId of sounds.keys()) {
dispatch(unregisterSound(soundId));
}
break;
case CONFERENCE_JOINED:
_onConferenceJoined(store);
break;
case PARTICIPANT_JOINED:
case PARTICIPANT_LEFT:
case PARTICIPANT_UPDATED: {
_maybeHideCalleeInfo(action, store);
const newParticipantPresence
= getParticipantPresenceStatus(state, action.participant.id);
if (oldParticipantPresence === newParticipantPresence) {
break;
}
const oldSoundId
= oldParticipantPresence
&& statusToRingtone[oldParticipantPresence as keyof typeof statusToRingtone];
const newSoundId
= newParticipantPresence
&& statusToRingtone[newParticipantPresence as keyof typeof statusToRingtone];
if (oldSoundId === newSoundId) {
break;
}
if (oldSoundId) {
dispatch(stopSound(oldSoundId));
}
if (newSoundId) {
dispatch(playSound(newSoundId));
}
break;
}
case UPDATE_DIAL_IN_NUMBERS_FAILED:
logger.error('Error encountered while fetching dial-in numbers:', action.error,
action.error?.message, action.error?.name, action.error?.stack);
break;
}
return result;
});
/**
* Hides the callee info layot if there are more than 1 real
* (not poltergeist, shared video, etc.) participants in the call.
*
* @param {Object} action - The redux action.
* @param {IStore} store - The redux store.
* @returns {void}
*/
function _maybeHideCalleeInfo(action: AnyAction, store: IStore) {
const state = store.getState();
if (!state['features/invite'].calleeInfoVisible) {
return;
}
const participants = getRemoteParticipants(state);
const participantCount = getParticipantCount(state);
let numberOfPoltergeists = 0;
participants.forEach(p => {
if (p.botType === 'poltergeist') {
numberOfPoltergeists++;
}
});
const numberOfRealParticipants = participantCount - numberOfPoltergeists;
if ((numberOfPoltergeists > 1 || numberOfRealParticipants > 1)
|| (action.type === PARTICIPANT_LEFT && participantCount === 1)) {
store.dispatch(setCalleeInfoVisible(false));
}
}
/**
* Executes the pending invitation requests if any.
*
* @param {IStore} store - The redux store.
* @returns {void}
*/
function _onConferenceJoined(store: IStore) {
const { dispatch, getState } = store;
const pendingInviteRequests
= getState()['features/invite'].pendingInviteRequests || [];
pendingInviteRequests.forEach(({ invitees, callback }) => {
dispatch(invite(invitees))
.then(failedInvitees => {
callback(failedInvitees);
});
});
dispatch(removePendingInviteRequests());
}

View File

@@ -0,0 +1,66 @@
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { hideDialog, openDialog } from '../base/dialog/actions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { BEGIN_ADD_PEOPLE, HIDE_ADD_PEOPLE_DIALOG } from './actionTypes';
import AddPeopleDialog from './components/add-people-dialog/web/AddPeopleDialog';
import './middleware.any';
/**
* The middleware of the feature invite specific to Web/React.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case BEGIN_ADD_PEOPLE:
return _beginAddPeople(store, next, action);
case HIDE_ADD_PEOPLE_DIALOG:
return _hideAddPeopleDialog(store, next, action);
}
return next(action);
});
/**
* Notifies the feature invite that the action {@link BEGIN_ADD_PEOPLE} is being
* dispatched within a specific redux {@code store}.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code BEGIN_ADD_PEOPLE} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _beginAddPeople({ dispatch }: IStore, next: Function, action: AnyAction) {
const result = next(action);
dispatch(openDialog(AddPeopleDialog));
return result;
}
/**
* Notifies the feature invite that the action {@link HIDE_ADD_PEOPLE_DIALOG} is being
* dispatched within a specific redux {@code store}.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} to the specified {@code store}.
* @param {Action} action - The redux action {@code HIDE_ADD_PEOPLE_DIALOG} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {*} The value returned by {@code next(action)}.
*/
function _hideAddPeopleDialog({ dispatch }: IStore, next: Function, action: AnyAction) {
dispatch(hideDialog(AddPeopleDialog));
return next(action);
}

View File

@@ -0,0 +1,107 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
ADD_PENDING_INVITE_REQUEST,
REMOVE_PENDING_INVITE_REQUESTS,
SET_CALLEE_INFO_VISIBLE,
UPDATE_DIAL_IN_NUMBERS_FAILED,
UPDATE_DIAL_IN_NUMBERS_SUCCESS
} from './actionTypes';
import logger from './logger';
import { IInvitee } from './types';
const DEFAULT_STATE = {
/**
* The indicator which determines whether (the) {@code CalleeInfo} is
* visible.
*
* @type {boolean|undefined}
*/
calleeInfoVisible: false,
numbersEnabled: true,
numbersFetched: false,
pendingInviteRequests: []
};
export interface IInviteState {
calleeInfoVisible?: boolean;
conferenceID?: string | number;
error?: {
status: number;
};
initialCalleeInfo?: {
id: string;
name: string;
status: string;
};
numbers?: string[];
numbersEnabled: boolean;
numbersFetched: boolean;
pendingInviteRequests: Array<{
callback: Function;
invitees: IInvitee[];
}>;
sipUri?: string;
}
ReducerRegistry.register<IInviteState>('features/invite', (state = DEFAULT_STATE, action): IInviteState => {
switch (action.type) {
case ADD_PENDING_INVITE_REQUEST:
return {
...state,
pendingInviteRequests: [
...state.pendingInviteRequests,
action.request
]
};
case REMOVE_PENDING_INVITE_REQUESTS:
return {
...state,
pendingInviteRequests: []
};
case SET_CALLEE_INFO_VISIBLE:
return {
...state,
calleeInfoVisible: action.calleeInfoVisible,
initialCalleeInfo: action.initialCalleeInfo
};
case UPDATE_DIAL_IN_NUMBERS_FAILED:
return {
...state,
error: action.error
};
case UPDATE_DIAL_IN_NUMBERS_SUCCESS: {
if (Array.isArray(action.dialInNumbers)) {
return {
...state,
conferenceID: action.conferenceID,
error: undefined,
numbers: action.dialInNumbers,
sipUri: action.sipUri,
numbersEnabled: true,
numbersFetched: true
};
}
// this is the old format which is deprecated
logger.warn('Using deprecated API for retrieving phone numbers');
const { numbersEnabled } = action.dialInNumbers;
return {
...state,
conferenceID: action.conferenceID,
error: undefined,
numbers: action.dialInNumbers,
numbersEnabled,
numbersFetched: true
};
}
}
return state;
});

View File

@@ -0,0 +1,44 @@
import {
OUTGOING_CALL_EXPIRED_SOUND_ID,
OUTGOING_CALL_REJECTED_SOUND_ID,
OUTGOING_CALL_RINGING_SOUND_ID,
OUTGOING_CALL_START_SOUND_ID
} from './constants';
/**
* Maps the sounds IDs with the filenames sounds associated with them.
*
* @type {Map<string, string>}
*/
export const sounds = new Map([
/**
* The name of the sound file which will be played when outgoing call is
* expired.
*/
[ OUTGOING_CALL_EXPIRED_SOUND_ID, { file: 'rejected.mp3' } ],
/**
* The name of the sound file which will be played when outgoing call is
* rejected.
*/
[ OUTGOING_CALL_REJECTED_SOUND_ID, { file: 'rejected.mp3' } ],
/**
* The name of the sound file which will be played when the status of an
* outgoing call is ringing.
*/
[
OUTGOING_CALL_RINGING_SOUND_ID,
{
file: 'outgoingRinging.mp3',
options: { loop: true }
}
],
/**
* The name of the sound file which will be played when outgoing call is
* started.
*/
[ OUTGOING_CALL_START_SOUND_ID, { file: 'outgoingStart.mp3' } ]
]);

View File

@@ -0,0 +1,20 @@
import { MultiSelectItem } from '../base/ui/components/types';
export interface IInvitee {
address: string;
allowed?: boolean;
id?: string;
name?: string;
number: string;
originalEntry?: string;
phone?: string;
showCountryCodeReminder?: boolean;
type: string;
user_id?: string;
}
export interface IInviteSelectItem extends MultiSelectItem {
filterValues?: string[];
item: IInvitee;
tag?: any;
}