diff --git a/lang/main.json b/lang/main.json index 639f000a3..bc8172457 100644 --- a/lang/main.json +++ b/lang/main.json @@ -627,7 +627,8 @@ "stopVideo": "Stop video", "unblockEveryoneMicCamera": "Unblock everyone's mic and camera", "videoModeration": "Start their video" - } + }, + "search": "Search participants" }, "passwordSetRemotely": "Set by another participant", "passwordDigitsOnly": "Up to {{number}} digits", diff --git a/react/features/base/icons/svg/close-solid.svg b/react/features/base/icons/svg/close-solid.svg new file mode 100644 index 000000000..854346976 --- /dev/null +++ b/react/features/base/icons/svg/close-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 4409de55a..6e3ff727c 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -29,6 +29,7 @@ export { default as IconCheck } from './check.svg'; export { default as IconCheckSolid } from './check-solid.svg'; export { default as IconClose } from './close.svg'; export { default as IconCloseCircle } from './close-circle.svg'; +export { default as IconCloseSolid } from './close-solid.svg'; export { default as IconCloseX } from './close-x.svg'; export { default as IconClosedCaption } from './closed_caption.svg'; export { default as IconCloseSmall } from './close-small.svg'; diff --git a/react/features/base/util/strings.native.js b/react/features/base/util/strings.native.js index 154e6df50..f7595dab8 100644 --- a/react/features/base/util/strings.native.js +++ b/react/features/base/util/strings.native.js @@ -12,3 +12,14 @@ import * as unorm from 'unorm'; export function normalizeNFKC(text: string) { return unorm.nfkc(text); } + +/** + * Replaces accent characters with english alphabet characters. + * NOTE: Here we use the unorm package because the JSC version in React Native for Android crashes. + * + * @param {string} text - The text that needs to be normalized. + * @returns {string} - The normalized text. + */ +export function normalizeAccents(text: string) { + return unorm.nfd(text).replace(/[\u0300-\u036f]/g, ''); +} diff --git a/react/features/base/util/strings.web.js b/react/features/base/util/strings.web.js index e07c55893..5113c33d4 100644 --- a/react/features/base/util/strings.web.js +++ b/react/features/base/util/strings.web.js @@ -9,3 +9,14 @@ export function normalizeNFKC(text: string) { return text.normalize('NFKC'); } + +/** + * Replaces accent characters with english alphabet characters. + * NOTE: Here we use the unorm package because the JSC version in React Native for Android crashes. + * + * @param {string} text - The text that needs to be normalized. + * @returns {string} - The normalized text. + */ +export function normalizeAccents(text: string) { + return text.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} diff --git a/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js b/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js index e12de1c4b..702cc3d65 100644 --- a/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js +++ b/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js @@ -5,8 +5,6 @@ import React from 'react'; import { ActivityIndicator, FlatList, - Platform, - TextInput, TouchableOpacity, View } from 'react-native'; @@ -18,7 +16,6 @@ import { Icon, IconCancelSelection, IconCheck, - IconClose, IconPhone, IconSearch, IconShare @@ -29,6 +26,7 @@ import { type Item } from '../../../../base/react'; import { connect } from '../../../../base/redux'; +import ClearableInput from '../../../../participants-pane/components/native/ClearableInput'; import { beginShareRoom } from '../../../../share-room'; import { INVITE_TYPES } from '../../../constants'; import AbstractAddPeopleDialog, { @@ -106,11 +104,6 @@ class AddPeopleDialog extends AbstractAddPeopleDialog { selectableItems: [] }; - /** - * Ref of the search field. - */ - inputFieldRef: ?TextInput; - /** * TimeoutID to delay the search for the time the user is probably typing. */ @@ -136,7 +129,6 @@ class AddPeopleDialog extends AbstractAddPeopleDialog { this._onShareMeeting = this._onShareMeeting.bind(this); this._onTypeQuery = this._onTypeQuery.bind(this); this._renderShareMeetingButton = this._renderShareMeetingButton.bind(this); - this._setFieldRef = this._setFieldRef.bind(this); } /** @@ -220,33 +212,27 @@ class AddPeopleDialog extends AbstractAddPeopleDialog { footerComponent = { this._renderShareMeetingButton } hasTabNavigator = { false } style = { styles.addPeopleContainer }> - - - { this.state.searchInprogress + + {this.state.searchInprogress ? : } - - - { this._renderClearButton() } - + } + value = { this.state.fieldValue } /> { Boolean(inviteItems.length) && { this._onTypeQuery(''); } - _onFocused: boolean => Function; - - /** - * Constructs a callback to be used to update the padding of the field if necessary. - * - * @param {boolean} focused - True of the field is focused. - * @returns {Function} - */ - _onFocused(focused) { - return () => { - Platform.OS === 'android' && this.setState({ - bottomPadding: focused - }); - }; - } - _onInvite: () => void /** @@ -458,37 +428,12 @@ class AddPeopleDialog extends AbstractAddPeopleDialog { .finally(() => { this.setState({ searchInprogress: false - }, () => { - this.inputFieldRef && this.inputFieldRef.focus(); }); }); } _query: (string) => Promise>; - /** - * Renders a button to clear the text field. - * - * @returns {React#Element<*>} - */ - _renderClearButton() { - if (!this.state.fieldValue.length) { - return null; - } - - return ( - - - - - - ); - } - _renderInvitedItem: Object => React$Element | null /** @@ -619,18 +564,6 @@ class AddPeopleDialog extends AbstractAddPeopleDialog { ); } - _setFieldRef: ?TextInput => void - - /** - * Sets a reference to the input field for later use. - * - * @param {?TextInput} input - The reference to the input field. - * @returns {void} - */ - _setFieldRef(input) { - this.inputFieldRef = input; - } - /** * Shows an alert telling the user that some invitees were failed to be * invited. diff --git a/react/features/invite/components/add-people-dialog/native/styles.js b/react/features/invite/components/add-people-dialog/native/styles.js index 4de19ca79..52da72c1f 100644 --- a/react/features/invite/components/add-people-dialog/native/styles.js +++ b/react/features/invite/components/add-people-dialog/native/styles.js @@ -31,13 +31,11 @@ export default { }, clearButton: { - alignItems: 'center', - justifyContent: 'center', - marginLeft: 5 + paddingTop: 7 }, clearIcon: { - color: DARK_GREY, + color: BaseTheme.palette.ui02, fontSize: 18, textAlign: 'center' }, @@ -100,7 +98,9 @@ export default { color: DARK_GREY, flex: 1, fontSize: 17, - paddingVertical: 7 + paddingVertical: 7, + paddingLeft: 0, + textAlign: 'left' }, selectedIcon: { @@ -117,11 +117,15 @@ export default { }, searchFieldWrapper: { + backgroundColor: BaseTheme.palette.section01, alignItems: 'stretch', flexDirection: 'row', - height: 52, - paddingHorizontal: 12, - paddingVertical: 8 + height: 36, + marginHorizontal: 15, + marginVertical: 8, + borderWidth: 0, + borderRadius: 10, + overflow: 'hidden' }, searchIcon: { @@ -132,8 +136,6 @@ export default { searchIconWrapper: { alignItems: 'center', backgroundColor: BaseTheme.palette.section01, - borderBottomLeftRadius: 10, - borderTopLeftRadius: 10, flexDirection: 'row', justifyContent: 'center', width: ICON_SIZE + 16 diff --git a/react/features/participants-pane/components/native/ClearableInput.js b/react/features/participants-pane/components/native/ClearableInput.js new file mode 100644 index 000000000..f4bb727d2 --- /dev/null +++ b/react/features/participants-pane/components/native/ClearableInput.js @@ -0,0 +1,195 @@ +// @flow + +import React, { useCallback, useEffect, useState } from 'react'; +import { View, TextInput, TouchableOpacity } from 'react-native'; +import { withTheme } from 'react-native-paper'; + +import { Icon, IconCloseSolid } from '../../../base/icons'; + +import styles from './styles'; + +type Props = { + + /** + * If the input should be focused on display. + */ + autoFocus?: boolean, + + /** + * Custom styles for the component. + */ + customStyles?: Object, + + /** + * Callback for the onBlur event of the field. + */ + onBlur?: Function, + + /** + * Callback for the onChange event of the field. + */ + onChange: Function, + + /** + * Callback for the onFocus event of the field. + */ + onFocus?: Function, + + /** + * Callback to be used when the user hits Enter in the field. + */ + onSubmit?: Function, + + /** + * Placeholder text for the field. + */ + placeholder: string, + + /** + * Placeholder text color. + */ + placeholderColor?: string, + + /** + * Component to be added to the beginning of the the input. + */ + prefixComponent?: React$Node, + + /** + * The type of the return key. + */ + returnKeyType?: 'done' | 'go' | 'next' | 'search' | 'send' | 'none' | 'previous' | 'default', + + /** + * Color of the caret and selection. + */ + selectionColor?: string, + + /** + * Theme used for styles. + */ + theme: Object, + + /** + * Externally provided value. + */ + value?: string +}; + +/** + * Implements a pre-styled clearable input field. + * + * @param {Props} props - The props of the component. + * @returns {ReactElement} + */ +function ClearableInput({ + autoFocus = false, + customStyles = {}, + onBlur, + onChange, + onFocus, + onSubmit, + placeholder, + placeholderColor, + prefixComponent, + returnKeyType = 'search', + selectionColor, + theme, + value +}: Props) { + const [ val, setVal ] = useState(value || ''); + const [ focused, setFocused ] = useState(false); + const inputRef = React.createRef(); + + useEffect(() => { + if (value && value !== val) { + setVal(value); + } + }, [ value ]); + + + /** + * Callback for the onBlur event of the field. + * + * @returns {void} + */ + const _onBlur = useCallback(() => { + setFocused(false); + + onBlur && onBlur(); + }, [ onBlur ]); + + /** + * Callback for the onChange event of the field. + * + * @param {Object} evt - The static event. + * @returns {void} + */ + const _onChange = useCallback(evt => { + const { nativeEvent: { text } } = evt; + + setVal(text); + onChange && onChange(text); + }, [ onChange ]); + + /** + * Callback for the onFocus event of the field. + * + * @returns {void} + */ + const _onFocus = useCallback(() => { + setFocused(true); + + onFocus && onFocus(); + }, [ onFocus ]); + + /** + * Clears the input. + * + * @returns {void} + */ + const _clearInput = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + setVal(''); + onChange && onChange(''); + }, [ onChange ]); + + return ( + + {prefixComponent} + + {val !== '' && ( + + + + )} + + ); +} + +export default withTheme(ClearableInput); diff --git a/react/features/participants-pane/components/native/MeetingParticipantItem.js b/react/features/participants-pane/components/native/MeetingParticipantItem.js index 6067199b3..3bcc13e62 100644 --- a/react/features/participants-pane/components/native/MeetingParticipantItem.js +++ b/react/features/participants-pane/components/native/MeetingParticipantItem.js @@ -5,7 +5,6 @@ import React, { PureComponent } from 'react'; import { translate } from '../../../base/i18n'; import { getLocalParticipant, - getParticipantByIdOrUndefined, getParticipantDisplayName, hasRaisedHand, isParticipantModerator @@ -80,9 +79,9 @@ type Props = { dispatch: Function, /** - * The ID of the participant. + * The participant. */ - participantID: ?string + participant: ?Object }; /** @@ -171,10 +170,9 @@ class MeetingParticipantItem extends PureComponent { * @returns {Props} */ function mapStateToProps(state, ownProps): Object { - const { participantID } = ownProps; + const { participant } = ownProps; const { ownerId } = state['features/shared-video']; const localParticipantId = getLocalParticipant(state).id; - const participant = getParticipantByIdOrUndefined(state, participantID); const _isAudioMuted = isParticipantAudioMuted(participant, state); const _isVideoMuted = isParticipantVideoMuted(participant, state); const audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state); diff --git a/react/features/participants-pane/components/native/MeetingParticipantList.js b/react/features/participants-pane/components/native/MeetingParticipantList.js index ba243262e..a7684e6b2 100644 --- a/react/features/participants-pane/components/native/MeetingParticipantList.js +++ b/react/features/participants-pane/components/native/MeetingParticipantList.js @@ -2,30 +2,38 @@ import React, { PureComponent } from 'react'; import { FlatList, Text, View } from 'react-native'; -import { Button } from 'react-native-paper'; +import { Button, withTheme } from 'react-native-paper'; import { translate } from '../../../base/i18n'; import { Icon, IconInviteMore } from '../../../base/icons'; -import { getLocalParticipant, getParticipantCountWithFake } from '../../../base/participants'; +import { getLocalParticipant, getParticipantCountWithFake, getRemoteParticipants } from '../../../base/participants'; import { connect } from '../../../base/redux'; +import { normalizeAccents } from '../../../base/util/strings'; import { doInvitePeople } from '../../../invite/actions.native'; import { shouldRenderInviteButton } from '../../functions'; +import ClearableInput from './ClearableInput'; import MeetingParticipantItem from './MeetingParticipantItem'; import styles from './styles'; + type Props = { /** - * The ID of the local participant. + * The local participant. */ - _localParticipantId: string, + _localParticipant: Object, /** * The number of participants in the conference. */ _participantsCount: number, + /** + * The remote participants. + */ + _remoteParticipants: Map, + /** * Whether or not to show the invite button. */ @@ -44,13 +52,22 @@ type Props = { /** * Translation function. */ - t: Function + t: Function, + + /** + * Theme used for styles. + */ + theme: Object } +type State = { + searchString: string +}; + /** * The meeting participant list component. */ -class MeetingParticipantList extends PureComponent { +class MeetingParticipantList extends PureComponent { /** * Creates new MeetingParticipantList instance. @@ -60,9 +77,14 @@ class MeetingParticipantList extends PureComponent { constructor(props: Props) { super(props); + this.state = { + searchString: '' + }; + this._keyExtractor = this._keyExtractor.bind(this); this._onInvite = this._onInvite.bind(this); this._renderParticipant = this._renderParticipant.bind(this); + this._onSearchStringChange = this._onSearchStringChange.bind(this); } _keyExtractor: Function; @@ -111,11 +133,49 @@ class MeetingParticipantList extends PureComponent { * @returns {ReactElement} */ _renderParticipant({ item/* , index, separators */ }) { - return ( - - ); + const { _localParticipant, _remoteParticipants } = this.props; + const { searchString } = this.state; + const participant = item === _localParticipant?.id ? _localParticipant : _remoteParticipants.get(item); + const displayName = participant?.name; + + if (displayName) { + const names = normalizeAccents(displayName) + .toLowerCase() + .split(' '); + const lowerCaseSearch = normalizeAccents(searchString).toLowerCase(); + + for (const name of names) { + if (lowerCaseSearch === '' || name.startsWith(lowerCaseSearch)) { + return ( + + ); + } + } + } else if (displayName === '' && searchString === '') { + return ( + + ); + } + + return null; + } + + _onSearchStringChange: (text: string) => void; + + /** + * Handles search string changes. + * + * @param {string} text - New value of the search string. + * @returns {void} + */ + _onSearchStringChange(text: string) { + this.setState({ + searchString: text + }); } /** @@ -126,7 +186,7 @@ class MeetingParticipantList extends PureComponent { */ render() { const { - _localParticipantId, + _localParticipant, _participantsCount, _showInviteButton, _sortedRemoteParticipants, @@ -150,9 +210,13 @@ class MeetingParticipantList extends PureComponent { onPress = { this._onInvite } style = { styles.inviteButton } /> } + { + return { + clearableInput: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + height: '20px', + border: `1px solid ${theme.palette.border02}`, + backgroundColor: theme.palette.uiBackground, + position: 'relative', + borderRadius: '6px', + padding: '10px 16px', + + '&.focused': { + border: `3px solid ${theme.palette.field01Focus}` + } + }, + clearButton: { + backgroundColor: 'transparent', + border: 0, + position: 'absolute', + right: '10px', + top: '11px', + padding: 0, + + '& svg': { + fill: theme.palette.icon02 + } + }, + input: { + backgroundColor: 'transparent', + border: 0, + width: '100%', + height: '100%', + borderRadius: '6px', + fontSize: '14px', + lineHeight: '20px', + textAlign: 'center', + caretColor: theme.palette.text01, + color: theme.palette.text01, + + '&::placeholder': { + color: theme.palette.text03 + } + } + }; +}); + +/** + * Implements a pre-styled clearable input field. + * + * @param {Props} props - The props of the component. + * @returns {ReactElement} + */ +function ClearableInput({ + autoFocus = false, + autoComplete, + className = '', + id, + onChange, + onSubmit, + placeholder, + testId, + type = 'text', + value +}: Props) { + const classes = useStyles(); + const [ val, setVal ] = useState(value || ''); + const [ focused, setFocused ] = useState(false); + const inputRef = React.createRef(); + + useEffect(() => { + if (value && value !== val) { + setVal(value); + } + }, [ value ]); + + + /** + * Callback for the onBlur event of the field. + * + * @returns {void} + */ + const _onBlur = useCallback(() => { + setFocused(false); + }); + + /** + * Callback for the onChange event of the field. + * + * @param {Object} evt - The static event. + * @returns {void} + */ + const _onChange = useCallback(evt => { + const newValue = evt.target.value; + + setVal(newValue); + onChange && onChange(newValue); + }, [ onChange ]); + + /** + * Callback for the onFocus event of the field. + * + * @returns {void} + */ + const _onFocus = useCallback(() => { + setFocused(true); + }); + + /** + * Joins the conference on 'Enter'. + * + * @param {Event} event - Key down event object. + * @returns {void} + */ + const _onKeyDown = useCallback(event => { + onSubmit && event.key === 'Enter' && onSubmit(); + }, [ onSubmit ]); + + /** + * Clears the input. + * + * @returns {void} + */ + const _clearInput = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + setVal(''); + onChange && onChange(''); + }, [ onChange ]); + + return ( +
+ + {val !== '' && ( + + )} +
+ ); +} + +export default ClearableInput; diff --git a/react/features/participants-pane/components/web/MeetingParticipantItem.js b/react/features/participants-pane/components/web/MeetingParticipantItem.js index d0077f0bb..ca52858bb 100644 --- a/react/features/participants-pane/components/web/MeetingParticipantItem.js +++ b/react/features/participants-pane/components/web/MeetingParticipantItem.js @@ -19,6 +19,7 @@ import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks'; +import { normalizeAccents } from '../../../base/util/strings'; import { ACTION_TRIGGER, type MediaState, MEDIA_STATE } from '../../constants'; import { getParticipantAudioMediaState, @@ -72,6 +73,11 @@ type Props = { */ _localVideoOwner: boolean, + /** + * Whether or not the participant name matches the search string. + */ + _matchesSearch: boolean, + /** * The participant. */ @@ -175,6 +181,7 @@ function MeetingParticipantItem({ _displayName, _local, _localVideoOwner, + _matchesSearch, _participant, _participantID, _quickActionButtonType, @@ -222,6 +229,10 @@ function MeetingParticipantItem({ }; }, [ _audioTrack ]); + if (!_matchesSearch) { + return null; + } + const audioMediaState = _audioMediaState === MEDIA_STATE.UNMUTED && hasAudioLevels ? MEDIA_STATE.DOMINANT_SPEAKER : _audioMediaState; @@ -280,12 +291,31 @@ function MeetingParticipantItem({ * @returns {Props} */ function _mapStateToProps(state, ownProps): Object { - const { participantID } = ownProps; + const { participantID, searchString } = ownProps; const { ownerId } = state['features/shared-video']; const localParticipantId = getLocalParticipant(state).id; const participant = getParticipantByIdOrUndefined(state, participantID); + const _displayName = getParticipantDisplayName(state, participant?.id); + + let _matchesSearch = false; + const names = normalizeAccents(_displayName) + .toLowerCase() + .split(' '); + const lowerCaseSearchString = searchString.toLowerCase(); + + if (lowerCaseSearchString === '') { + _matchesSearch = true; + } else { + for (const name of names) { + if (name.startsWith(lowerCaseSearchString)) { + _matchesSearch = true; + break; + } + } + } + const _isAudioMuted = isParticipantAudioMuted(participant, state); const _isVideoMuted = isParticipantVideoMuted(participant, state); const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state); @@ -302,9 +332,10 @@ function _mapStateToProps(state, ownProps): Object { _audioMediaState, _audioTrack, _disableModeratorIndicator: disableModeratorIndicator, - _displayName: getParticipantDisplayName(state, participant?.id), + _displayName, _local: Boolean(participant?.local), _localVideoOwner: Boolean(ownerId === localParticipantId), + _matchesSearch, _participant: participant, _participantID: participant?.id, _quickActionButtonType, diff --git a/react/features/participants-pane/components/web/MeetingParticipantItems.js b/react/features/participants-pane/components/web/MeetingParticipantItems.js index 19cb3fa2c..afebbb0b2 100644 --- a/react/features/participants-pane/components/web/MeetingParticipantItems.js +++ b/react/features/participants-pane/components/web/MeetingParticipantItems.js @@ -56,6 +56,11 @@ type Props = { */ participantActionEllipsisLabel: string, + /** + * Current search string. + */ + searchString?: string, + /** * The translated "you" text. */ @@ -78,8 +83,9 @@ function MeetingParticipantItems({ overflowDrawer, raiseContextId, participantActionEllipsisLabel, + searchString, youText -}) { +}: Props) { const renderParticipant = id => ( ); diff --git a/react/features/participants-pane/components/web/MeetingParticipants.js b/react/features/participants-pane/components/web/MeetingParticipants.js index 765fc5dad..c660227bf 100644 --- a/react/features/participants-pane/components/web/MeetingParticipants.js +++ b/react/features/participants-pane/components/web/MeetingParticipants.js @@ -11,11 +11,13 @@ import { getParticipantCountWithFake } from '../../../base/participants'; import { connect } from '../../../base/redux'; +import { normalizeAccents } from '../../../base/util/strings'; import { showOverflowDrawer } from '../../../toolbox/functions'; import { muteRemote } from '../../../video-menu/actions.any'; import { findStyledAncestor, getSortedParticipantIds, shouldRenderInviteButton } from '../../functions'; import { useParticipantDrawer } from '../../hooks'; +import ClearableInput from './ClearableInput'; import { InviteButton } from './InviteButton'; import MeetingParticipantContextMenu from './MeetingParticipantContextMenu'; import MeetingParticipantItems from './MeetingParticipantItems'; @@ -56,6 +58,7 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw const isMouseOverMenu = useRef(false); const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState); + const [ searchString, setSearchString ] = useState(''); const { t } = useTranslation(); const lowerMenu = useCallback(() => { @@ -123,6 +126,9 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw <> {t('participantsPane.headings.participantsList', { count: participantsCount })} {showInviteButton && } +