feat(invite) add email autocomplete in invite (#14610)

* feat(invite) add email value in peopleSearchQueryTypes and peopleSearchToken config

* feat(invite) pass custom auth token in search directory (e. g. for email directory)

* feat(invite) autocomplete and invitations working with custom auth token (e. g. email invite type), invite icons updated

* feat(invite) remove newly documented config from undocumented settings list

* feat(invite) jwt are now passed in the invite requests headers

* feat(invite) linter-related formatting

* feat(invite) fix default user icon regression

* feat(invite) last lint issues

* feat(invite) pass alternate token in header, not in params

* Fixes lint error

---------

Co-authored-by: Raphaël Badawi <raphael.badawi@ceo-vision.com>
Co-authored-by: Дамян Минков <damencho@jitsi.org>
This commit is contained in:
Raphaël Badawi
2024-08-01 20:01:12 +02:00
committed by GitHub
parent 4d79bbb5d8
commit 1e101afe5a
9 changed files with 116 additions and 32 deletions

View File

@@ -1549,6 +1549,17 @@ var config = {
// and will automatically redirect to the token service to get the token for the meeting.
// tokenAuthUrlAutoRedirect: false
// You can put an array of values to target different entity types in the invite dialog.
// Valid values are "phone", "room", "sip", "user", "videosipgw" and "email"
// peopleSearchQueryTypes: ["user", "email"],
// Directory endpoint which is called for invite dialog autocomplete
// peopleSearchUrl: "https://myservice.com/api/people",
// Endpoint which is called to send invitation requests
// inviteServiceUrl: "https://myservice.com/api/invite",
// For external entities (e. g. email), the localStorage key holding the token value for directory authentication
// peopleSearchTokenLocation: "mytoken",
// List of undocumented settings used in jitsi-meet
/**
_immediateReloadThreshold
@@ -1565,8 +1576,6 @@ var config = {
iAmRecorder
iAmSipGateway
microsoftApiApplicationClientID
peopleSearchQueryTypes
peopleSearchUrl
requireDisplayName
*/

View File

@@ -48,6 +48,11 @@ export interface IProps {
*/
colorBase?: string;
/**
* Indicates the default icon for the avatar.
*/
defaultIcon?: string;
/**
* Display name of the entity to render an avatar for (if any). This is handy when we need
* an avatar for a non-participant entity (e.g. A recent list item).
@@ -112,6 +117,7 @@ class Avatar<P extends IProps> extends PureComponent<P, IState> {
* @static
*/
static defaultProps = {
defaultIcon: IconUser,
dynamicColor: true
};
@@ -172,6 +178,7 @@ class Avatar<P extends IProps> extends PureComponent<P, IState> {
_loadableAvatarUrlUseCORS,
className,
colorBase,
defaultIcon,
dynamicColor,
id,
size,
@@ -229,7 +236,7 @@ class Avatar<P extends IProps> extends PureComponent<P, IState> {
}
if (navigator.product !== 'ReactNative') {
avatarProps.iconUser = IconUser;
avatarProps.iconUser = defaultIcon;
}
return (

View File

@@ -477,6 +477,7 @@ export interface IConfig {
};
pcStatsInterval?: number;
peopleSearchQueryTypes?: string[];
peopleSearchTokenLocation?: string;
peopleSearchUrl?: string;
preferBosh?: boolean;
preferVisitor?: boolean;

View File

@@ -82,14 +82,19 @@ export function invite(
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.
return new Promise(resolve => {
dispatch(addPendingInviteRequest({
invitees,
callback: (failedInvitees: any) => resolve(failedInvitees)
}));
});
if (jitsiInvitees.length) {
return new Promise(resolve => {
dispatch(addPendingInviteRequest({
invitees: jitsiInvitees,
callback: (failedInvitees: any) => resolve(failedInvitees)
}));
});
}
}
let allInvitePromises: Promise<any>[] = [];
@@ -112,10 +117,12 @@ export function invite(
// For each number, dial out. On success, remove the number from
// {@link invitesLeftToSend}.
const phoneInvitePromises = phoneNumbers.map(item => {
const numberToInvite = item.number;
const phoneInvitePromises = typeof conference === 'undefined'
? []
: phoneNumbers.map(item => {
const numberToInvite = item.number;
return conference.dial(numberToInvite)
return conference.dial(numberToInvite)
.then(() => {
invitesLeftToSend
= invitesLeftToSend.filter(
@@ -123,13 +130,13 @@ export function invite(
})
.catch((error: Error) =>
logger.error('Error inviting phone number:', error));
});
});
allInvitePromises = allInvitePromises.concat(phoneInvitePromises);
const usersAndRooms
= invitesLeftToSend.filter(
({ type }) => [ INVITE_TYPES.USER, INVITE_TYPES.ROOM ].includes(type));
({ 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,
@@ -139,12 +146,12 @@ export function invite(
(callFlowsEnabled
? inviteServiceCallFlowsUrl : inviteServiceUrl) ?? '',
inviteUrl,
jwt,
usersAndRooms)
usersAndRooms,
state)
.then(() => {
invitesLeftToSend
= invitesLeftToSend.filter(
({ type }) => ![ INVITE_TYPES.USER, INVITE_TYPES.ROOM ].includes(type));
({ type }) => ![ INVITE_TYPES.USER, INVITE_TYPES.EMAIL, INVITE_TYPES.ROOM ].includes(type));
})
.catch(error => {
dispatch(setCalleeInfoVisible(false));

View File

@@ -61,6 +61,11 @@ export interface IProps {
*/
_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.
*/
@@ -254,6 +259,7 @@ export default class AbstractAddPeopleDialog<P extends IProps, S extends IState>
_jwt: jwt,
_peopleSearchQueryTypes: peopleSearchQueryTypes,
_peopleSearchUrl: peopleSearchUrl,
_peopleSearchTokenLocation: peopleSearchTokenLocation,
_region: region,
_sipInviteEnabled: sipInviteEnabled
} = this.props;
@@ -266,6 +272,7 @@ export default class AbstractAddPeopleDialog<P extends IProps, S extends IState>
jwt,
peopleSearchQueryTypes,
peopleSearchUrl,
peopleSearchTokenLocation,
region,
sipInviteEnabled
};
@@ -295,7 +302,8 @@ export function _mapStateToProps(state: IReduxState) {
dialOutAuthUrl,
dialOutRegionUrl,
peopleSearchQueryTypes,
peopleSearchUrl
peopleSearchUrl,
peopleSearchTokenLocation
} = state['features/base/config'];
return {
@@ -308,6 +316,7 @@ export function _mapStateToProps(state: IReduxState) {
_jwt: state['features/base/jwt'].jwt ?? '',
_peopleSearchQueryTypes: peopleSearchQueryTypes ?? [],
_peopleSearchUrl: peopleSearchUrl ?? '',
_peopleSearchTokenLocation: peopleSearchTokenLocation ?? '',
_region: getMeetingRegion(state),
_sipInviteEnabled: isSipInviteEnabled(state)
};

View File

@@ -19,6 +19,7 @@ import Icon from '../../../../base/icons/components/Icon';
import {
IconCheck,
IconCloseCircle,
IconEnvelope,
IconPhoneRinging,
IconSearch,
IconShare
@@ -260,6 +261,12 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
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;
}
@@ -273,7 +280,11 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
* @returns {string}
*/
_keyExtractor(item: any) {
return item.type === INVITE_TYPES.USER ? item.id || item.user_id : item.number;
if (item.type === INVITE_TYPES.USER || item.type === INVITE_TYPES.EMAIL) {
return item.id || item.user_id;
}
return item.number;
}
/**
@@ -451,6 +462,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
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));

View File

@@ -7,7 +7,7 @@ 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 { IconPhoneRinging } from '../../../../base/icons/svg';
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';
@@ -302,9 +302,12 @@ class InviteContactsForm extends AbstractAddPeopleDialog<IProps, IState> {
* @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 } />
@@ -325,7 +328,7 @@ class InviteContactsForm extends AbstractAddPeopleDialog<IProps, IState> {
_parseQueryResults(response: IInvitee[] = []) {
const { t, _dialOutEnabled } = this.props;
const userTypes = [ INVITE_TYPES.USER, INVITE_TYPES.VIDEO_ROOM, INVITE_TYPES.ROOM ];
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 = [];

View File

@@ -48,6 +48,7 @@ export const SIP_ADDRESS_REGEX = /^[+a-zA-Z0-9]+(?:([^\s>:@]+)(?::([^\s@>]+))?@)
* Different invite types mapping.
*/
export const INVITE_TYPES = {
EMAIL: 'email',
PHONE: 'phone',
ROOM: 'room',
SIP: 'sip',

View File

@@ -1,3 +1,6 @@
// @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';
@@ -143,6 +146,11 @@ export type GetInviteResultsOptions = {
*/
peopleSearchQueryTypes: Array<string>;
/**
* Key in localStorage holding the alternative token for people directory.
*/
peopleSearchTokenLocation?: string;
/**
* The url to query for people.
*/
@@ -181,6 +189,7 @@ export function getInviteResultsForQuery(
dialOutEnabled,
peopleSearchQueryTypes,
peopleSearchUrl,
peopleSearchTokenLocation,
region,
sipInviteEnabled,
jwt
@@ -193,7 +202,8 @@ export function getInviteResultsForQuery(
peopleSearchUrl,
jwt,
text,
peopleSearchQueryTypes);
peopleSearchQueryTypes,
peopleSearchTokenLocation);
} else {
peopleSearchPromise = Promise.resolve([]);
}
@@ -411,29 +421,40 @@ export function getInviteTypeCounts(inviteItems: IInvitee[] = []) {
* @param {string} inviteServiceUrl - The invite service that generates the
* invitation.
* @param {string} inviteUrl - The url to the conference.
* @param {string} jwt - The jwt token to pass to the search service.
* @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,
jwt: string,
inviteItems: Array<Object>
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 = {
...jwt ? { 'Authorization': `Bearer ${jwt}` } : {},
...token ? { 'Authorization': `Bearer ${token}` } : {},
'Content-Type': 'application/json'
};
return fetch(
`${inviteServiceUrl}`,
inviteServiceUrl,
{
body: JSON.stringify({
'invited': inviteItems,
@@ -452,9 +473,14 @@ export function invitePeopleAndChatRooms(
* @returns {boolean} Indication of whether adding people is currently enabled.
*/
export function isAddPeopleEnabled(state: IReduxState): boolean {
const { peopleSearchUrl } = state['features/base/config'];
const {
peopleSearchUrl,
peopleSearchTokenLocation
} = state['features/base/config'];
return Boolean(state['features/base/jwt'].jwt && Boolean(peopleSearchUrl) && !isVpaasMeeting(state));
const hasToken = Boolean(state['features/base/jwt'].jwt || Boolean(peopleSearchTokenLocation));
return Boolean(hasToken && Boolean(peopleSearchUrl) && !isVpaasMeeting(state));
}
/**
@@ -534,21 +560,30 @@ function isPhoneNumberRegex(): RegExp {
* @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".
* 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' ]
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 = {
...jwt ? { 'Authorization': `Bearer ${jwt}` } : {}
...token ? { 'Authorization': `Bearer ${token}` } : {}
};
return fetch(`${serviceUrl}?query=${query}&queryTypes=${