This commit is contained in:
83
react/features/invite/_utils.ts
Normal file
83
react/features/invite/_utils.ts
Normal 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);
|
||||
}
|
||||
72
react/features/invite/actionTypes.ts
Normal file
72
react/features/invite/actionTypes.ts
Normal 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';
|
||||
297
react/features/invite/actions.any.ts
Normal file
297
react/features/invite/actions.any.ts
Normal 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
|
||||
};
|
||||
}
|
||||
25
react/features/invite/actions.native.ts
Normal file
25
react/features/invite/actions.native.ts
Normal 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());
|
||||
};
|
||||
}
|
||||
1
react/features/invite/actions.web.ts
Normal file
1
react/features/invite/actions.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './actions.any';
|
||||
@@ -0,0 +1,323 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import { createInviteDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { getMeetingRegion } from '../../../base/config/functions.any';
|
||||
import { showErrorNotification, showNotification } from '../../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
|
||||
import { INotificationProps } from '../../../notifications/types';
|
||||
import { invite } from '../../actions.any';
|
||||
import { INVITE_TYPES } from '../../constants';
|
||||
import {
|
||||
getInviteResultsForQuery,
|
||||
getInviteTypeCounts,
|
||||
isAddPeopleEnabled,
|
||||
isDialOutEnabled,
|
||||
isSipInviteEnabled
|
||||
} from '../../functions';
|
||||
import logger from '../../logger';
|
||||
import { IInviteSelectItem, IInvitee } from '../../types';
|
||||
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* Whether or not to show Add People functionality.
|
||||
*/
|
||||
_addPeopleEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The app id of the user.
|
||||
*/
|
||||
_appId: string;
|
||||
|
||||
/**
|
||||
* Whether or not call flows are enabled.
|
||||
*/
|
||||
_callFlowsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The URL for validating if a phone number can be called.
|
||||
*/
|
||||
_dialOutAuthUrl: string;
|
||||
|
||||
/**
|
||||
* Whether or not to show Dial Out functionality.
|
||||
*/
|
||||
_dialOutEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The URL for validating if an outbound destination is allowed.
|
||||
*/
|
||||
_dialOutRegionUrl: string;
|
||||
|
||||
/**
|
||||
* The JWT token.
|
||||
*/
|
||||
_jwt: string;
|
||||
|
||||
/**
|
||||
* The query types used when searching people.
|
||||
*/
|
||||
_peopleSearchQueryTypes: Array<string>;
|
||||
|
||||
/**
|
||||
* The localStorage key holding the alternative token for people directory.
|
||||
*/
|
||||
_peopleSearchTokenLocation: string;
|
||||
|
||||
/**
|
||||
* The URL pointing to the service allowing for people search.
|
||||
*/
|
||||
_peopleSearchUrl: string;
|
||||
|
||||
/**
|
||||
* The region where we connected to.
|
||||
*/
|
||||
_region: string;
|
||||
|
||||
/**
|
||||
* Whether or not to allow sip invites.
|
||||
*/
|
||||
_sipInviteEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
|
||||
/**
|
||||
* Indicating that an error occurred when adding people to the call.
|
||||
*/
|
||||
addToCallError: boolean;
|
||||
|
||||
/**
|
||||
* Indicating that we're currently adding the new people to the
|
||||
* call.
|
||||
*/
|
||||
addToCallInProgress: boolean;
|
||||
|
||||
/**
|
||||
* The list of invite items.
|
||||
*/
|
||||
inviteItems: Array<IInvitee | IInviteSelectItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements an abstract dialog to invite people to the conference.
|
||||
*/
|
||||
export default class AbstractAddPeopleDialog<P extends IProps, S extends IState> extends Component<P, S> {
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
this._query = this._query.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the notification display name for the invitee.
|
||||
*
|
||||
* @param {IInvitee} invitee - The invitee object.
|
||||
* @returns {string}
|
||||
*/
|
||||
_getDisplayName(invitee: IInvitee) {
|
||||
if (invitee.type === INVITE_TYPES.PHONE) {
|
||||
return invitee.number;
|
||||
}
|
||||
|
||||
if (invitee.type === INVITE_TYPES.SIP) {
|
||||
return invitee.address;
|
||||
}
|
||||
|
||||
return invitee.name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite people and numbers to the conference. The logic works by inviting
|
||||
* numbers, people/rooms, sip endpoints and videosipgw in parallel. All invitees are
|
||||
* stored in an array. As each invite succeeds, the invitee is removed
|
||||
* from the array. After all invites finish, close the modal if there are
|
||||
* no invites left to send. If any are left, that means an invite failed
|
||||
* and an error state should display.
|
||||
*
|
||||
* @param {Array<IInvitee>} invitees - The items to be invited.
|
||||
* @returns {Promise<Array<any>>}
|
||||
*/
|
||||
_invite(invitees: IInvitee[]) {
|
||||
const inviteTypeCounts = getInviteTypeCounts(invitees);
|
||||
|
||||
sendAnalytics(createInviteDialogEvent(
|
||||
'clicked', 'inviteButton', {
|
||||
...inviteTypeCounts,
|
||||
inviteAllowed: this._isAddDisabled()
|
||||
}));
|
||||
|
||||
if (this._isAddDisabled()) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
addToCallInProgress: true
|
||||
});
|
||||
|
||||
const { _callFlowsEnabled, dispatch } = this.props;
|
||||
|
||||
return dispatch(invite(invitees))
|
||||
.then((invitesLeftToSend: IInvitee[]) => {
|
||||
this.setState({
|
||||
addToCallInProgress: false
|
||||
});
|
||||
|
||||
// If any invites are left that means something failed to send
|
||||
// so treat it as an error.
|
||||
if (invitesLeftToSend.length) {
|
||||
const erroredInviteTypeCounts
|
||||
= getInviteTypeCounts(invitesLeftToSend);
|
||||
|
||||
logger.error(`${invitesLeftToSend.length} invites failed`,
|
||||
erroredInviteTypeCounts);
|
||||
|
||||
sendAnalytics(createInviteDialogEvent(
|
||||
'error', 'invite', {
|
||||
...erroredInviteTypeCounts
|
||||
}));
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'addPeople.failedToAdd'
|
||||
}));
|
||||
} else if (!_callFlowsEnabled) {
|
||||
const invitedCount = invitees.length;
|
||||
let notificationProps: INotificationProps | undefined;
|
||||
|
||||
if (invitedCount >= 3) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
name: this._getDisplayName(invitees[0]),
|
||||
count: `${invitedCount - 1}`
|
||||
},
|
||||
titleKey: 'notify.invitedThreePlusMembers'
|
||||
};
|
||||
} else if (invitedCount === 2) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
first: this._getDisplayName(invitees[0]),
|
||||
second: this._getDisplayName(invitees[1])
|
||||
},
|
||||
titleKey: 'notify.invitedTwoMembers'
|
||||
};
|
||||
} else if (invitedCount) {
|
||||
notificationProps = {
|
||||
titleArguments: {
|
||||
name: this._getDisplayName(invitees[0])
|
||||
},
|
||||
titleKey: 'notify.invitedOneMember'
|
||||
};
|
||||
}
|
||||
|
||||
if (notificationProps) {
|
||||
dispatch(
|
||||
showNotification(notificationProps, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
}
|
||||
}
|
||||
|
||||
return invitesLeftToSend;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the Add button should be disabled.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - True to indicate that the Add button should
|
||||
* be disabled, false otherwise.
|
||||
*/
|
||||
_isAddDisabled() {
|
||||
return !this.state.inviteItems.length
|
||||
|| this.state.addToCallInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a people and phone number search request.
|
||||
*
|
||||
* @param {string} query - The search text.
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_query(query = '') {
|
||||
const {
|
||||
_addPeopleEnabled: addPeopleEnabled,
|
||||
_appId: appId,
|
||||
_dialOutAuthUrl: dialOutAuthUrl,
|
||||
_dialOutRegionUrl: dialOutRegionUrl,
|
||||
_dialOutEnabled: dialOutEnabled,
|
||||
_jwt: jwt,
|
||||
_peopleSearchQueryTypes: peopleSearchQueryTypes,
|
||||
_peopleSearchUrl: peopleSearchUrl,
|
||||
_peopleSearchTokenLocation: peopleSearchTokenLocation,
|
||||
_region: region,
|
||||
_sipInviteEnabled: sipInviteEnabled
|
||||
} = this.props;
|
||||
const options = {
|
||||
addPeopleEnabled,
|
||||
appId,
|
||||
dialOutAuthUrl,
|
||||
dialOutEnabled,
|
||||
dialOutRegionUrl,
|
||||
jwt,
|
||||
peopleSearchQueryTypes,
|
||||
peopleSearchUrl,
|
||||
peopleSearchTokenLocation,
|
||||
region,
|
||||
sipInviteEnabled
|
||||
};
|
||||
|
||||
return getInviteResultsForQuery(query, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _addPeopleEnabled: boolean,
|
||||
* _dialOutAuthUrl: string,
|
||||
* _dialOutEnabled: boolean,
|
||||
* _jwt: string,
|
||||
* _peopleSearchQueryTypes: Array<string>,
|
||||
* _peopleSearchUrl: string
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
callFlowsEnabled,
|
||||
dialOutAuthUrl,
|
||||
dialOutRegionUrl,
|
||||
peopleSearchQueryTypes,
|
||||
peopleSearchUrl,
|
||||
peopleSearchTokenLocation
|
||||
} = state['features/base/config'];
|
||||
|
||||
return {
|
||||
_addPeopleEnabled: isAddPeopleEnabled(state),
|
||||
_appId: state['features/base/jwt']?.tenant ?? '',
|
||||
_callFlowsEnabled: callFlowsEnabled ?? false,
|
||||
_dialOutAuthUrl: dialOutAuthUrl ?? '',
|
||||
_dialOutRegionUrl: dialOutRegionUrl ?? '',
|
||||
_dialOutEnabled: isDialOutEnabled(state),
|
||||
_jwt: state['features/base/jwt'].jwt ?? '',
|
||||
_peopleSearchQueryTypes: peopleSearchQueryTypes ?? [],
|
||||
_peopleSearchUrl: peopleSearchUrl ?? '',
|
||||
_peopleSearchTokenLocation: peopleSearchTokenLocation ?? '',
|
||||
_region: getMeetingRegion(state),
|
||||
_sipInviteEnabled: isSipInviteEnabled(state)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import { matchesProperty, sortBy } from 'lodash-es';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import AlertDialog from '../../../../base/dialog/components/native/AlertDialog';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloseCircle,
|
||||
IconEnvelope,
|
||||
IconPhoneRinging,
|
||||
IconSearch,
|
||||
IconShare
|
||||
} from '../../../../base/icons/svg';
|
||||
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
|
||||
import AvatarListItem from '../../../../base/react/components/native/AvatarListItem';
|
||||
import { Item } from '../../../../base/react/types';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
|
||||
import Input from '../../../../base/ui/components/native/Input';
|
||||
import HeaderNavigationButton
|
||||
from '../../../../mobile/navigation/components/HeaderNavigationButton';
|
||||
import { beginShareRoom } from '../../../../share-room/actions';
|
||||
import { INVITE_TYPES } from '../../../constants';
|
||||
import { IInviteSelectItem, IInvitee } from '../../../types';
|
||||
import AbstractAddPeopleDialog, {
|
||||
type IProps as AbstractProps,
|
||||
type IState as AbstractState,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractAddPeopleDialog';
|
||||
|
||||
import styles, { AVATAR_SIZE } from './styles';
|
||||
|
||||
interface IProps extends AbstractProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* True if the invite dialog should be open, false otherwise.
|
||||
*/
|
||||
_isVisible: boolean;
|
||||
|
||||
/**
|
||||
* Default prop for navigation between screen components(React Navigation).
|
||||
*/
|
||||
navigation: any;
|
||||
|
||||
/**
|
||||
* Theme used for styles.
|
||||
*/
|
||||
theme: Object;
|
||||
}
|
||||
|
||||
interface IState extends AbstractState {
|
||||
|
||||
/**
|
||||
* Boolean to show if an extra padding needs to be added to the bottom bar.
|
||||
*/
|
||||
bottomPadding: boolean;
|
||||
|
||||
/**
|
||||
* State variable to keep track of the search field value.
|
||||
*/
|
||||
fieldValue: string;
|
||||
|
||||
/**
|
||||
* True if a search is in progress, false otherwise.
|
||||
*/
|
||||
searchInprogress: boolean;
|
||||
|
||||
/**
|
||||
* An array of items that are selectable on this dialog. This is usually
|
||||
* populated by an async search.
|
||||
*/
|
||||
selectableItems: Array<Object>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements a special dialog to invite people from a directory service.
|
||||
*/
|
||||
class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
|
||||
/**
|
||||
* Default state object to reset the state to when needed.
|
||||
*/
|
||||
defaultState = {
|
||||
addToCallError: false,
|
||||
addToCallInProgress: false,
|
||||
bottomPadding: false,
|
||||
fieldValue: '',
|
||||
inviteItems: [],
|
||||
searchInprogress: false,
|
||||
selectableItems: []
|
||||
};
|
||||
|
||||
/**
|
||||
* TimeoutID to delay the search for the time the user is probably typing.
|
||||
*/
|
||||
|
||||
/* eslint-disable-next-line no-undef */
|
||||
searchTimeout: number;
|
||||
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = this.defaultState;
|
||||
|
||||
this._keyExtractor = this._keyExtractor.bind(this);
|
||||
this._renderInvitedItem = this._renderInvitedItem.bind(this);
|
||||
this._renderItem = this._renderItem.bind(this);
|
||||
this._renderSeparator = this._renderSeparator.bind(this);
|
||||
this._onClearField = this._onClearField.bind(this);
|
||||
this._onInvite = this._onInvite.bind(this);
|
||||
this._onPressItem = this._onPressItem.bind(this);
|
||||
this._onShareMeeting = this._onShareMeeting.bind(this);
|
||||
this._onTypeQuery = this._onTypeQuery.bind(this);
|
||||
this._renderShareMeetingButton = this._renderShareMeetingButton.bind(this);
|
||||
this._renderIcon = this._renderIcon.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
disabled = { this._isAddDisabled() }
|
||||
label = { t('inviteDialog.send') }
|
||||
style = { styles.sendBtn }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#componentDidUpdate}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
disabled = { this._isAddDisabled() }
|
||||
label = { t('inviteDialog.send') }
|
||||
onPress = { this._onInvite }
|
||||
style = { styles.sendBtn }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
|
||||
if (prevProps._isVisible !== this.props._isVisible) {
|
||||
// Clear state
|
||||
this._clearState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_addPeopleEnabled,
|
||||
_dialOutEnabled
|
||||
} = this.props;
|
||||
const { inviteItems, selectableItems } = this.state;
|
||||
|
||||
let placeholderKey = 'searchPlaceholder';
|
||||
|
||||
if (!_addPeopleEnabled) {
|
||||
placeholderKey = 'searchCallOnlyPlaceholder';
|
||||
} else if (!_dialOutEnabled) {
|
||||
placeholderKey = 'searchPeopleOnlyPlaceholder';
|
||||
}
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
footerComponent = { this._renderShareMeetingButton }
|
||||
hasExtraHeaderHeight = { true }
|
||||
style = { styles.addPeopleContainer }>
|
||||
<Input
|
||||
autoFocus = { false }
|
||||
clearable = { true }
|
||||
customStyles = {{ container: styles.customContainer }}
|
||||
icon = { this._renderIcon }
|
||||
onChange = { this._onTypeQuery }
|
||||
placeholder = { this.props.t(`inviteDialog.${placeholderKey}`) }
|
||||
value = { this.state.fieldValue } />
|
||||
{ Boolean(inviteItems.length) && <View style = { styles.invitedList }>
|
||||
<FlatList
|
||||
data = { inviteItems }
|
||||
horizontal = { true }
|
||||
keyExtractor = { this._keyExtractor }
|
||||
renderItem = { this._renderInvitedItem as any } />
|
||||
</View> }
|
||||
<View style = { styles.resultList }>
|
||||
<FlatList
|
||||
ItemSeparatorComponent = { this._renderSeparator }
|
||||
data = { selectableItems }
|
||||
extraData = { inviteItems }
|
||||
keyExtractor = { this._keyExtractor }
|
||||
renderItem = { this._renderItem as any } />
|
||||
</View>
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the dialog content.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_clearState() {
|
||||
this.setState(this.defaultState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object capable of being rendered by an {@code AvatarListItem}.
|
||||
*
|
||||
* @param {Object} flatListItem - An item of the data array of the {@code FlatList}.
|
||||
* @returns {?Object}
|
||||
*/
|
||||
_getRenderableItem(flatListItem: any) {
|
||||
const { item } = flatListItem;
|
||||
|
||||
switch (item.type) {
|
||||
|
||||
// isCORSAvatarURL in this case is false
|
||||
case INVITE_TYPES.PHONE:
|
||||
return {
|
||||
avatar: IconPhoneRinging,
|
||||
key: item.number,
|
||||
title: item.number
|
||||
};
|
||||
case INVITE_TYPES.USER:
|
||||
return {
|
||||
avatar: item.avatar,
|
||||
key: item.id || item.user_id,
|
||||
title: item.name
|
||||
};
|
||||
case INVITE_TYPES.EMAIL:
|
||||
return {
|
||||
avatar: item.avatar || IconEnvelope,
|
||||
key: item.id || item.user_id,
|
||||
title: item.name
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key extractor for the flatlist.
|
||||
*
|
||||
* @param {Object} item - The flatlist item that we need the key to be
|
||||
* generated for.
|
||||
* @returns {string}
|
||||
*/
|
||||
_keyExtractor(item: any) {
|
||||
if (item.type === INVITE_TYPES.USER || item.type === INVITE_TYPES.EMAIL) {
|
||||
return item.id || item.user_id;
|
||||
}
|
||||
|
||||
return item.number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to clear the text field.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClearField() {
|
||||
this.setState({
|
||||
fieldValue: ''
|
||||
});
|
||||
|
||||
// Clear search results
|
||||
this._onTypeQuery('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites the selected entries.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onInvite() {
|
||||
// @ts-ignore
|
||||
this._invite(this.state.inviteItems)
|
||||
.then((invitesLeftToSend: IInvitee[]) => {
|
||||
if (invitesLeftToSend.length) {
|
||||
this.setState({
|
||||
inviteItems: invitesLeftToSend
|
||||
});
|
||||
this._showFailedInviteAlert();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to prepare a callback for the onPress event of the touchable.
|
||||
*
|
||||
* @param {Item} item - The item on which onPress was invoked.
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onPressItem(item: Item) {
|
||||
return () => {
|
||||
const { inviteItems } = this.state;
|
||||
const finderKey = item.type === INVITE_TYPES.PHONE ? 'number' : 'user_id';
|
||||
|
||||
if (inviteItems.find(
|
||||
matchesProperty(finderKey, item[finderKey as keyof typeof item]))) {
|
||||
// Item is already selected, need to unselect it.
|
||||
this.setState({
|
||||
inviteItems: inviteItems.filter(
|
||||
(element: any) => item[finderKey as keyof typeof item] !== element[finderKey])
|
||||
});
|
||||
} else {
|
||||
// Item is not selected yet, need to add to the list.
|
||||
// @ts-ignore
|
||||
const items = inviteItems.concat(item);
|
||||
|
||||
this.setState({
|
||||
inviteItems: sortBy(items, [ 'name', 'number' ])
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the system share sheet to share the meeting information.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onShareMeeting() {
|
||||
if (this.state.inviteItems.length > 0) {
|
||||
// The use probably intended to invite people.
|
||||
this._onInvite();
|
||||
} else {
|
||||
this.props.dispatch(beginShareRoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the typing event of the text field on the dialog and performs the
|
||||
* search.
|
||||
*
|
||||
* @param {string} query - The query that is typed in the field.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onTypeQuery(query: string) {
|
||||
this.setState({
|
||||
fieldValue: query
|
||||
});
|
||||
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.setState({
|
||||
searchInprogress: true
|
||||
}, () => {
|
||||
this._performSearch(query);
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual search.
|
||||
*
|
||||
* @param {string} query - The query to search for.
|
||||
* @returns {void}
|
||||
*/
|
||||
_performSearch(query: string) {
|
||||
this._query(query).then(results => {
|
||||
this.setState({
|
||||
selectableItems: sortBy(results, [ 'name', 'number' ])
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({
|
||||
searchInprogress: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single item in the invited {@code FlatList}.
|
||||
*
|
||||
* @param {Object} flatListItem - An item of the data array of the
|
||||
* {@code FlatList}.
|
||||
* @param {number} index - The index of the currently rendered item.
|
||||
* @returns {ReactElement<any>}
|
||||
*/
|
||||
_renderInvitedItem(flatListItem: any, index: number): ReactElement | null {
|
||||
const { item } = flatListItem;
|
||||
const renderableItem = this._getRenderableItem(flatListItem);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress = { this._onPressItem(item) } >
|
||||
<View
|
||||
pointerEvents = 'box-only'
|
||||
style = { styles.itemWrapper as ViewStyle }>
|
||||
<AvatarListItem
|
||||
avatarOnly = { true }
|
||||
avatarSize = { AVATAR_SIZE }
|
||||
avatarStatus = { item.status }
|
||||
avatarStyle = { styles.avatar }
|
||||
avatarTextStyle = { styles.avatarText }
|
||||
item = { renderableItem as any }
|
||||
key = { index }
|
||||
linesStyle = { styles.itemLinesStyle }
|
||||
titleStyle = { styles.itemText } />
|
||||
<Icon
|
||||
src = { IconCloseCircle }
|
||||
style = { styles.unselectIcon } />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single item in the search result {@code FlatList}.
|
||||
*
|
||||
* @param {Object} flatListItem - An item of the data array of the
|
||||
* {@code FlatList}.
|
||||
* @param {number} index - The index of the currently rendered item.
|
||||
* @returns {?ReactElement<*>}
|
||||
*/
|
||||
_renderItem(flatListItem: any, index: number): ReactElement | null {
|
||||
const { item } = flatListItem;
|
||||
const { inviteItems } = this.state;
|
||||
let selected: IInvitee | IInviteSelectItem | undefined | boolean = false;
|
||||
const renderableItem = this._getRenderableItem(flatListItem);
|
||||
|
||||
if (!renderableItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case INVITE_TYPES.PHONE:
|
||||
selected = inviteItems.find(matchesProperty('number', item.number));
|
||||
break;
|
||||
case INVITE_TYPES.USER:
|
||||
case INVITE_TYPES.EMAIL:
|
||||
selected = item.id
|
||||
? inviteItems.find(matchesProperty('id', item.id))
|
||||
: inviteItems.find(matchesProperty('user_id', item.user_id));
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress = { this._onPressItem(item) } >
|
||||
<View
|
||||
pointerEvents = 'box-only'
|
||||
style = { styles.itemWrapper as ViewStyle }>
|
||||
<AvatarListItem
|
||||
avatarSize = { AVATAR_SIZE }
|
||||
avatarStatus = { item.status }
|
||||
avatarStyle = { styles.avatar }
|
||||
avatarTextStyle = { styles.avatarText }
|
||||
item = { renderableItem as any }
|
||||
key = { index }
|
||||
linesStyle = { styles.itemLinesStyle }
|
||||
titleStyle = { styles.itemText } />
|
||||
{ selected && <Icon
|
||||
src = { IconCheck }
|
||||
style = { styles.selectedIcon } /> }
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the item separator.
|
||||
*
|
||||
* @returns {?ReactElement<*>}
|
||||
*/
|
||||
_renderSeparator() {
|
||||
return (
|
||||
<View style = { styles.separator } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a button to share the meeting info.
|
||||
*
|
||||
* @returns {React#Element<*>}
|
||||
*/
|
||||
_renderShareMeetingButton() {
|
||||
return (
|
||||
<SafeAreaView
|
||||
style = { [
|
||||
styles.bottomBar as ViewStyle,
|
||||
this.state.bottomPadding ? styles.extraBarPadding : null
|
||||
] }>
|
||||
<TouchableOpacity
|
||||
onPress = { this._onShareMeeting }>
|
||||
<Icon
|
||||
src = { IconShare }
|
||||
style = { styles.shareIcon } />
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an icon.
|
||||
*
|
||||
* @returns {React#Element<*>}
|
||||
*/
|
||||
_renderIcon() {
|
||||
if (this.state.searchInprogress) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
color = { BaseTheme.palette.icon01 }
|
||||
size = 'small' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
src = { IconSearch }
|
||||
style = { styles.searchIcon } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert telling the user that some invitees were failed to be
|
||||
* invited.
|
||||
*
|
||||
* NOTE: We're using an Alert here because we're on a modal and it makes
|
||||
* using our dialogs a tad more difficult.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_showFailedInviteAlert() {
|
||||
this.props.dispatch(openDialog(AlertDialog, {
|
||||
contentKey: {
|
||||
key: 'inviteDialog.alertText'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {any} _ownProps - Component's own props.
|
||||
* @returns {{
|
||||
* _isVisible: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AddPeopleDialog));
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BoxModel } from '../../../../base/styles/components/styles/BoxModel';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
export const AVATAR_SIZE = 40;
|
||||
export const DARK_GREY = 'rgb(28, 32, 37)';
|
||||
export const LIGHT_GREY = 'rgb(209, 219, 232)';
|
||||
|
||||
export default {
|
||||
|
||||
addPeopleContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
avatar: {
|
||||
backgroundColor: LIGHT_GREY
|
||||
},
|
||||
|
||||
customContainer: {
|
||||
marginHorizontal: BaseTheme.spacing[3],
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
avatarText: {
|
||||
color: DARK_GREY,
|
||||
fontSize: 12
|
||||
},
|
||||
|
||||
bottomBar: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
|
||||
clearButton: {
|
||||
paddingTop: 7
|
||||
},
|
||||
|
||||
clearIcon: {
|
||||
color: BaseTheme.palette.ui02,
|
||||
fontSize: 18,
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* A special padding to avoid issues on some devices (such as Android devices with custom suggestions bar).
|
||||
*/
|
||||
extraBarPadding: {
|
||||
paddingBottom: 30
|
||||
},
|
||||
|
||||
headerCloseIcon: {
|
||||
marginLeft: 12
|
||||
},
|
||||
|
||||
headerSendInvite: {
|
||||
color: BaseTheme.palette.text01,
|
||||
marginRight: 12
|
||||
},
|
||||
|
||||
invitedList: {
|
||||
padding: 3
|
||||
},
|
||||
|
||||
itemLinesStyle: {
|
||||
color: 'rgb(118, 136, 152)',
|
||||
fontSize: 13
|
||||
},
|
||||
|
||||
itemText: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
},
|
||||
|
||||
itemWrapper: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 5
|
||||
},
|
||||
|
||||
resultList: {
|
||||
flex: 1,
|
||||
padding: 5
|
||||
},
|
||||
|
||||
selectedIcon: {
|
||||
color: BaseTheme.palette.icon01,
|
||||
fontSize: 20,
|
||||
marginRight: BoxModel.margin,
|
||||
padding: 2
|
||||
},
|
||||
|
||||
separator: {
|
||||
borderBottomColor: BaseTheme.palette.ui07,
|
||||
borderBottomWidth: 1,
|
||||
marginLeft: 85
|
||||
},
|
||||
|
||||
searchIcon: {
|
||||
color: BaseTheme.palette.icon01,
|
||||
fontSize: 22
|
||||
},
|
||||
|
||||
shareIcon: {
|
||||
fontSize: 42
|
||||
},
|
||||
|
||||
unselectIcon: {
|
||||
color: BaseTheme.palette.ui01,
|
||||
fontSize: 16,
|
||||
left: AVATAR_SIZE / -3,
|
||||
position: 'relative',
|
||||
top: AVATAR_SIZE / -3
|
||||
},
|
||||
|
||||
sendBtn: {
|
||||
marginRight: BaseTheme.spacing[3]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createInviteDialogEvent } from '../../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../../analytics/functions';
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { getInviteURL } from '../../../../base/connection/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { JitsiRecordingConstants } from '../../../../base/lib-jitsi-meet';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { StatusCode } from '../../../../base/util/uri';
|
||||
import { isDynamicBrandingDataLoaded } from '../../../../dynamic-branding/functions.any';
|
||||
import { getActiveSession } from '../../../../recording/functions';
|
||||
import { updateDialInNumbers } from '../../../actions.web';
|
||||
import {
|
||||
_getDefaultPhoneNumber,
|
||||
getInviteText,
|
||||
getInviteTextiOS,
|
||||
isAddPeopleEnabled,
|
||||
isDialOutEnabled,
|
||||
isSharingEnabled,
|
||||
sharingFeatures
|
||||
} from '../../../functions';
|
||||
|
||||
import CopyMeetingLinkSection from './CopyMeetingLinkSection';
|
||||
import DialInLimit from './DialInLimit';
|
||||
import DialInSection from './DialInSection';
|
||||
import InviteByEmailSection from './InviteByEmailSection';
|
||||
import InviteContactsSection from './InviteContactsSection';
|
||||
import LiveStreamSection from './LiveStreamSection';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The object representing the dialIn feature.
|
||||
*/
|
||||
_dialIn: any;
|
||||
|
||||
/**
|
||||
* Whether or not dial in number should be visible.
|
||||
*/
|
||||
_dialInVisible: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not email sharing features should be visible.
|
||||
*/
|
||||
_emailSharingVisible: boolean;
|
||||
|
||||
/**
|
||||
* The meeting invitation text.
|
||||
*/
|
||||
_invitationText: string;
|
||||
|
||||
/**
|
||||
* The custom no new-lines meeting invitation text for iOS default email.
|
||||
* Needed because of this mailto: iOS issue: https://developer.apple.com/forums/thread/681023.
|
||||
*/
|
||||
_invitationTextiOS: string;
|
||||
|
||||
/**
|
||||
* An alternate app name to be displayed in the email subject.
|
||||
*/
|
||||
_inviteAppName?: string | null;
|
||||
|
||||
/**
|
||||
* Whether or not invite contacts should be visible.
|
||||
*/
|
||||
_inviteContactsVisible: boolean;
|
||||
|
||||
/**
|
||||
* The current url of the conference to be copied onto the clipboard.
|
||||
*/
|
||||
_inviteUrl: string;
|
||||
|
||||
/**
|
||||
* Whether the dial in limit has been exceeded.
|
||||
*/
|
||||
_isDialInOverLimit?: boolean;
|
||||
|
||||
/**
|
||||
* The current known URL for a live stream in progress.
|
||||
*/
|
||||
_liveStreamViewURL?: string;
|
||||
|
||||
/**
|
||||
* The default phone number.
|
||||
*/
|
||||
_phoneNumber?: string | null;
|
||||
|
||||
/**
|
||||
* Whether or not url sharing button should be visible.
|
||||
*/
|
||||
_urlSharingVisible: boolean;
|
||||
|
||||
/**
|
||||
* Method to update the dial in numbers.
|
||||
*/
|
||||
updateNumbers: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite More component.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function AddPeopleDialog({
|
||||
_dialIn,
|
||||
_dialInVisible,
|
||||
_urlSharingVisible,
|
||||
_emailSharingVisible,
|
||||
_invitationText,
|
||||
_invitationTextiOS,
|
||||
_inviteAppName,
|
||||
_inviteContactsVisible,
|
||||
_inviteUrl,
|
||||
_isDialInOverLimit,
|
||||
_liveStreamViewURL,
|
||||
_phoneNumber,
|
||||
t,
|
||||
updateNumbers
|
||||
}: IProps) {
|
||||
|
||||
/**
|
||||
* Updates the dial-in numbers.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!_dialIn.numbers) {
|
||||
updateNumbers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sends analytics events when the dialog opens/closes.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
useEffect(() => {
|
||||
sendAnalytics(createInviteDialogEvent(
|
||||
'opened', 'dialog'));
|
||||
|
||||
return () => {
|
||||
sendAnalytics(createInviteDialogEvent(
|
||||
'closed', 'dialog'));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const inviteSubject = t('addPeople.inviteMoreMailSubject', {
|
||||
appName: _inviteAppName ?? interfaceConfig.APP_NAME
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
ok = {{ hidden: true }}
|
||||
titleKey = 'addPeople.inviteMorePrompt'>
|
||||
<div className = 'invite-more-dialog'>
|
||||
{ _inviteContactsVisible && <InviteContactsSection /> }
|
||||
{_urlSharingVisible ? <CopyMeetingLinkSection url = { _inviteUrl } /> : null}
|
||||
{
|
||||
_emailSharingVisible
|
||||
? <InviteByEmailSection
|
||||
inviteSubject = { inviteSubject }
|
||||
inviteText = { _invitationText }
|
||||
inviteTextiOS = { _invitationTextiOS } />
|
||||
: null
|
||||
}
|
||||
<div className = 'invite-more-dialog separator' />
|
||||
{
|
||||
_liveStreamViewURL
|
||||
&& <LiveStreamSection liveStreamViewURL = { _liveStreamViewURL } />
|
||||
}
|
||||
{
|
||||
_phoneNumber
|
||||
&& _dialInVisible
|
||||
&& <DialInSection phoneNumber = { _phoneNumber } />
|
||||
}
|
||||
{
|
||||
!_phoneNumber && _dialInVisible && _isDialInOverLimit && <DialInLimit />
|
||||
}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code AddPeopleDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The properties explicitly passed to the component.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
|
||||
const currentLiveStreamingSession
|
||||
= getActiveSession(state, JitsiRecordingConstants.mode.STREAM);
|
||||
const { iAmRecorder, inviteAppName } = state['features/base/config'];
|
||||
const addPeopleEnabled = isAddPeopleEnabled(state);
|
||||
const dialOutEnabled = isDialOutEnabled(state);
|
||||
const hideInviteContacts = iAmRecorder || (!addPeopleEnabled && !dialOutEnabled);
|
||||
const dialIn = state['features/invite']; // @ts-ignore
|
||||
const phoneNumber = dialIn?.numbers ? _getDefaultPhoneNumber(dialIn.numbers) : undefined;
|
||||
const isDialInOverLimit = dialIn?.error?.status === StatusCode.PaymentRequired;
|
||||
|
||||
return {
|
||||
_dialIn: dialIn,
|
||||
_dialInVisible: isSharingEnabled(sharingFeatures.dialIn),
|
||||
_urlSharingVisible: isDynamicBrandingDataLoaded(state) && isSharingEnabled(sharingFeatures.url),
|
||||
_emailSharingVisible: isSharingEnabled(sharingFeatures.email),
|
||||
_invitationText: getInviteText({ state,
|
||||
phoneNumber,
|
||||
t: ownProps.t }),
|
||||
_invitationTextiOS: getInviteTextiOS({ state,
|
||||
phoneNumber,
|
||||
t: ownProps.t }),
|
||||
_inviteAppName: inviteAppName,
|
||||
_inviteContactsVisible: interfaceConfig.ENABLE_DIAL_OUT && !hideInviteContacts,
|
||||
_inviteUrl: getInviteURL(state),
|
||||
_isDialInOverLimit: isDialInOverLimit,
|
||||
_liveStreamViewURL: currentLiveStreamingSession?.liveStreamViewURL,
|
||||
_phoneNumber: phoneNumber
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps dispatching of some action to React component props.
|
||||
*
|
||||
* @param {Function} dispatch - Redux action dispatcher.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
const mapDispatchToProps = {
|
||||
updateNumbers: () => updateDialInNumbers()
|
||||
};
|
||||
|
||||
export default translate(
|
||||
connect(mapStateToProps, mapDispatchToProps)(AddPeopleDialog)
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import CopyButton from '../../../../base/buttons/CopyButton.web';
|
||||
import { getDecodedURI } from '../../../../base/util/uri';
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The URL of the conference.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
label: {
|
||||
display: 'block',
|
||||
marginBottom: theme.spacing(2)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component meant to enable users to copy the conference URL.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function CopyMeetingLinkSection({ url }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className = { classes.label }>{t('addPeople.shareLink')}</p>
|
||||
<CopyButton
|
||||
accessibilityText = { t('addPeople.accessibilityLabel.meetingLink', { url: getDecodedURI(url) }) }
|
||||
className = 'invite-more-dialog-conference-url'
|
||||
displayedText = { getDecodedURI(url) }
|
||||
id = 'add-people-copy-link-button'
|
||||
textOnCopySuccess = { t('addPeople.linkCopied') }
|
||||
textOnHover = { t('addPeople.copyLink') }
|
||||
textToCopy = { url } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyMeetingLinkSection;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { UPGRADE_OPTIONS_LINK, UPGRADE_OPTIONS_TEXT } from '../../../constants';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
limitContainer: {
|
||||
backgroundColor: theme.palette.warning01,
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px'
|
||||
},
|
||||
limitInfo: {
|
||||
color: theme.palette.text.primary,
|
||||
...theme.typography.bodyShortRegular
|
||||
},
|
||||
link: {
|
||||
color: `${theme.palette.text.primary} !important`,
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that displays a message when the dial in limit is reached.
|
||||
* * @param {Function} t - Function which translate strings.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
const DialInLimit: React.FC<WithTranslation> = ({ t }) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { classes.limitContainer }>
|
||||
<span className = { classes.limitInfo }>
|
||||
<b>{ `${t('info.dialInNumber')} ` }</b>
|
||||
{ `${t('info.reachedLimit')} `}
|
||||
{ `${t('info.upgradeOptions')} ` }
|
||||
<a
|
||||
className = { classes.link }
|
||||
href = { UPGRADE_OPTIONS_LINK }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ `${UPGRADE_OPTIONS_TEXT}` }
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate(DialInLimit);
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconCheck, IconCopy } from '../../../../base/icons/svg';
|
||||
import Tooltip from '../../../../base/tooltip/components/Tooltip';
|
||||
import { copyText } from '../../../../base/util/copyText.web';
|
||||
import { showSuccessNotification } from '../../../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../../notifications/constants';
|
||||
import { _formatConferenceIDPin } from '../../../_utils';
|
||||
|
||||
let mounted: boolean;
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link DialInNumber}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The numeric identifier for the current conference, used after dialing a
|
||||
* the number to join the conference.
|
||||
*/
|
||||
conferenceID: string | number;
|
||||
|
||||
/**
|
||||
* The phone number to dial to begin the process of dialing into a
|
||||
* conference.
|
||||
*/
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Component responsible for displaying a telephone number and
|
||||
* conference ID for dialing into a conference and copying them to clipboard.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
function DialInNumber({ conferenceID, phoneNumber, t }: IProps) {
|
||||
const dispatch = useDispatch();
|
||||
const [ isClicked, setIsClicked ] = useState(false);
|
||||
const dialInLabel = t('info.dialInNumber');
|
||||
const passcode = t('info.dialInConferenceID');
|
||||
const conferenceIDPin = `${_formatConferenceIDPin(conferenceID)}#`;
|
||||
const textToCopy = `${dialInLabel} ${phoneNumber} ${passcode} ${conferenceIDPin}`;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
mounted = true;
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Copies the conference ID and phone number to the clipboard.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onCopyText() {
|
||||
copyText(textToCopy);
|
||||
dispatch(showSuccessNotification({
|
||||
titleKey: 'dialog.copied'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
setIsClicked(true);
|
||||
setTimeout(() => {
|
||||
// avoid: Can't perform a React state update on an unmounted component
|
||||
if (mounted) {
|
||||
setIsClicked(false);
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the conference invitation to the clipboard.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onCopyTextKeyPress(e: React.KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_onCopyText();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders section that shows the phone number and conference ID
|
||||
* and give user the ability to copy them to the clipboard.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
return (
|
||||
<div className = 'dial-in-number'>
|
||||
<p>
|
||||
<span className = 'phone-number'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInNumber') }
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ phoneNumber }
|
||||
</span>
|
||||
</span>
|
||||
<br />
|
||||
<span className = 'conference-id'>
|
||||
<span className = 'info-label'>
|
||||
{ t('info.dialInConferenceID') }
|
||||
</span>
|
||||
<span className = 'spacer'> </span>
|
||||
<span className = 'info-value'>
|
||||
{ `${_formatConferenceIDPin(conferenceID)}#` }
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<Tooltip
|
||||
content = { t('info.copyNumber') }
|
||||
position = 'top'>
|
||||
<button
|
||||
aria-label = { t('info.copyNumber') }
|
||||
className = 'dial-in-copy invisible-button'
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick = { _onCopyText }
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onKeyPress = { _onCopyTextKeyPress }>
|
||||
<Icon src = { isClicked ? IconCheck : IconCopy } />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate(DialInNumber);
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { getDialInfoPageURL, hasMultipleNumbers } from '../../../functions';
|
||||
|
||||
import DialInNumber from './DialInNumber';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The phone number to dial to begin the process of dialing into a
|
||||
* conference.
|
||||
*/
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
'& .info-label': {
|
||||
...theme.typography.bodyLongBold
|
||||
}
|
||||
},
|
||||
|
||||
link: {
|
||||
...theme.typography.bodyLongRegular,
|
||||
color: theme.palette.link01,
|
||||
|
||||
'&:hover': {
|
||||
color: theme.palette.link01Hover
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a ReactElement for showing how to dial into the conference, if
|
||||
* dialing in is available.
|
||||
*
|
||||
* @private
|
||||
* @returns {null|ReactElement}
|
||||
*/
|
||||
function DialInSection({
|
||||
phoneNumber
|
||||
}: IProps) {
|
||||
const { classes, cx } = useStyles();
|
||||
const conferenceID = useSelector((state: IReduxState) => state['features/invite'].conferenceID);
|
||||
const dialInfoPageUrl: string = useSelector(getDialInfoPageURL);
|
||||
const showMoreNumbers = useSelector((state: IReduxState) => hasMultipleNumbers(state['features/invite'].numbers));
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<DialInNumber
|
||||
conferenceID = { conferenceID ?? '' }
|
||||
phoneNumber = { phoneNumber } />
|
||||
{showMoreNumbers ? <a
|
||||
className = { cx('more-numbers', classes.link) }
|
||||
href = { dialInfoPageUrl }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
{ t('info.moreNumbers') }
|
||||
</a> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DialInSection;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createToolbarEvent } from '../../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../../analytics/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { IconAddUser } from '../../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../../base/toolbox/components/AbstractButton';
|
||||
import { beginAddPeople } from '../../../actions.any';
|
||||
|
||||
/**
|
||||
* Implementation of a button for opening invite people dialog.
|
||||
*/
|
||||
class InviteButton extends AbstractButton<AbstractButtonProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.invite';
|
||||
override icon = IconAddUser;
|
||||
override label = 'toolbar.invite';
|
||||
override tooltip = 'toolbar.invite';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent('invite'));
|
||||
dispatch(beginAddPeople());
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(InviteButton));
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isIosMobileBrowser } from '../../../../base/environment/utils';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconEnvelope,
|
||||
IconGoogle,
|
||||
IconOffice365,
|
||||
IconYahoo
|
||||
} from '../../../../base/icons/svg';
|
||||
import Tooltip from '../../../../base/tooltip/components/Tooltip';
|
||||
import { copyText } from '../../../../base/util/copyText.web';
|
||||
import { showSuccessNotification } from '../../../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../../notifications/constants';
|
||||
|
||||
let mounted: boolean;
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The encoded invitation subject.
|
||||
*/
|
||||
inviteSubject: string;
|
||||
|
||||
/**
|
||||
* The encoded invitation text to be sent.
|
||||
*/
|
||||
inviteText: string;
|
||||
|
||||
/**
|
||||
* The encoded no new-lines iOS invitation text to be sent on default mail.
|
||||
*/
|
||||
inviteTextiOS: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: theme.spacing(4)
|
||||
},
|
||||
|
||||
label: {
|
||||
marginBottom: theme.spacing(2)
|
||||
},
|
||||
|
||||
iconRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
iconContainer: {
|
||||
display: 'block',
|
||||
padding: theme.spacing(2),
|
||||
cursor: 'pointer'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders email invite options.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
function InviteByEmailSection({ inviteSubject, inviteText, inviteTextiOS, t }: IProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { classes } = useStyles();
|
||||
const [ isClicked, setIsClicked ] = useState(false);
|
||||
const encodedInviteSubject = encodeURIComponent(inviteSubject);
|
||||
const encodedInviteText = encodeURIComponent(inviteText);
|
||||
const encodedInviteTextiOS = encodeURIComponent(inviteTextiOS);
|
||||
|
||||
const encodedDefaultEmailText = isIosMobileBrowser() ? encodedInviteTextiOS : encodedInviteText;
|
||||
|
||||
useEffect(() => {
|
||||
mounted = true;
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Copies the conference invitation to the clipboard.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onCopyText() {
|
||||
copyText(inviteText);
|
||||
dispatch(showSuccessNotification({
|
||||
titleKey: 'dialog.copied'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
setIsClicked(true);
|
||||
setTimeout(() => {
|
||||
// avoid: Can't perform a React state update on an unmounted component
|
||||
if (mounted) {
|
||||
setIsClicked(false);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the conference invitation to the clipboard.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onCopyTextKeyPress(e: React.KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_onCopyText();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders clickable elements that each open an email client
|
||||
* containing a conference invite.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
function renderEmailIcons() {
|
||||
const PROVIDER_MAPPING = [
|
||||
{
|
||||
icon: IconEnvelope,
|
||||
tooltipKey: 'addPeople.defaultEmail',
|
||||
url: `mailto:?subject=${encodedInviteSubject}&body=${encodedDefaultEmailText}`
|
||||
},
|
||||
{
|
||||
icon: IconGoogle,
|
||||
tooltipKey: 'addPeople.googleEmail',
|
||||
url: `https://mail.google.com/mail/?view=cm&fs=1&su=${encodedInviteSubject}&body=${encodedInviteText}`
|
||||
},
|
||||
{
|
||||
icon: IconOffice365,
|
||||
tooltipKey: 'addPeople.outlookEmail',
|
||||
// eslint-disable-next-line max-len
|
||||
url: `https://outlook.office.com/mail/deeplink/compose?subject=${encodedInviteSubject}&body=${encodedInviteText}`
|
||||
},
|
||||
{
|
||||
icon: IconYahoo,
|
||||
tooltipKey: 'addPeople.yahooEmail',
|
||||
url: `https://compose.mail.yahoo.com/?To=&Subj=${encodedInviteSubject}&Body=${encodedInviteText}`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
PROVIDER_MAPPING.map(({ icon, tooltipKey, url }, idx) => (
|
||||
<Tooltip
|
||||
content = { t(tooltipKey) }
|
||||
key = { idx }
|
||||
position = 'top'>
|
||||
<a
|
||||
aria-label = { t(tooltipKey) }
|
||||
className = { classes.iconContainer }
|
||||
href = { url }
|
||||
rel = 'noopener noreferrer'
|
||||
target = '_blank'>
|
||||
<Icon src = { icon } />
|
||||
</a>
|
||||
</Tooltip>
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className = { classes.container }>
|
||||
<p className = { classes.label }>{t('addPeople.shareInvite')}</p>
|
||||
<div className = { classes.iconRow }>
|
||||
<Tooltip
|
||||
content = { t('addPeople.copyInvite') }
|
||||
position = 'top'>
|
||||
<div
|
||||
aria-label = { t('addPeople.copyInvite') }
|
||||
className = { classes.iconContainer }
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick = { _onCopyText }
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onKeyPress = { _onCopyTextKeyPress }
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
<Icon src = { isClicked ? IconCheck : IconCopy } />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{renderEmailIcons()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate(InviteByEmailSection);
|
||||
@@ -0,0 +1,517 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../../app/types';
|
||||
import Avatar from '../../../../base/avatar/components/Avatar';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconEnvelope, IconPhoneRinging, IconUser } from '../../../../base/icons/svg';
|
||||
import MultiSelectAutocomplete from '../../../../base/react/components/web/MultiSelectAutocomplete';
|
||||
import Button from '../../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.any';
|
||||
import { isVpaasMeeting } from '../../../../jaas/functions';
|
||||
import { hideAddPeopleDialog } from '../../../actions.web';
|
||||
import { INVITE_TYPES } from '../../../constants';
|
||||
import { IInviteSelectItem, IInvitee } from '../../../types';
|
||||
import AbstractAddPeopleDialog, {
|
||||
IProps as AbstractProps,
|
||||
IState,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractAddPeopleDialog';
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
formWrap: {
|
||||
marginTop: theme.spacing(2)
|
||||
},
|
||||
inviteButtons: {
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: theme.spacing(2),
|
||||
'& .invite-button': {
|
||||
marginLeft: theme.spacing(2)
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
/**
|
||||
* The {@link JitsiMeetConference} which will be used to invite "room" participants.
|
||||
*/
|
||||
_conference?: Object;
|
||||
|
||||
/**
|
||||
* Whether the meeting belongs to JaaS user.
|
||||
*/
|
||||
_isVpaas?: boolean;
|
||||
|
||||
/**
|
||||
* Css classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* The redux {@code dispatch} function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form that enables inviting others to the call.
|
||||
*/
|
||||
class InviteContactsForm extends AbstractAddPeopleDialog<IProps, IState> {
|
||||
_multiselect: MultiSelectAutocomplete | null = null;
|
||||
|
||||
_resourceClient: {
|
||||
makeQuery: (query: string) => Promise<Array<any>>;
|
||||
parseResults: Function;
|
||||
};
|
||||
|
||||
_translations: {
|
||||
[key: string]: string;
|
||||
_addPeopleEnabled: string;
|
||||
_dialOutEnabled: string;
|
||||
_sipInviteEnabled: string;
|
||||
};
|
||||
|
||||
override state = {
|
||||
addToCallError: false,
|
||||
addToCallInProgress: false,
|
||||
inviteItems: [] as IInviteSelectItem[]
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code AddPeopleDialog} instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onClearItems = this._onClearItems.bind(this);
|
||||
this._onClearItemsKeyPress = this._onClearItemsKeyPress.bind(this);
|
||||
this._onItemSelected = this._onItemSelected.bind(this);
|
||||
this._onSelectionChange = this._onSelectionChange.bind(this);
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
this._onSubmitKeyPress = this._onSubmitKeyPress.bind(this);
|
||||
this._parseQueryResults = this._parseQueryResults.bind(this);
|
||||
this._setMultiSelectElement = this._setMultiSelectElement.bind(this);
|
||||
this._onKeyDown = this._onKeyDown.bind(this);
|
||||
|
||||
this._resourceClient = {
|
||||
makeQuery: this._query,
|
||||
parseResults: this._parseQueryResults
|
||||
};
|
||||
|
||||
|
||||
const { t } = props;
|
||||
|
||||
this._translations = {
|
||||
_dialOutEnabled: t('addPeople.phoneNumbers'),
|
||||
_addPeopleEnabled: t('addPeople.contacts'),
|
||||
_sipInviteEnabled: t('addPeople.sipAddresses')
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* React Component method that executes once component is updated.
|
||||
*
|
||||
* @param {Props} prevProps - The props object before the update.
|
||||
* @param {State} prevState - The state object before the update.
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps, prevState: IState) {
|
||||
/**
|
||||
* Clears selected items from the multi select component on successful
|
||||
* invite.
|
||||
*/
|
||||
if (prevState.addToCallError
|
||||
&& !this.state.addToCallInProgress
|
||||
&& !this.state.addToCallError
|
||||
&& this._multiselect) {
|
||||
this._multiselect.setSelectedItems([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of this component.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_addPeopleEnabled,
|
||||
_dialOutEnabled,
|
||||
_isVpaas,
|
||||
_sipInviteEnabled,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
let isMultiSelectDisabled = this.state.addToCallInProgress;
|
||||
const loadingMessage = 'addPeople.searching';
|
||||
const noMatches = 'addPeople.noResults';
|
||||
|
||||
const features: { [key: string]: boolean; } = {
|
||||
_dialOutEnabled,
|
||||
_addPeopleEnabled,
|
||||
_sipInviteEnabled
|
||||
};
|
||||
|
||||
const computedPlaceholder = Object.keys(features)
|
||||
.filter(v => Boolean(features[v]))
|
||||
.map(v => this._translations[v])
|
||||
.join(', ');
|
||||
|
||||
const placeholder = computedPlaceholder ? `${t('dialog.add')} ${computedPlaceholder}` : t('addPeople.disabled');
|
||||
|
||||
if (!computedPlaceholder) {
|
||||
isMultiSelectDisabled = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { classes.formWrap }
|
||||
onKeyDown = { this._onKeyDown }>
|
||||
<MultiSelectAutocomplete
|
||||
id = 'invite-contacts-input'
|
||||
isDisabled = { isMultiSelectDisabled }
|
||||
loadingMessage = { t(loadingMessage) }
|
||||
noMatchesFound = { t(noMatches) }
|
||||
onItemSelected = { this._onItemSelected }
|
||||
onSelectionChange = { this._onSelectionChange }
|
||||
placeholder = { placeholder }
|
||||
ref = { this._setMultiSelectElement }
|
||||
resourceClient = { this._resourceClient }
|
||||
shouldFitContainer = { true }
|
||||
shouldFocus = { true }
|
||||
showSupportLink = { !_isVpaas } />
|
||||
{ this._renderFormActions() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when a selection has been made but before it has been
|
||||
* set as selected.
|
||||
*
|
||||
* @param {IInviteSelectItem} item - The item that has just been selected.
|
||||
* @private
|
||||
* @returns {Object} The item to display as selected in the input.
|
||||
*/
|
||||
_onItemSelected(item: IInviteSelectItem) {
|
||||
if (item.item.type === INVITE_TYPES.PHONE) {
|
||||
item.content = item.item.number;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a selection change.
|
||||
*
|
||||
* @param {Array<IInviteSelectItem>} selectedItems - The list of selected items.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelectionChange(selectedItems: IInviteSelectItem[]) {
|
||||
this.setState({
|
||||
inviteItems: selectedItems
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Submits the selection for inviting.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSubmit() {
|
||||
const { inviteItems } = this.state;
|
||||
const invitees = inviteItems.map(({ item }) => item);
|
||||
|
||||
this._invite(invitees)
|
||||
.then((invitesLeftToSend: IInvitee[]) => {
|
||||
if (invitesLeftToSend.length) {
|
||||
const unsentInviteIDs
|
||||
= invitesLeftToSend.map(invitee =>
|
||||
invitee.id || invitee.user_id || invitee.number);
|
||||
const itemsToSelect = inviteItems.filter(({ item }) =>
|
||||
unsentInviteIDs.includes(item.id || item.user_id || item.number));
|
||||
|
||||
if (this._multiselect) {
|
||||
this._multiselect.setSelectedItems(itemsToSelect);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => this.props.dispatch(hideAddPeopleDialog()));
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyPress handler for accessibility.
|
||||
*
|
||||
* @param {KeyboardEvent} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSubmitKeyPress(e: React.KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles 'Enter' key in the form to trigger the invite.
|
||||
*
|
||||
* @param {KeyboardEvent} event - The key event.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyDown(event: React.KeyboardEvent) {
|
||||
const { inviteItems } = this.state;
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!this._isAddDisabled() && inviteItems.length) {
|
||||
this._onSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the avatar component for a user.
|
||||
*
|
||||
* @param {any} user - The user.
|
||||
* @param {string} className - The CSS class for the avatar component.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_getAvatar(user: any, className = 'avatar-small') {
|
||||
const defaultIcon = user.type === INVITE_TYPES.EMAIL ? IconEnvelope : IconUser;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className = { className }
|
||||
defaultIcon = { defaultIcon }
|
||||
size = { 32 }
|
||||
status = { user.status }
|
||||
url = { user.avatar } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes results from requesting available numbers and people by munging
|
||||
* each result into a format {@code MultiSelectAutocomplete} can use for
|
||||
* display.
|
||||
*
|
||||
* @param {Array} response - The response object from the server for the
|
||||
* query.
|
||||
* @private
|
||||
* @returns {Object[]} Configuration objects for items to display in the
|
||||
* search autocomplete.
|
||||
*/
|
||||
_parseQueryResults(response: IInvitee[] = []) {
|
||||
const { t, _dialOutEnabled } = this.props;
|
||||
|
||||
const userTypes = [ INVITE_TYPES.USER, INVITE_TYPES.EMAIL, INVITE_TYPES.VIDEO_ROOM, INVITE_TYPES.ROOM ];
|
||||
const users = response.filter(item => userTypes.includes(item.type));
|
||||
const userDisplayItems: any = [];
|
||||
|
||||
for (const user of users) {
|
||||
const { name, phone } = user;
|
||||
const tagAvatar = this._getAvatar(user, 'avatar-xsmall');
|
||||
const elemAvatar = this._getAvatar(user);
|
||||
|
||||
userDisplayItems.push({
|
||||
content: name,
|
||||
elemBefore: elemAvatar,
|
||||
item: user,
|
||||
tag: {
|
||||
elemBefore: tagAvatar
|
||||
},
|
||||
value: user.id || user.user_id
|
||||
});
|
||||
|
||||
if (phone && _dialOutEnabled) {
|
||||
userDisplayItems.push({
|
||||
filterValues: [ name, phone ],
|
||||
content: `${phone} (${name})`,
|
||||
elemBefore: elemAvatar,
|
||||
item: {
|
||||
type: INVITE_TYPES.PHONE,
|
||||
number: phone
|
||||
},
|
||||
tag: {
|
||||
elemBefore: tagAvatar
|
||||
},
|
||||
value: phone
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const numbers = response.filter(item => item.type === INVITE_TYPES.PHONE);
|
||||
const telephoneIcon = this._renderTelephoneIcon();
|
||||
|
||||
const numberDisplayItems = numbers.map(number => {
|
||||
const numberNotAllowedMessage
|
||||
= number.allowed ? '' : t('addPeople.countryNotSupported');
|
||||
const countryCodeReminder = number.showCountryCodeReminder
|
||||
? t('addPeople.countryReminder') : '';
|
||||
const description
|
||||
= `${numberNotAllowedMessage} ${countryCodeReminder}`.trim();
|
||||
|
||||
return {
|
||||
filterValues: [
|
||||
number.originalEntry,
|
||||
number.number
|
||||
],
|
||||
content: t('addPeople.telephone', { number: number.number }),
|
||||
description,
|
||||
isDisabled: !number.allowed,
|
||||
elemBefore: telephoneIcon,
|
||||
item: number,
|
||||
tag: {
|
||||
elemBefore: telephoneIcon
|
||||
},
|
||||
value: number.number
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const sipAddresses = response.filter(item => item.type === INVITE_TYPES.SIP);
|
||||
|
||||
const sipDisplayItems = sipAddresses.map(sip => {
|
||||
return {
|
||||
filterValues: [
|
||||
sip.address
|
||||
],
|
||||
content: sip.address,
|
||||
description: '',
|
||||
item: sip,
|
||||
value: sip.address
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...userDisplayItems,
|
||||
...numberDisplayItems,
|
||||
...sipDisplayItems
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected items from state and form.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClearItems() {
|
||||
if (this._multiselect) {
|
||||
this._multiselect.setSelectedItems([]);
|
||||
}
|
||||
this.setState({ inviteItems: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected items from state and form.
|
||||
*
|
||||
* @param {KeyboardEvent} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClearItemsKeyPress(e: KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onClearItems();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the add/cancel actions for the form.
|
||||
*
|
||||
* @returns {ReactElement|null}
|
||||
*/
|
||||
_renderFormActions() {
|
||||
const { inviteItems } = this.state;
|
||||
const { t } = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
if (!inviteItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.inviteButtons }>
|
||||
<Button
|
||||
aria-label = { t('dialog.Cancel') }
|
||||
className = 'invite-button'
|
||||
label = { t('dialog.Cancel') }
|
||||
onClick = { this._onClearItems }
|
||||
onKeyPress = { this._onClearItemsKeyPress }
|
||||
role = 'button'
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<Button
|
||||
aria-label = { t('addPeople.add') }
|
||||
className = 'invite-button'
|
||||
disabled = { this._isAddDisabled() }
|
||||
label = { t('addPeople.add') }
|
||||
onClick = { this._onSubmit }
|
||||
onKeyPress = { this._onSubmitKeyPress }
|
||||
role = 'button' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a telephone icon.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderTelephoneIcon() {
|
||||
return (
|
||||
<Icon src = { IconPhoneRinging } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the instance variable for the multi select component
|
||||
* element so it can be accessed directly.
|
||||
*
|
||||
* @param {MultiSelectAutocomplete} element - The DOM element for the component's dialog.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setMultiSelectElement(element: MultiSelectAutocomplete) {
|
||||
this._multiselect = element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code AddPeopleDialog}'s props.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @private
|
||||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
_isVpaas: isVpaasMeeting(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(withStyles(InviteContactsForm, styles)));
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import InviteContactsForm from './InviteContactsForm';
|
||||
|
||||
/**
|
||||
* Component that represents the invitation section of the {@code AddPeopleDialog}.
|
||||
*
|
||||
* @returns {ReactElement$<any>}
|
||||
*/
|
||||
function InviteContactsSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>{t('addPeople.addContacts')}</span>
|
||||
<InviteContactsForm />
|
||||
<div className = 'invite-more-dialog separator' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteContactsSection;
|
||||
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconCheck, IconCopy } from '../../../../base/icons/svg';
|
||||
import { copyText } from '../../../../base/util/copyText.web';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The current known URL for a live stream in progress.
|
||||
*/
|
||||
liveStreamViewURL: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section of the {@code AddPeopleDialog} that renders the
|
||||
* live streaming url, allowing a copy action.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function LiveStreamSection({ liveStreamViewURL, t }: IProps) {
|
||||
const [ isClicked, setIsClicked ] = useState(false);
|
||||
const [ isHovered, setIsHovered ] = useState(false);
|
||||
|
||||
/**
|
||||
* Click handler for the element.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
async function onClick() {
|
||||
setIsHovered(false);
|
||||
|
||||
const isCopied = await copyText(liveStreamViewURL);
|
||||
|
||||
if (isCopied) {
|
||||
setIsClicked(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsClicked(false);
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover handler for the element.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function onHoverIn() {
|
||||
if (!isClicked) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover handler for the element.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function onHoverOut() {
|
||||
setIsHovered(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of the link based on the state.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function renderLinkContent() {
|
||||
if (isClicked) {
|
||||
return (
|
||||
<>
|
||||
<div className = 'invite-more-dialog stream-text selected'>
|
||||
{t('addPeople.linkCopied')}
|
||||
</div>
|
||||
<Icon src = { IconCheck } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className = 'invite-more-dialog stream-text'>
|
||||
{isHovered ? t('addPeople.copyStream') : liveStreamViewURL}
|
||||
</div>
|
||||
<Icon src = { IconCopy } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>{t('addPeople.shareStream')}</span>
|
||||
<div
|
||||
className = { `invite-more-dialog stream${isClicked ? ' clicked' : ''}` }
|
||||
onClick = { onClick }
|
||||
onMouseOut = { onHoverOut }
|
||||
onMouseOver = { onHoverIn }>
|
||||
{ renderLinkContent() }
|
||||
</div>
|
||||
<div className = 'invite-more-dialog separator' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate(LiveStreamSection);
|
||||
167
react/features/invite/components/callee-info/CalleeInfo.tsx
Normal file
167
react/features/invite/components/callee-info/CalleeInfo.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
@@ -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')!);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
60
react/features/invite/constants.ts
Normal file
60
react/features/invite/constants.ts
Normal 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';
|
||||
985
react/features/invite/functions.ts
Normal file
985
react/features/invite/functions.ts
Normal 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}®ion=${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'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
3
react/features/invite/logger.ts
Normal file
3
react/features/invite/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/invite');
|
||||
206
react/features/invite/middleware.any.ts
Normal file
206
react/features/invite/middleware.any.ts
Normal 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());
|
||||
}
|
||||
0
react/features/invite/middleware.native.ts
Normal file
0
react/features/invite/middleware.native.ts
Normal file
66
react/features/invite/middleware.web.ts
Normal file
66
react/features/invite/middleware.web.ts
Normal 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);
|
||||
}
|
||||
107
react/features/invite/reducer.ts
Normal file
107
react/features/invite/reducer.ts
Normal 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;
|
||||
});
|
||||
44
react/features/invite/sounds.ts
Normal file
44
react/features/invite/sounds.ts
Normal 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' } ]
|
||||
]);
|
||||
20
react/features/invite/types.ts
Normal file
20
react/features/invite/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user