snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
@@ -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)));
|
||||
Reference in New Issue
Block a user