From 1e101afe5ae228ac80327b19adb8ead5e626eabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Badawi?= <58936818+raphaelbadawi@users.noreply.github.com> Date: Thu, 1 Aug 2024 20:01:12 +0200 Subject: [PATCH] feat(invite) add email autocomplete in invite (#14610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Дамян Минков --- config.js | 13 ++++- .../base/avatar/components/Avatar.tsx | 9 ++- react/features/base/config/configType.ts | 1 + react/features/invite/actions.any.ts | 35 +++++++----- .../AbstractAddPeopleDialog.tsx | 11 +++- .../native/AddPeopleDialog.tsx | 14 ++++- .../web/InviteContactsForm.tsx | 7 ++- react/features/invite/constants.ts | 1 + react/features/invite/functions.ts | 57 +++++++++++++++---- 9 files changed, 116 insertions(+), 32 deletions(-) diff --git a/config.js b/config.js index 6380b0a70ab3..a14f348adf34 100644 --- a/config.js +++ b/config.js @@ -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 */ diff --git a/react/features/base/avatar/components/Avatar.tsx b/react/features/base/avatar/components/Avatar.tsx index 7aaf187cb6be..3cd131bcc650 100644 --- a/react/features/base/avatar/components/Avatar.tsx +++ b/react/features/base/avatar/components/Avatar.tsx @@ -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

extends PureComponent { * @static */ static defaultProps = { + defaultIcon: IconUser, dynamicColor: true }; @@ -172,6 +178,7 @@ class Avatar

extends PureComponent { _loadableAvatarUrlUseCORS, className, colorBase, + defaultIcon, dynamicColor, id, size, @@ -229,7 +236,7 @@ class Avatar

extends PureComponent { } if (navigator.product !== 'ReactNative') { - avatarProps.iconUser = IconUser; + avatarProps.iconUser = defaultIcon; } return ( diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index 94523f9f8fb0..52386194e370 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -477,6 +477,7 @@ export interface IConfig { }; pcStatsInterval?: number; peopleSearchQueryTypes?: string[]; + peopleSearchTokenLocation?: string; peopleSearchUrl?: string; preferBosh?: boolean; preferVisitor?: boolean; diff --git a/react/features/invite/actions.any.ts b/react/features/invite/actions.any.ts index 315c79bc437a..43bd90199220 100644 --- a/react/features/invite/actions.any.ts +++ b/react/features/invite/actions.any.ts @@ -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[] = []; @@ -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)); diff --git a/react/features/invite/components/add-people-dialog/AbstractAddPeopleDialog.tsx b/react/features/invite/components/add-people-dialog/AbstractAddPeopleDialog.tsx index e70d0ff7f7fe..98b0fd8f82b1 100644 --- a/react/features/invite/components/add-people-dialog/AbstractAddPeopleDialog.tsx +++ b/react/features/invite/components/add-people-dialog/AbstractAddPeopleDialog.tsx @@ -61,6 +61,11 @@ export interface IProps { */ _peopleSearchQueryTypes: Array; + /** + * 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

_jwt: jwt, _peopleSearchQueryTypes: peopleSearchQueryTypes, _peopleSearchUrl: peopleSearchUrl, + _peopleSearchTokenLocation: peopleSearchTokenLocation, _region: region, _sipInviteEnabled: sipInviteEnabled } = this.props; @@ -266,6 +272,7 @@ export default class AbstractAddPeopleDialog

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) }; diff --git a/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.tsx b/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.tsx index 13eb042cca8d..6a88365a4f31 100644 --- a/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.tsx +++ b/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.tsx @@ -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 { 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 { * @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 { 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)); diff --git a/react/features/invite/components/add-people-dialog/web/InviteContactsForm.tsx b/react/features/invite/components/add-people-dialog/web/InviteContactsForm.tsx index 5b68c760a05c..6482eaa3c2c3 100644 --- a/react/features/invite/components/add-people-dialog/web/InviteContactsForm.tsx +++ b/react/features/invite/components/add-people-dialog/web/InviteContactsForm.tsx @@ -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 { * @returns {ReactElement} */ _getAvatar(user: any, className = 'avatar-small') { + const defaultIcon = user.type === INVITE_TYPES.EMAIL ? IconEnvelope : IconUser; + return ( @@ -325,7 +328,7 @@ class InviteContactsForm extends AbstractAddPeopleDialog { _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 = []; diff --git a/react/features/invite/constants.ts b/react/features/invite/constants.ts index a4b6c31078cc..0ad03482f9bd 100644 --- a/react/features/invite/constants.ts +++ b/react/features/invite/constants.ts @@ -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', diff --git a/react/features/invite/functions.ts b/react/features/invite/functions.ts index 44147ba3a65d..872b6bdba876 100644 --- a/react/features/invite/functions.ts +++ b/react/features/invite/functions.ts @@ -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; + /** + * 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 + inviteItems: Array, + state: IReduxState ): Promise { 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} 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 = [ 'conferenceRooms', 'user', 'room' ] + queryTypes: Array = [ 'conferenceRooms', 'user', 'room', 'email' ], + peopleSearchTokenLocation?: string ): Promise> { 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=${