diff --git a/modules/API/API.js b/modules/API/API.js index e29d3d69f..4c4fcbb01 100644 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -23,7 +23,8 @@ import { getParticipantById, pinParticipant, kickParticipant, - raiseHand + raiseHand, + isParticipantModerator } from '../../react/features/base/participants'; import { updateSettings } from '../../react/features/base/settings'; import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks'; @@ -105,13 +106,14 @@ function initCommands() { const muteMediaType = mediaType ? mediaType : MEDIA_TYPE.AUDIO; sendAnalytics(createApiEvent('muted-everyone')); - const participants = APP.store.getState()['features/base/participants']; - const localIds = participants - .filter(participant => participant.local) - .filter(participant => participant.role === 'moderator') - .map(participant => participant.id); + const localParticipant = getLocalParticipant(APP.store.getState()); + const exclude = []; - APP.store.dispatch(muteAllParticipants(localIds, muteMediaType)); + if (localParticipant && isParticipantModerator(localParticipant)) { + exclude.push(localParticipant.id); + } + + APP.store.dispatch(muteAllParticipants(exclude, muteMediaType)); }, 'toggle-lobby': isLobbyEnabled => { APP.store.dispatch(toggleLobbyMode(isLobbyEnabled)); diff --git a/react/features/av-moderation/actions.js b/react/features/av-moderation/actions.js index 917c30388..2c9b6e6f9 100644 --- a/react/features/av-moderation/actions.js +++ b/react/features/av-moderation/actions.js @@ -49,24 +49,24 @@ export const disableModeration = (mediaType: MediaType, actor: Object) => { /** * Hides the notification with the participant that asked to unmute audio. * - * @param {string} id - The participant id. + * @param {Object} participant - The participant for which the notification to be hidden. * @returns {Object} */ -export function dismissPendingAudioParticipant(id: string) { - return dismissPendingParticipant(id, MEDIA_TYPE.AUDIO); +export function dismissPendingAudioParticipant(participant: Object) { + return dismissPendingParticipant(participant, MEDIA_TYPE.AUDIO); } /** * Hides the notification with the participant that asked to unmute. * - * @param {string} id - The participant id. + * @param {Object} participant - The participant for which the notification to be hidden. * @param {MediaType} mediaType - The media type. * @returns {Object} */ -export function dismissPendingParticipant(id: string, mediaType: MediaType) { +export function dismissPendingParticipant(participant: Object, mediaType: MediaType) { return { type: DISMISS_PENDING_PARTICIPANT, - id, + participant, mediaType }; } @@ -145,13 +145,13 @@ export function showModeratedNotification(mediaType: MediaType) { /** * Shows a notification with the participant that asked to audio unmute. * - * @param {string} id - The participant id. + * @param {Object} participant - The participant for which is the notification. * @returns {Object} */ -export function participantPendingAudio(id: string) { +export function participantPendingAudio(participant: Object) { return { type: PARTICIPANT_PENDING_AUDIO, - id + participant }; } diff --git a/react/features/av-moderation/components/AudioModerationNotifications.js b/react/features/av-moderation/components/AudioModerationNotifications.js index a282f8bd1..cd15831fd 100644 --- a/react/features/av-moderation/components/AudioModerationNotifications.js +++ b/react/features/av-moderation/components/AudioModerationNotifications.js @@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import NotificationWithParticipants from '../../notifications/components/web/NotificationWithParticipants'; -import { approveAudio, dismissPendingAudioParticipant } from '../actions'; +import { + approveParticipant, + dismissPendingAudioParticipant +} from '../actions'; import { getParticipantsAskingToAudioUnmute } from '../functions'; @@ -25,7 +28,7 @@ export default function() { state['features/av-moderation']; +/** + * We use to construct once the empty array so we can keep the same instance between calls + * of getParticipantsAskingToAudioUnmute. + * + * @type {*[]} + */ +const EMPTY_ARRAY = []; + /** * Returns whether moderation is enabled per media type. * @@ -33,6 +41,17 @@ export const isEnabledFromState = (mediaType: MediaType, state: Object) => */ export const isEnabled = (mediaType: MediaType) => (state: Object) => isEnabledFromState(mediaType, state); +/** + * Returns whether moderation is supported by the backend. + * + * @returns {null|boolean} + */ +export const isSupported = () => (state: Object) => { + const { conference } = state['features/base/conference']; + + return conference ? conference.isAVModerationSupported() : false; +}; + /** * Returns whether local participant is approved to unmute a media type. * @@ -74,15 +93,15 @@ export const isParticipantApproved = (id: string, mediaType: MediaType) => (stat /** * Returns a selector creator which determines if the participant is pending or not for a media type. * - * @param {string} id - The participant id. + * @param {Participant} participant - The participant. * @param {MEDIA_TYPE} mediaType - The media type to check. * @returns {boolean} */ -export const isParticipantPending = (id: string, mediaType: MediaType) => (state: Object) => { +export const isParticipantPending = (participant: Object, mediaType: MediaType) => (state: Object) => { const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType]; const arr = getState(state)[storeKey]; - return Boolean(arr.find(pending => pending === id)); + return Boolean(arr.find(pending => pending.id === participant.id)); }; /** @@ -94,12 +113,10 @@ export const isParticipantPending = (id: string, mediaType: MediaType) => (state */ export const getParticipantsAskingToAudioUnmute = (state: Object) => { if (isLocalParticipantModerator(state)) { - const ids = getState(state).pendingAudio; - - return ids.map(id => getParticipantById(state, id)).filter(Boolean); + return getState(state).pendingAudio; } - return []; + return EMPTY_ARRAY; }; /** diff --git a/react/features/av-moderation/middleware.js b/react/features/av-moderation/middleware.js index 863491691..748494c4d 100644 --- a/react/features/av-moderation/middleware.js +++ b/react/features/av-moderation/middleware.js @@ -127,14 +127,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { // this is handled only by moderators if (audioModerationEnabled && isLocalParticipantModerator(state)) { - const { participant: { id, raisedHand } } = action; + const participant = action.participant; - if (raisedHand) { + if (participant.raisedHand) { // if participant raises hand show notification - !isParticipantApproved(id, MEDIA_TYPE.AUDIO)(state) && dispatch(participantPendingAudio(id)); + !isParticipantApproved(participant.id, MEDIA_TYPE.AUDIO)(state) + && dispatch(participantPendingAudio(participant)); } else { // if participant lowers hand hide notification - isParticipantPending(id, MEDIA_TYPE.AUDIO)(state) && dispatch(dismissPendingAudioParticipant(id)); + isParticipantPending(participant, MEDIA_TYPE.AUDIO)(state) + && dispatch(dismissPendingAudioParticipant(participant)); } } diff --git a/react/features/av-moderation/reducer.js b/react/features/av-moderation/reducer.js index d78695201..e5b501ccc 100644 --- a/react/features/av-moderation/reducer.js +++ b/react/features/av-moderation/reducer.js @@ -1,6 +1,11 @@ /* @flow */ import { MEDIA_TYPE } from '../base/media/constants'; +import type { MediaType } from '../base/media/constants'; +import { + PARTICIPANT_LEFT, + PARTICIPANT_UPDATED +} from '../base/participants'; import { ReducerRegistry } from '../base/redux'; import { @@ -11,6 +16,7 @@ import { PARTICIPANT_APPROVED, PARTICIPANT_PENDING_AUDIO } from './actionTypes'; +import { MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants'; const initialState = { audioModerationEnabled: false, @@ -21,6 +27,41 @@ const initialState = { pendingVideo: [] }; +/** + Updates a participant in the state for the specified media type. + * + * @param {MediaType} mediaType - The media type. + * @param {Object} participant - Information about participant to be modified. + * @param {Object} state - The current state. + * @private + * @returns {boolean} - Whether state instance was modified. + */ +function _updatePendingParticipant(mediaType: MediaType, participant, state: Object = {}) { + let arrayItemChanged = false; + const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType]; + const arr = state[storeKey]; + const newArr = arr.map(pending => { + if (pending.id === participant.id) { + arrayItemChanged = true; + + return { + ...pending, + ...participant + }; + } + + return pending; + }); + + if (arrayItemChanged) { + state[storeKey] = newArr; + + return true; + } + + return false; +} + ReducerRegistry.register('features/av-moderation', (state = initialState, action) => { switch (action.type) { @@ -65,13 +106,13 @@ ReducerRegistry.register('features/av-moderation', (state = initialState, action } case PARTICIPANT_PENDING_AUDIO: { - const { id } = action; + const { participant } = action; - // Add participant to pendigAudio array only if it's not already added - if (!state.pendingAudio.find(pending => pending === id)) { + // Add participant to pendingAudio array only if it's not already added + if (!state.pendingAudio.find(pending => pending.id === participant.id)) { const updated = [ ...state.pendingAudio ]; - updated.push(id); + updated.push(participant); return { ...state, @@ -82,20 +123,79 @@ ReducerRegistry.register('features/av-moderation', (state = initialState, action return state; } + case PARTICIPANT_UPDATED: { + const participant = action.participant; + const { audioModerationEnabled, videoModerationEnabled } = state; + let hasStateChanged = false; + + // skips changing the reference of pendingAudio or pendingVideo, + // if there is no change in the elements + if (audioModerationEnabled) { + hasStateChanged = _updatePendingParticipant(MEDIA_TYPE.AUDIO, participant, state); + } + + if (videoModerationEnabled) { + hasStateChanged = _updatePendingParticipant(MEDIA_TYPE.VIDEO, participant, state); + } + + // If the state has changed we need to return a new object reference in order to trigger subscriber updates. + if (hasStateChanged) { + return { + ...state + }; + } + + return state; + } + case PARTICIPANT_LEFT: { + const participant = action.participant; + const { audioModerationEnabled, videoModerationEnabled } = state; + let hasStateChanged = false; + + // skips changing the reference of pendingAudio or pendingVideo, + // if there is no change in the elements + if (audioModerationEnabled) { + const newPendingAudio = state.pendingAudio.filter(pending => pending.id !== participant.id); + + if (state.pendingAudio.length !== newPendingAudio.length) { + state.pendingAudio = newPendingAudio; + hasStateChanged = true; + } + } + + if (videoModerationEnabled) { + const newPendingVideo = state.pendingVideo.filter(pending => pending.id !== participant.id); + + if (state.pendingVideo.length !== newPendingVideo.length) { + state.pendingVideo = newPendingVideo; + hasStateChanged = true; + } + } + + // If the state has changed we need to return a new object reference in order to trigger subscriber updates. + if (hasStateChanged) { + return { + ...state + }; + } + + return state; + } + case DISMISS_PENDING_PARTICIPANT: { - const { id, mediaType } = action; + const { participant, mediaType } = action; if (mediaType === MEDIA_TYPE.AUDIO) { return { ...state, - pendingAudio: state.pendingAudio.filter(pending => pending !== id) + pendingAudio: state.pendingAudio.filter(pending => pending.id !== participant.id) }; } if (mediaType === MEDIA_TYPE.VIDEO) { return { ...state, - pendingAudio: state.pendingVideo.filter(pending => pending !== id) + pendingVideo: state.pendingVideo.filter(pending => pending.id !== participant.id) }; } diff --git a/react/features/base/conference/middleware.any.js b/react/features/base/conference/middleware.any.js index 692fb39c4..e864f203f 100644 --- a/react/features/base/conference/middleware.any.js +++ b/react/features/base/conference/middleware.any.js @@ -398,10 +398,9 @@ function _pinParticipant({ getState }, next, action) { return next(action); } - const participants = state['features/base/participants']; const id = action.participant.id; - const participantById = getParticipantById(participants, id); - const pinnedParticipant = getPinnedParticipant(participants); + const participantById = getParticipantById(state, id); + const pinnedParticipant = getPinnedParticipant(state); const actionName = id ? ACTION_PINNED : ACTION_UNPINNED; const local = (participantById && participantById.local) diff --git a/react/features/base/config/functions.web.js b/react/features/base/config/functions.web.js index 224d4f5b0..f78d9c37a 100644 --- a/react/features/base/config/functions.web.js +++ b/react/features/base/config/functions.web.js @@ -56,12 +56,15 @@ export function getToolbarButtons(state: Object): Array { } /** - * Curried selector to check if the specified button is enabled. + * Checks if the specified button is enabled. * * @param {string} buttonName - The name of the button. * {@link interfaceConfig}. - * @returns {Function} - Selector that returns a boolean. + * @param {Object|Array} state - The redux state or the array with the enabled buttons. + * @returns {boolean} - True if the button is enabled and false otherwise. */ -export const isToolbarButtonEnabled = (buttonName: string) => - (state: Object): boolean => - getToolbarButtons(state).includes(buttonName); +export function isToolbarButtonEnabled(buttonName: string, state: Object | Array) { + const buttons = Array.isArray(state) ? state : getToolbarButtons(state); + + return buttons.includes(buttonName); +} diff --git a/react/features/base/participants/functions.js b/react/features/base/participants/functions.js index 4b361256c..b859ca479 100644 --- a/react/features/base/participants/functions.js +++ b/react/features/base/participants/functions.js @@ -79,16 +79,15 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store p.local); + return state.local; } /** @@ -109,8 +108,7 @@ export function getNormalizedDisplayName(name: string) { /** * Returns participant by ID from Redux state. * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's * {@code getState} function to be used to retrieve the state * features/base/participants. * @param {string} id - The ID of the participant to retrieve. @@ -119,37 +117,82 @@ export function getNormalizedDisplayName(name: string) { */ export function getParticipantById( stateful: Object | Function, id: string): ?Object { - const participants = _getAllParticipants(stateful); + const state = toState(stateful)['features/base/participants']; + const { local, remote } = state; - return participants.find(p => p.id === id); + return remote.get(id) || (local?.id === id ? local : undefined); +} + +/** + * Returns the participant with the ID matching the passed ID or the local participant if the ID is + * undefined. + * + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's + * {@code getState} function to be used to retrieve the state + * features/base/participants. + * @param {string|undefined} [participantID] - An optional partipantID argument. + * @returns {Participant|undefined} + */ +export function getParticipantByIdOrUndefined(stateful: Object | Function, participantID: ?string) { + return participantID ? getParticipantById(stateful, participantID) : getLocalParticipant(stateful); } /** * Returns a count of the known participants in the passed in redux state, * excluding any fake participants. * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's * {@code getState} function to be used to retrieve the state * features/base/participants. * @returns {number} */ export function getParticipantCount(stateful: Object | Function) { - return getParticipants(stateful).length; + const state = toState(stateful)['features/base/participants']; + const { local, remote, fakeParticipants } = state; + + return remote.size - fakeParticipants.size + (local ? 1 : 0); +} + +/** + * Returns the Map with fake participants. + * + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's + * {@code getState} function to be used to retrieve the state + * features/base/participants. + * @returns {Map} - The Map with fake participants. + */ +export function getFakeParticipants(stateful: Object | Function) { + return toState(stateful)['features/base/participants'].fakeParticipants; +} + +/** + * Returns a count of the known remote participants in the passed in redux state. + * + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's + * {@code getState} function to be used to retrieve the state + * features/base/participants. + * @returns {number} + */ +export function getRemoteParticipantCount(stateful: Object | Function) { + const state = toState(stateful)['features/base/participants']; + + return state.remote.size; } /** * Returns a count of the known participants in the passed in redux state, * including fake participants. * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's * {@code getState} function to be used to retrieve the state * features/base/participants. * @returns {number} */ export function getParticipantCountWithFake(stateful: Object | Function) { - return _getAllParticipants(stateful).length; + const state = toState(stateful)['features/base/participants']; + const { local, remote } = state; + + return remote.size + (local ? 1 : 0); } /** @@ -185,17 +228,6 @@ export function getParticipantDisplayName( : 'Fellow Jitster'; } -/** - * Curried version of getParticipantDisplayName. - * - * @see {@link getParticipantDisplayName} - * @param {string} id - The ID of the participant's display name to retrieve. - * @returns {Function} - */ -export const getParticipantDisplayNameWithId = (id: string) => - (state: Object | Function) => - getParticipantDisplayName(state, id); - /** * Returns the presence status of a participant associated with the passed id. * @@ -219,64 +251,45 @@ export function getParticipantPresenceStatus( } /** - * Selectors for getting all known participants with fake participants filtered - * out. + * Returns true if there is at least 1 participant with screen sharing feature and false otherwise. * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's + * {@code getState} function to be used to retrieve the state. + * @returns {boolean} + */ +export function haveParticipantWithScreenSharingFeature(stateful: Object | Function) { + return toState(stateful)['features/base/participants'].haveParticipantWithScreenSharingFeature; +} + +/** + * Selectors for getting all remote participants. + * + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's * {@code getState} function to be used to retrieve the state * features/base/participants. - * @returns {Participant[]} + * @returns {Map} */ -export function getParticipants(stateful: Object | Function) { - return _getAllParticipants(stateful).filter(p => !p.isFakeParticipant); +export function getRemoteParticipants(stateful: Object | Function) { + return toState(stateful)['features/base/participants'].remote; } /** * Returns the participant which has its pinned state set to truthy. * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's + * @param {(Function|Object)} stateful - The (whole) redux state, or redux's * {@code getState} function to be used to retrieve the state * features/base/participants. * @returns {(Participant|undefined)} */ export function getPinnedParticipant(stateful: Object | Function) { - return _getAllParticipants(stateful).find(p => p.pinned); -} + const state = toState(stateful)['features/base/participants']; + const { pinnedParticipant } = state; -/** - * Returns array of participants from Redux state. - * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's - * {@code getState} function to be used to retrieve the state - * features/base/participants. - * @private - * @returns {Participant[]} - */ -function _getAllParticipants(stateful) { - return ( - Array.isArray(stateful) - ? stateful - : toState(stateful)['features/base/participants'] || []); -} + if (!pinnedParticipant) { + return undefined; + } -/** - * Returns the youtube fake participant. - * At the moment it is considered the youtube participant the only fake participant in the list. - * - * @param {(Function|Object|Participant[])} stateful - The redux state - * features/base/participants, the (whole) redux state, or redux's - * {@code getState} function to be used to retrieve the state - * features/base/participants. - * @private - * @returns {Participant} - */ -export function getYoutubeParticipant(stateful: Object | Function) { - const participants = _getAllParticipants(stateful); - - return participants.filter(p => p.isFakeParticipant)[0]; + return getParticipantById(stateful, pinnedParticipant); } /** @@ -289,6 +302,24 @@ export function isParticipantModerator(participant: Object) { return participant?.role === PARTICIPANT_ROLE.MODERATOR; } +/** + * Returns the dominant speaker participant. + * + * @param {(Function|Object)} stateful - The (whole) redux state or redux's + * {@code getState} function to be used to retrieve the state features/base/participants. + * @returns {Participant} - The participant from the redux store. + */ +export function getDominantSpeakerParticipant(stateful: Object | Function) { + const state = toState(stateful)['features/base/participants']; + const { dominantSpeaker } = state; + + if (!dominantSpeaker) { + return undefined; + } + + return getParticipantById(stateful, dominantSpeaker); +} + /** * Returns true if all of the meeting participants are moderators. * @@ -297,9 +328,9 @@ export function isParticipantModerator(participant: Object) { * @returns {boolean} */ export function isEveryoneModerator(stateful: Object | Function) { - const participants = _getAllParticipants(stateful); + const state = toState(stateful)['features/base/participants']; - return participants.every(isParticipantModerator); + return state.everyoneIsModerator === true; } /** @@ -321,14 +352,15 @@ export function isIconUrl(icon: ?string | ?Object) { * @returns {boolean} */ export function isLocalParticipantModerator(stateful: Object | Function) { - const state = toState(stateful); - const localParticipant = getLocalParticipant(state); + const state = toState(stateful)['features/base/participants']; - if (!localParticipant) { + const { local } = state; + + if (!local) { return false; } - return isParticipantModerator(localParticipant); + return isParticipantModerator(local); } /** @@ -390,7 +422,7 @@ async function _getFirstLoadableAvatarUrl(participant, store) { for (let i = 0; i < AVATAR_CHECKER_FUNCTIONS.length; i++) { const url = AVATAR_CHECKER_FUNCTIONS[i](participant, store); - if (url) { + if (url !== null) { if (AVATAR_CHECKED_URLS.has(url)) { if (AVATAR_CHECKED_URLS.get(url)) { return url; diff --git a/react/features/base/participants/middleware.js b/react/features/base/participants/middleware.js index 2d0be9927..5c2b986aa 100644 --- a/react/features/base/participants/middleware.js +++ b/react/features/base/participants/middleware.js @@ -1,5 +1,7 @@ // @flow +import { batch } from 'react-redux'; + import UIEvents from '../../../../service/UI/UIEvents'; import { toggleE2EE } from '../../e2ee/actions'; import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications'; @@ -43,7 +45,8 @@ import { getLocalParticipant, getParticipantById, getParticipantCount, - getParticipantDisplayName + getParticipantDisplayName, + getRemoteParticipants } from './functions'; import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds'; @@ -182,11 +185,12 @@ MiddlewareRegistry.register(store => next => action => { StateListenerRegistry.register( /* selector */ state => getCurrentConference(state), /* listener */ (conference, { dispatch, getState }) => { - for (const p of getState()['features/base/participants']) { - !p.local - && (!conference || p.conference !== conference) - && dispatch(participantLeft(p.id, p.conference, p.isReplaced)); - } + batch(() => { + for (const [ id, p ] of getRemoteParticipants(getState())) { + (!conference || p.conference !== conference) + && dispatch(participantLeft(id, p.conference, p.isReplaced)); + } + }); }); /** diff --git a/react/features/base/participants/reducer.js b/react/features/base/participants/reducer.js index 7467fbf55..e3c304653 100644 --- a/react/features/base/participants/reducer.js +++ b/react/features/base/participants/reducer.js @@ -12,6 +12,7 @@ import { SET_LOADABLE_AVATAR_URL } from './actionTypes'; import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants'; +import { isParticipantModerator } from './functions'; /** * Participant object. @@ -51,6 +52,16 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [ 'pinned' ]; +const DEFAULT_STATE = { + haveParticipantWithScreenSharingFeature: false, + dominantSpeaker: undefined, + everyoneIsModerator: false, + pinnedParticipant: undefined, + local: undefined, + remote: new Map(), + fakeParticipants: new Map() +}; + /** * Listen for actions which add, remove, or update the set of participants in * the conference. @@ -62,18 +73,157 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [ * added/removed/modified. * @returns {Participant[]} */ -ReducerRegistry.register('features/base/participants', (state = [], action) => { +ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, action) => { switch (action.type) { + case PARTICIPANT_ID_CHANGED: { + const { local } = state; + + if (local) { + state.local = { + ...local, + id: action.newValue + }; + + return { + ...state + }; + } + + return state; + } + case DOMINANT_SPEAKER_CHANGED: { + const { participant } = action; + const { id } = participant; + const { dominantSpeaker } = state; + + // Only one dominant speaker is allowed. + if (dominantSpeaker) { + _updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false); + } + + if (_updateParticipantProperty(state, id, 'dominantSpeaker', true)) { + return { + ...state, + dominantSpeaker: id + }; + } + + delete state.dominantSpeaker; + + return { + ...state + }; + } + case PIN_PARTICIPANT: { + const { participant } = action; + const { id } = participant; + const { pinnedParticipant } = state; + + // Only one pinned participant is allowed. + if (pinnedParticipant) { + _updateParticipantProperty(state, pinnedParticipant, 'pinned', false); + } + + if (_updateParticipantProperty(state, id, 'pinned', true)) { + return { + ...state, + pinnedParticipant: id + }; + } + + delete state.pinnedParticipant; + + return { + ...state + }; + } case SET_LOADABLE_AVATAR_URL: - case DOMINANT_SPEAKER_CHANGED: - case PARTICIPANT_ID_CHANGED: - case PARTICIPANT_UPDATED: - case PIN_PARTICIPANT: - return state.map(p => _participant(p, action)); + case PARTICIPANT_UPDATED: { + const { participant } = action; + let { id } = participant; + const { local } = participant; - case PARTICIPANT_JOINED: - return [ ...state, _participantJoined(action) ]; + if (!id && local) { + id = LOCAL_PARTICIPANT_DEFAULT_ID; + } + let newParticipant; + + if (state.remote.has(id)) { + newParticipant = _participant(state.remote.get(id), action); + state.remote.set(id, newParticipant); + } else if (id === state.local?.id) { + newParticipant = state.local = _participant(state.local, action); + } + + if (newParticipant) { + + // everyoneIsModerator calculation: + const isModerator = isParticipantModerator(newParticipant); + + if (state.everyoneIsModerator && !isModerator) { + state.everyoneIsModerator = false; + } else if (!state.everyoneIsModerator && isModerator) { + state.everyoneIsModerator = _isEveryoneModerator(state); + } + + // haveParticipantWithScreenSharingFeature calculation: + const { features = {} } = participant; + + // Currently we use only PARTICIPANT_UPDATED to set a feature to enabled and we never disable it. + if (String(features['screen-sharing']) === 'true') { + state.haveParticipantWithScreenSharingFeature = true; + } + } + + return { + ...state + }; + } + case PARTICIPANT_JOINED: { + const participant = _participantJoined(action); + const { pinnedParticipant, dominantSpeaker } = state; + + if (participant.pinned) { + if (pinnedParticipant) { + _updateParticipantProperty(state, pinnedParticipant, 'pinned', false); + } + + state.pinnedParticipant = participant.id; + } + + if (participant.dominantSpeaker) { + if (dominantSpeaker) { + _updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false); + } + state.dominantSpeaker = participant.id; + } + + const isModerator = isParticipantModerator(participant); + const { local, remote } = state; + + if (state.everyoneIsModerator && !isModerator) { + state.everyoneIsModerator = false; + } else if (!local && remote.size === 0 && isModerator) { + state.everyoneIsModerator = true; + } + + if (participant.local) { + return { + ...state, + local: participant + }; + } + + state.remote.set(participant.id, participant); + + if (participant.isFakeParticipant) { + state.fakeParticipants.set(participant.id, participant); + } + + return { ...state }; + + } case PARTICIPANT_LEFT: { // XXX A remote participant is uniquely identified by their id in a // specific JitsiConference instance. The local participant is uniquely @@ -81,23 +231,111 @@ ReducerRegistry.register('features/base/participants', (state = [], action) => { // (and the fact that the local participant "joins" at the beginning of // the app and "leaves" at the end of the app). const { conference, id } = action.participant; + const { fakeParticipants, remote, local, dominantSpeaker, pinnedParticipant } = state; + let oldParticipant = remote.get(id); - return state.filter(p => - !( - p.id === id + if (oldParticipant && oldParticipant.conference === conference) { + remote.delete(id); + } else if (local?.id === id) { + oldParticipant = state.local; + delete state.local; + } else { + // no participant found + return state; + } - // XXX Do not allow collisions in the IDs of the local - // participant and a remote participant cause the removal of - // the local participant when the remote participant's - // removal is requested. - && p.conference === conference - && (conference || p.local))); + if (!state.everyoneIsModerator && !isParticipantModerator(oldParticipant)) { + state.everyoneIsModerator = _isEveryoneModerator(state); + } + + const { features = {} } = oldParticipant || {}; + + if (state.haveParticipantWithScreenSharingFeature && String(features['screen-sharing']) === 'true') { + const { features: localFeatures = {} } = state.local || {}; + + if (String(localFeatures['screen-sharing']) !== 'true') { + state.haveParticipantWithScreenSharingFeature = false; + + // eslint-disable-next-line no-unused-vars + for (const [ key, participant ] of state.remote) { + const { features: f = {} } = participant; + + if (String(f['screen-sharing']) === 'true') { + state.haveParticipantWithScreenSharingFeature = true; + break; + } + } + } + + + } + + if (dominantSpeaker === id) { + state.dominantSpeaker = undefined; + } + + if (pinnedParticipant === id) { + state.pinnedParticipant = undefined; + } + + if (fakeParticipants.has(id)) { + fakeParticipants.delete(id); + } + + return { ...state }; } } return state; }); +/** + * Loops trough the participants in the state in order to check if all participants are moderators. + * + * @param {Object} state - The local participant redux state. + * @returns {boolean} + */ +function _isEveryoneModerator(state) { + if (isParticipantModerator(state.local)) { + // eslint-disable-next-line no-unused-vars + for (const [ k, p ] of state.remote) { + if (!isParticipantModerator(p)) { + return false; + } + } + + return true; + } + + return false; +} + + +/** + * Updates a specific property for a participant. + * + * @param {State} state - The redux state. + * @param {string} id - The ID of the participant. + * @param {string} property - The property to update. + * @param {*} value - The new value. + * @returns {boolean} - True if a participant was updated and false otherwise. + */ +function _updateParticipantProperty(state, id, property, value) { + const { remote, local } = state; + + if (remote.has(id)) { + remote.set(id, set(remote.get(id), property, value)); + + return true; + } else if (local?.id === id) { + state.local = set(local, property, value); + + return true; + } + + return false; +} + /** * Reducer function for a single participant. * @@ -112,56 +350,22 @@ ReducerRegistry.register('features/base/participants', (state = [], action) => { */ function _participant(state: Object = {}, action) { switch (action.type) { - case DOMINANT_SPEAKER_CHANGED: - // Only one dominant speaker is allowed. - return ( - set(state, 'dominantSpeaker', state.id === action.participant.id)); - - case PARTICIPANT_ID_CHANGED: { - // A participant is identified by an id-conference pair. Only the local - // participant is with an undefined conference. - const { conference } = action; - - if (state.id === action.oldValue - && state.conference === conference - && (conference || state.local)) { - return { - ...state, - id: action.newValue - }; - } - break; - } - case SET_LOADABLE_AVATAR_URL: case PARTICIPANT_UPDATED: { const { participant } = action; // eslint-disable-line no-shadow - let { id } = participant; - const { local } = participant; - if (!id && local) { - id = LOCAL_PARTICIPANT_DEFAULT_ID; - } + const newState = { ...state }; - if (state.id === id) { - const newState = { ...state }; - - for (const key in participant) { - if (participant.hasOwnProperty(key) - && PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE.indexOf(key) - === -1) { - newState[key] = participant[key]; - } + for (const key in participant) { + if (participant.hasOwnProperty(key) + && PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE.indexOf(key) + === -1) { + newState[key] = participant[key]; } - - return newState; } - break; - } - case PIN_PARTICIPANT: - // Currently, only one pinned participant is allowed. - return set(state, 'pinned', state.id === action.participant.id); + return newState; + } } return state; diff --git a/react/features/base/tracks/functions.js b/react/features/base/tracks/functions.js index ff1135c2e..9cc66a0d1 100644 --- a/react/features/base/tracks/functions.js +++ b/react/features/base/tracks/functions.js @@ -21,68 +21,50 @@ import logger from './logger'; export const getTrackState = state => state['features/base/tracks']; /** - * Higher-order function that returns a selector for a specific participant - * and media type. + * Checks if the passed media type is muted for the participant. * * @param {Object} participant - Participant reference. * @param {MEDIA_TYPE} mediaType - Media type. - * @returns {Function} Selector. + * @param {Object} state - Global state. + * @returns {boolean} - Is the media type muted for the participant. */ -export const getIsParticipantMediaMuted = (participant, mediaType) => +export function isParticipantMediaMuted(participant, mediaType, state) { + if (!participant) { + return false; + } - /** - * Bound selector. - * - * @param {Object} state - Global state. - * @returns {boolean} Is the media type muted for the participant. - */ - state => { - if (!participant) { - return; - } + const tracks = getTrackState(state); - const tracks = getTrackState(state); + if (participant?.local) { + return isLocalTrackMuted(tracks, mediaType); + } else if (!participant?.isFakeParticipant) { + return isRemoteTrackMuted(tracks, mediaType, participant.id); + } - if (participant?.local) { - return isLocalTrackMuted(tracks, mediaType); - } else if (!participant?.isFakeParticipant) { - return isRemoteTrackMuted(tracks, mediaType, participant.id); - } - - return true; - }; + return true; +} /** - * Higher-order function that returns a selector for a specific participant. + * Checks if the participant is audio muted. * * @param {Object} participant - Participant reference. - * @returns {Function} Selector. + * @param {Object} state - Global state. + * @returns {boolean} - Is audio muted for the participant. */ -export const getIsParticipantAudioMuted = participant => - - /** - * Bound selector. - * - * @param {Object} state - Global state. - * @returns {boolean} Is audio muted for the participant. - */ - state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.AUDIO)(state); +export function isParticipantAudioMuted(participant, state) { + return isParticipantMediaMuted(participant, MEDIA_TYPE.AUDIO, state); +} /** - * Higher-order function that returns a selector for a specific participant. + * Checks if the participant is video muted. * * @param {Object} participant - Participant reference. - * @returns {Function} Selector. + * @param {Object} state - Global state. + * @returns {boolean} - Is video muted for the participant. */ -export const getIsParticipantVideoMuted = participant => - - /** - * Bound selector. - * - * @param {Object} state - Global state. - * @returns {boolean} Is video muted for the participant. - */ - state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO)(state); +export function isParticipantVideoMuted(participant, state) { + return isParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO, state); +} /** * Creates a local video track for presenter. The constraints are computed based diff --git a/react/features/chat/components/AbstractChat.js b/react/features/chat/components/AbstractChat.js index 22e6d5f87..7bbb1cc82 100644 --- a/react/features/chat/components/AbstractChat.js +++ b/react/features/chat/components/AbstractChat.js @@ -108,6 +108,6 @@ export function _mapStateToProps(state: Object) { _isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD, _isOpen: isOpen, _messages: messages, - _showNamePrompt: !_localParticipant.name + _showNamePrompt: !_localParticipant?.name }; } diff --git a/react/features/display-name/components/web/DisplayName.js b/react/features/display-name/components/web/DisplayName.js index f02ef5a26..7588e7cd1 100644 --- a/react/features/display-name/components/web/DisplayName.js +++ b/react/features/display-name/components/web/DisplayName.js @@ -288,8 +288,7 @@ function _mapStateToProps(state, ownProps) { return { _configuredDisplayName: participant && participant.name, - _nameToDisplay: getParticipantDisplayName( - state, participantID) + _nameToDisplay: getParticipantDisplayName(state, participantID) }; } diff --git a/react/features/e2ee/actionTypes.js b/react/features/e2ee/actionTypes.js index 25051106a..14fcdaa6d 100644 --- a/react/features/e2ee/actionTypes.js +++ b/react/features/e2ee/actionTypes.js @@ -6,3 +6,22 @@ * } */ export const TOGGLE_E2EE = 'TOGGLE_E2EE'; + +/** + * The type of the action which signals to set new value whether everyone has E2EE enabled. + * + * { + * type: SET_EVERYONE_ENABLED_E2EE, + * everyoneEnabledE2EE: boolean + * } + */ +export const SET_EVERYONE_ENABLED_E2EE = 'SET_EVERYONE_ENABLED_E2EE'; + +/** + * The type of the action which signals to set new value whether everyone supports E2EE. + * + * { + * type: SET_EVERYONE_SUPPORT_E2EE + * } + */ +export const SET_EVERYONE_SUPPORT_E2EE = 'SET_EVERYONE_SUPPORT_E2EE'; diff --git a/react/features/e2ee/actions.js b/react/features/e2ee/actions.js index 4beb1629f..a9b737559 100644 --- a/react/features/e2ee/actions.js +++ b/react/features/e2ee/actions.js @@ -1,6 +1,6 @@ // @flow -import { TOGGLE_E2EE } from './actionTypes'; +import { SET_EVERYONE_ENABLED_E2EE, SET_EVERYONE_SUPPORT_E2EE, TOGGLE_E2EE } from './actionTypes'; /** * Dispatches an action to enable / disable E2EE. @@ -14,3 +14,35 @@ export function toggleE2EE(enabled: boolean) { enabled }; } + +/** + * Set new value whether everyone has E2EE enabled. + * + * @param {boolean} everyoneEnabledE2EE - The new value. + * @returns {{ + * type: SET_EVERYONE_ENABLED_E2EE, + * everyoneEnabledE2EE: boolean + * }} + */ +export function setEveryoneEnabledE2EE(everyoneEnabledE2EE: boolean) { + return { + type: SET_EVERYONE_ENABLED_E2EE, + everyoneEnabledE2EE + }; +} + +/** + * Set new value whether everyone support E2EE. + * + * @param {boolean} everyoneSupportE2EE - The new value. + * @returns {{ + * type: SET_EVERYONE_SUPPORT_E2EE, + * everyoneSupportE2EE: boolean + * }} + */ +export function setEveryoneSupportE2EE(everyoneSupportE2EE: boolean) { + return { + type: SET_EVERYONE_SUPPORT_E2EE, + everyoneSupportE2EE + }; +} diff --git a/react/features/e2ee/components/AbstractE2EELabel.js b/react/features/e2ee/components/AbstractE2EELabel.js index e3af399aa..c668d7675 100644 --- a/react/features/e2ee/components/AbstractE2EELabel.js +++ b/react/features/e2ee/components/AbstractE2EELabel.js @@ -22,9 +22,7 @@ export type Props = { * @returns {Props} */ export function _mapStateToProps(state: Object) { - const participants = state['features/base/participants']; - return { - _showLabel: participants.every(p => p.e2eeEnabled) + _showLabel: state['features/e2ee'].everyoneEnabledE2EE }; } diff --git a/react/features/e2ee/components/E2EESection.js b/react/features/e2ee/components/E2EESection.js index be6bd5502..4e5f54c60 100644 --- a/react/features/e2ee/components/E2EESection.js +++ b/react/features/e2ee/components/E2EESection.js @@ -5,7 +5,6 @@ import type { Dispatch } from 'redux'; import { createE2EEEvent, sendAnalytics } from '../../analytics'; import { translate } from '../../base/i18n'; -import { getParticipants } from '../../base/participants'; import { Switch } from '../../base/react'; import { connect } from '../../base/redux'; import { toggleE2EE } from '../actions'; @@ -21,7 +20,7 @@ type Props = { /** * Indicates whether all participants in the conference currently support E2EE. */ - _everyoneSupportsE2EE: boolean, + _everyoneSupportE2EE: boolean, /** * The redux {@code dispatch} function. @@ -96,7 +95,7 @@ class E2EESection extends Component { * @returns {ReactElement} */ render() { - const { _everyoneSupportsE2EE, t } = this.props; + const { _everyoneSupportE2EE, t } = this.props; const { enabled, expand } = this.state; const description = t('dialog.e2eeDescription'); @@ -120,7 +119,7 @@ class E2EESection extends Component { }

{ - !_everyoneSupportsE2EE + !_everyoneSupportE2EE && { t('dialog.e2eeWarning') } @@ -195,12 +194,11 @@ class E2EESection extends Component { * @returns {Props} */ function mapStateToProps(state) { - const { enabled } = state['features/e2ee']; - const participants = getParticipants(state).filter(p => !p.local); + const { enabled, everyoneSupportE2EE } = state['features/e2ee']; return { _enabled: enabled, - _everyoneSupportsE2EE: participants.every(p => Boolean(p.e2eeSupported)) + _everyoneSupportE2EE: everyoneSupportE2EE }; } diff --git a/react/features/e2ee/middleware.js b/react/features/e2ee/middleware.js index 1941f7c92..065e573c0 100644 --- a/react/features/e2ee/middleware.js +++ b/react/features/e2ee/middleware.js @@ -1,13 +1,24 @@ // @flow +import { batch } from 'react-redux'; + import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; import { getCurrentConference } from '../base/conference'; -import { getLocalParticipant, participantUpdated } from '../base/participants'; +import { + getLocalParticipant, + getParticipantById, + getParticipantCount, + PARTICIPANT_JOINED, + PARTICIPANT_LEFT, + PARTICIPANT_UPDATED, + participantUpdated, + getRemoteParticipants +} from '../base/participants'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { TOGGLE_E2EE } from './actionTypes'; -import { toggleE2EE } from './actions'; +import { setEveryoneEnabledE2EE, setEveryoneSupportE2EE, toggleE2EE } from './actions'; import { E2EE_OFF_SOUND_ID, E2EE_ON_SOUND_ID } from './constants'; import logger from './logger'; import { E2EE_OFF_SOUND_FILE, E2EE_ON_SOUND_FILE } from './sounds'; @@ -35,6 +46,128 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { dispatch(unregisterSound(E2EE_ON_SOUND_ID)); break; + case PARTICIPANT_UPDATED: { + const { id, e2eeEnabled, e2eeSupported } = action.participant; + const oldParticipant = getParticipantById(getState(), id); + const result = next(action); + + if (e2eeEnabled !== oldParticipant?.e2eeEnabled + || e2eeSupported !== oldParticipant?.e2eeSupported) { + const state = getState(); + let newEveryoneSupportE2EE = true; + let newEveryoneEnabledE2EE = true; + + // eslint-disable-next-line no-unused-vars + for (const [ key, p ] of getRemoteParticipants(state)) { + if (!p.e2eeEnabled) { + newEveryoneEnabledE2EE = false; + } + + if (!p.e2eeSupported) { + newEveryoneSupportE2EE = false; + } + + if (!newEveryoneEnabledE2EE && !newEveryoneSupportE2EE) { + break; + } + } + + if (!getLocalParticipant(state)?.e2eeEnabled) { + newEveryoneEnabledE2EE = false; + } + + batch(() => { + dispatch(setEveryoneEnabledE2EE(newEveryoneEnabledE2EE)); + dispatch(setEveryoneSupportE2EE(newEveryoneSupportE2EE)); + }); + } + + return result; + } + case PARTICIPANT_JOINED: { + const result = next(action); + const { e2eeEnabled, e2eeSupported, local } = action.participant; + const { everyoneEnabledE2EE } = getState()['features/e2ee']; + const participantCount = getParticipantCount(getState()); + + // the initial values + if (participantCount === 1) { + batch(() => { + dispatch(setEveryoneEnabledE2EE(e2eeEnabled)); + dispatch(setEveryoneSupportE2EE(e2eeSupported)); + }); + } + + // if all had it enabled and this one disabled it, change value in store + // otherwise there is no change in the value we store + if (everyoneEnabledE2EE && !e2eeEnabled) { + dispatch(setEveryoneEnabledE2EE(false)); + } + + if (local) { + return result; + } + + const { everyoneSupportE2EE } = getState()['features/e2ee']; + + // if all supported it and this one does not, change value in store + // otherwise there is no change in the value we store + if (everyoneSupportE2EE && !e2eeSupported) { + dispatch(setEveryoneSupportE2EE(false)); + } + + return result; + } + + case PARTICIPANT_LEFT: { + const previosState = getState(); + const participant = getParticipantById(previosState, action.participant?.id) || {}; + const result = next(action); + const newState = getState(); + const { e2eeEnabled = false, e2eeSupported = false } = participant; + + const { everyoneEnabledE2EE, everyoneSupportE2EE } = newState['features/e2ee']; + + + // if it was not enabled by everyone, and the participant leaving had it disabled, or if it was not supported + // by everyone, and the participant leaving had it not supported let's check is it enabled for all that stay + if ((!everyoneEnabledE2EE && !e2eeEnabled) || (!everyoneSupportE2EE && !e2eeSupported)) { + let latestEveryoneEnabledE2EE = true; + let latestEveryoneSupportE2EE = true; + + // eslint-disable-next-line no-unused-vars + for (const [ key, p ] of getRemoteParticipants(newState)) { + if (!p.e2eeEnabled) { + latestEveryoneEnabledE2EE = false; + } + + if (!p.e2eeSupported) { + latestEveryoneSupportE2EE = false; + } + + if (!latestEveryoneEnabledE2EE && !latestEveryoneSupportE2EE) { + break; + } + } + + if (!getLocalParticipant(newState)?.e2eeEnabled) { + latestEveryoneEnabledE2EE = false; + } + + batch(() => { + if (!everyoneEnabledE2EE && latestEveryoneEnabledE2EE) { + dispatch(setEveryoneEnabledE2EE(true)); + } + + if (!everyoneSupportE2EE && latestEveryoneSupportE2EE) { + dispatch(setEveryoneSupportE2EE(true)); + } + }); + } + + return result; + } + case TOGGLE_E2EE: { const conference = getCurrentConference(getState); diff --git a/react/features/e2ee/reducer.js b/react/features/e2ee/reducer.js index cba87327b..abb587947 100644 --- a/react/features/e2ee/reducer.js +++ b/react/features/e2ee/reducer.js @@ -2,7 +2,11 @@ import { ReducerRegistry } from '../base/redux'; -import { TOGGLE_E2EE } from './actionTypes'; +import { + SET_EVERYONE_ENABLED_E2EE, + SET_EVERYONE_SUPPORT_E2EE, + TOGGLE_E2EE +} from './actionTypes'; const DEFAULT_STATE = { enabled: false @@ -18,6 +22,16 @@ ReducerRegistry.register('features/e2ee', (state = DEFAULT_STATE, action) => { ...state, enabled: action.enabled }; + case SET_EVERYONE_ENABLED_E2EE: + return { + ...state, + everyoneEnabledE2EE: action.everyoneEnabledE2EE + }; + case SET_EVERYONE_SUPPORT_E2EE: + return { + ...state, + everyoneSupportE2EE: action.everyoneSupportE2EE + }; default: return state; diff --git a/react/features/filmstrip/actions.web.js b/react/features/filmstrip/actions.web.js index a3bfb8211..631573ee3 100644 --- a/react/features/filmstrip/actions.web.js +++ b/react/features/filmstrip/actions.web.js @@ -1,7 +1,7 @@ // @flow import type { Dispatch } from 'redux'; -import { pinParticipant } from '../base/participants'; +import { getLocalParticipant, getRemoteParticipants, pinParticipant } from '../base/participants'; import { SET_HORIZONTAL_VIEW_DIMENSIONS, @@ -127,7 +127,8 @@ export function setHorizontalViewDimensions() { */ export function clickOnVideo(n: number) { return (dispatch: Function, getState: Function) => { - const participants = getState()['features/base/participants']; + const state = getState(); + const participants = [ getLocalParticipant(state), ...getRemoteParticipants(state).values() ]; const nThParticipant = participants[n]; const { id, pinned } = nThParticipant; diff --git a/react/features/filmstrip/components/native/Filmstrip.js b/react/features/filmstrip/components/native/Filmstrip.js index 9b5a84138..1f569c11d 100644 --- a/react/features/filmstrip/components/native/Filmstrip.js +++ b/react/features/filmstrip/components/native/Filmstrip.js @@ -109,10 +109,10 @@ class Filmstrip extends Component { { this._sort(_participants, isNarrowAspectRatio) - .map(p => ( + .map(id => ( )) + key = { id } + participantID = { id } />)) } { @@ -166,12 +166,11 @@ class Filmstrip extends Component { * @returns {Props} */ function _mapStateToProps(state) { - const participants = state['features/base/participants']; - const { enabled } = state['features/filmstrip']; + const { enabled, remoteParticipants } = state['features/filmstrip']; return { _aspectRatio: state['features/base/responsive-ui'].aspectRatio, - _participants: participants.filter(p => !p.local), + _participants: remoteParticipants, _visible: enabled && isFilmstripVisible(state) }; } diff --git a/react/features/filmstrip/components/native/LocalThumbnail.js b/react/features/filmstrip/components/native/LocalThumbnail.js index c519ec302..b30cc43ce 100644 --- a/react/features/filmstrip/components/native/LocalThumbnail.js +++ b/react/features/filmstrip/components/native/LocalThumbnail.js @@ -1,63 +1,21 @@ // @flow -import React, { Component } from 'react'; +import React from 'react'; import { View } from 'react-native'; -import { getLocalParticipant } from '../../../base/participants'; -import { connect } from '../../../base/redux'; - import Thumbnail from './Thumbnail'; import styles from './styles'; -type Props = { - - /** - * The local participant. - */ - _localParticipant: Object -}; - /** * Component to render a local thumbnail that can be separated from the * remote thumbnails later. - */ -class LocalThumbnail extends Component { - /** - * Implements React Component's render. - * - * @inheritdoc - */ - render() { - const { _localParticipant } = this.props; - - return ( - - - - ); - } -} - -/** - * Maps (parts of) the redux state to the associated {@code LocalThumbnail}'s - * props. * - * @param {Object} state - The redux state. - * @private - * @returns {{ - * _localParticipant: Participant - * }} + * @returns {ReactElement} */ -function _mapStateToProps(state) { - return { - /** - * The local participant. - * - * @private - * @type {Participant} - */ - _localParticipant: getLocalParticipant(state) - }; +export default function LocalThumbnail() { + return ( + + + + ); } - -export default connect(_mapStateToProps)(LocalThumbnail); diff --git a/react/features/filmstrip/components/native/Thumbnail.js b/react/features/filmstrip/components/native/Thumbnail.js index 9026ad48c..eb3f145e9 100644 --- a/react/features/filmstrip/components/native/Thumbnail.js +++ b/react/features/filmstrip/components/native/Thumbnail.js @@ -1,6 +1,6 @@ // @flow -import React from 'react'; +import React, { useCallback } from 'react'; import { View } from 'react-native'; import type { Dispatch } from 'redux'; @@ -12,7 +12,8 @@ import { ParticipantView, getParticipantCount, isEveryoneModerator, - pinParticipant + pinParticipant, + getParticipantByIdOrUndefined } from '../../../base/participants'; import { Container } from '../../../base/react'; import { connect } from '../../../base/redux'; @@ -48,14 +49,9 @@ type Props = { _largeVideo: Object, /** - * Handles click/tap event on the thumbnail. + * The Redux representation of the participant to display. */ - _onClick: ?Function, - - /** - * Handles long press on the thumbnail. - */ - _onThumbnailLongPress: ?Function, + _participant: Object, /** * Whether to show the dominant speaker indicator or not. @@ -90,9 +86,9 @@ type Props = { dispatch: Dispatch, /** - * The Redux representation of the participant to display. + * The ID of the participant related to the thumbnail. */ - participant: Object, + participantID: ?string, /** * Whether to display or hide the display name of the participant in the thumbnail. @@ -120,14 +116,13 @@ function Thumbnail(props: Props) { const { _audioMuted: audioMuted, _largeVideo: largeVideo, - _onClick, - _onThumbnailLongPress, _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, _renderModeratorIndicator: renderModeratorIndicator, + _participant: participant, _styles, _videoTrack: videoTrack, + dispatch, disableTint, - participant, renderDisplayName, tileView } = props; @@ -137,11 +132,29 @@ function Thumbnail(props: Props) { = participantId === largeVideo.participantId; const videoMuted = !videoTrack || videoTrack.muted; const isScreenShare = videoTrack && videoTrack.videoType === VIDEO_TYPE.DESKTOP; + const onClick = useCallback(() => { + if (tileView) { + dispatch(toggleToolboxVisible()); + } else { + dispatch(pinParticipant(participant.pinned ? null : participant.id)); + } + }, [ participant, tileView, dispatch ]); + const onThumbnailLongPress = useCallback(() => { + if (participant.local) { + dispatch(openDialog(ConnectionStatusComponent, { + participantID: participant.id + })); + } else { + dispatch(openDialog(RemoteVideoMenu, { + participant + })); + } + }, [ participant, dispatch ]); return ( 2; + const renderDominantSpeakerIndicator = participant && participant.dominantSpeaker && participantCount > 2; const _isEveryoneModerator = isEveryoneModerator(state); - const renderModeratorIndicator = !_isEveryoneModerator && participant.role === PARTICIPANT_ROLE.MODERATOR; + const renderModeratorIndicator = !_isEveryoneModerator + && participant && participant.role === PARTICIPANT_ROLE.MODERATOR; return { _audioMuted: audioTrack?.muted ?? true, _largeVideo: largeVideo, + _participant: participant, _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, _renderModeratorIndicator: renderModeratorIndicator, _styles: ColorSchemeRegistry.get(state, 'Thumbnail'), @@ -281,4 +248,4 @@ function _mapStateToProps(state, ownProps) { }; } -export default connect(_mapStateToProps, _mapDispatchToProps)(Thumbnail); +export default connect(_mapStateToProps)(Thumbnail); diff --git a/react/features/filmstrip/components/native/TileView.js b/react/features/filmstrip/components/native/TileView.js index 4e39ca82a..7ed8fed53 100644 --- a/react/features/filmstrip/components/native/TileView.js +++ b/react/features/filmstrip/components/native/TileView.js @@ -8,6 +8,7 @@ import { } from 'react-native'; import type { Dispatch } from 'redux'; +import { getLocalParticipant, getParticipantCountWithFake } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants'; import { setTileViewDimensions } from '../../actions.native'; @@ -15,6 +16,7 @@ import { setTileViewDimensions } from '../../actions.native'; import Thumbnail from './Thumbnail'; import styles from './styles'; + /** * The type of the React {@link Component} props of {@link TileView}. */ @@ -31,9 +33,19 @@ type Props = { _height: number, /** - * The participants in the conference. + * The local participant. */ - _participants: Array, + _localParticipant: Object, + + /** + * The number of participants in the conference. + */ + _participantCount: number, + + /** + * An array with the IDs of the remote participants in the conference. + */ + _remoteParticipants: Array, /** * Application's viewport height. @@ -131,7 +143,7 @@ class TileView extends Component { * @private */ _getColumnCount() { - const participantCount = this.props._participants.length; + const participantCount = this.props._participantCount; // For narrow view, tiles should stack on top of each other for a lonely // call and a 1:1 call. Otherwise tiles should be grouped into rows of @@ -155,18 +167,10 @@ class TileView extends Component { * @returns {Participant[]} */ _getSortedParticipants() { - const participants = []; - let localParticipant; + const { _localParticipant, _remoteParticipants } = this.props; + const participants = [ ..._remoteParticipants ]; - for (const participant of this.props._participants) { - if (participant.local) { - localParticipant = participant; - } else { - participants.push(participant); - } - } - - localParticipant && participants.push(localParticipant); + _localParticipant && participants.push(_localParticipant.id); return participants; } @@ -178,16 +182,15 @@ class TileView extends Component { * @returns {Object} */ _getTileDimensions() { - const { _height, _participants, _width } = this.props; + const { _height, _participantCount, _width } = this.props; const columns = this._getColumnCount(); - const participantCount = _participants.length; const heightToUse = _height - (MARGIN * 2); const widthToUse = _width - (MARGIN * 2); let tileWidth; // If there is going to be at least two rows, ensure that at least two // rows display fully on screen. - if (participantCount / columns > 1) { + if (_participantCount / columns > 1) { tileWidth = Math.min(widthToUse / columns, heightToUse / 2); } else { tileWidth = Math.min(widthToUse / columns, heightToUse); @@ -247,11 +250,11 @@ class TileView extends Component { }; return this._getSortedParticipants() - .map(participant => ( + .map(id => ( )); @@ -285,11 +288,14 @@ class TileView extends Component { */ function _mapStateToProps(state) { const responsiveUi = state['features/base/responsive-ui']; + const { remoteParticipants } = state['features/filmstrip']; return { _aspectRatio: responsiveUi.aspectRatio, _height: responsiveUi.clientHeight, - _participants: state['features/base/participants'], + _localParticipant: getLocalParticipant(state), + _participantCount: getParticipantCountWithFake(state), + _remoteParticipants: remoteParticipants, _width: responsiveUi.clientWidth }; } diff --git a/react/features/filmstrip/components/web/StatusIndicators.js b/react/features/filmstrip/components/web/StatusIndicators.js index 274a25e16..cbaf346f0 100644 --- a/react/features/filmstrip/components/web/StatusIndicators.js +++ b/react/features/filmstrip/components/web/StatusIndicators.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { MEDIA_TYPE } from '../../../base/media'; -import { getLocalParticipant, getParticipantById, PARTICIPANT_ROLE } from '../../../base/participants'; +import { getParticipantByIdOrUndefined, PARTICIPANT_ROLE } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { getTrackByMediaTypeAndParticipant, isLocalTrackMuted, isRemoteTrackMuted } from '../../../base/tracks'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; @@ -111,7 +111,7 @@ function _mapStateToProps(state, ownProps) { const { participantID } = ownProps; // Only the local participant won't have id for the time when the conference is not yet joined. - const participant = participantID ? getParticipantById(state, participantID) : getLocalParticipant(state); + const participant = getParticipantByIdOrUndefined(state, participantID); const tracks = state['features/base/tracks']; let isVideoMuted = true; diff --git a/react/features/filmstrip/components/web/Thumbnail.js b/react/features/filmstrip/components/web/Thumbnail.js index ef36b466c..a46ee3093 100644 --- a/react/features/filmstrip/components/web/Thumbnail.js +++ b/react/features/filmstrip/components/web/Thumbnail.js @@ -9,8 +9,7 @@ import { Avatar } from '../../../base/avatar'; import JitsiMeetJS from '../../../base/lib-jitsi-meet/_'; import { MEDIA_TYPE, VideoTrack } from '../../../base/media'; import { - getLocalParticipant, - getParticipantById, + getParticipantByIdOrUndefined, getParticipantCount, pinParticipant } from '../../../base/participants'; @@ -1012,9 +1011,8 @@ class Thumbnail extends Component { function _mapStateToProps(state, ownProps): Object { const { participantID } = ownProps; - // Only the local participant won't have id for the time when the conference is not yet joined. - const participant = participantID ? getParticipantById(state, participantID) : getLocalParticipant(state); - const { id } = participant; + const participant = getParticipantByIdOrUndefined(state, participantID); + const id = participant?.id; const isLocal = participant?.local ?? true; const tracks = state['features/base/tracks']; const { participantsVolume } = state['features/filmstrip']; @@ -1085,14 +1083,14 @@ function _mapStateToProps(state, ownProps): Object { _isDominantSpeakerDisabled: interfaceConfig.DISABLE_DOMINANT_SPEAKER_INDICATOR, _isScreenSharing: _videoTrack?.videoType === 'desktop', _isTestModeEnabled: isTestModeEnabled(state), - _isVideoPlayable: isVideoPlayable(state, id), + _isVideoPlayable: id && isVideoPlayable(state, id), _indicatorIconSize: NORMAL, _localFlipX: Boolean(localFlipX), _participant: participant, _participantCountMoreThan2: getParticipantCount(state) > 2, _startSilent: Boolean(startSilent), _videoTrack, - _volume: isLocal ? undefined : participantsVolume[id], + _volume: isLocal ? undefined : id ? participantsVolume[id] : undefined, ...size }; } diff --git a/react/features/filmstrip/functions.native.js b/react/features/filmstrip/functions.native.js index 859fca6bd..700a58f50 100644 --- a/react/features/filmstrip/functions.native.js +++ b/react/features/filmstrip/functions.native.js @@ -1,6 +1,7 @@ // @flow import { getFeatureFlag, FILMSTRIP_ENABLED } from '../base/flags'; +import { getParticipantCountWithFake } from '../base/participants'; import { toState } from '../base/redux'; /** @@ -22,7 +23,5 @@ export function isFilmstripVisible(stateful: Object | Function) { return false; } - const { length: participantCount } = state['features/base/participants']; - - return participantCount > 1; + return getParticipantCountWithFake(state) > 1; } diff --git a/react/features/filmstrip/subscriber.web.js b/react/features/filmstrip/subscriber.web.js index a2c4639f6..de6ee26ac 100644 --- a/react/features/filmstrip/subscriber.web.js +++ b/react/features/filmstrip/subscriber.web.js @@ -1,5 +1,6 @@ // @flow +import { getParticipantCountWithFake } from '../base/participants'; import { StateListenerRegistry, equals } from '../base/redux'; import { clientResized } from '../base/responsive-ui'; import { setFilmstripVisible } from '../filmstrip/actions'; @@ -19,7 +20,7 @@ import { * Listens for changes in the number of participants to calculate the dimensions of the tile view grid and the tiles. */ StateListenerRegistry.register( - /* selector */ state => state['features/base/participants'].length, + /* selector */ getParticipantCountWithFake, /* listener */ (numberOfParticipants, store) => { const state = store.getState(); diff --git a/react/features/invite/actions.any.js b/react/features/invite/actions.any.js index d14329e86..d26301b42 100644 --- a/react/features/invite/actions.any.js +++ b/react/features/invite/actions.any.js @@ -3,7 +3,7 @@ import type { Dispatch } from 'redux'; import { getInviteURL } from '../base/connection'; -import { getLocalParticipant, getParticipants } from '../base/participants'; +import { getLocalParticipant, getParticipantCount } from '../base/participants'; import { inviteVideoRooms } from '../videosipgw'; import { @@ -71,14 +71,14 @@ export function invite( dispatch: Dispatch, getState: Function): Promise> => { const state = getState(); - const participants = getParticipants(state); + const participantsCount = getParticipantCount(state); const { calleeInfoVisible } = state['features/invite']; if (showCalleeInfo && !calleeInfoVisible && invitees.length === 1 && invitees[0].type === INVITE_TYPES.USER - && participants.length === 1) { + && participantsCount === 1) { dispatch(setCalleeInfoVisible(true, invitees[0])); } diff --git a/react/features/invite/components/callee-info/CalleeInfo.js b/react/features/invite/components/callee-info/CalleeInfo.js index 69888d05f..6f5c62cc2 100644 --- a/react/features/invite/components/callee-info/CalleeInfo.js +++ b/react/features/invite/components/callee-info/CalleeInfo.js @@ -5,9 +5,9 @@ import React, { Component } from 'react'; import { Avatar } from '../../../base/avatar'; import { MEDIA_TYPE } from '../../../base/media'; import { - getParticipants, getParticipantDisplayName, - getParticipantPresenceStatus + getParticipantPresenceStatus, + getRemoteParticipants } from '../../../base/participants'; import { Container, Text } from '../../../base/react'; import { connect } from '../../../base/redux'; @@ -135,20 +135,20 @@ class CalleeInfo extends Component { function _mapStateToProps(state) { const _isVideoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO); - const poltergeist - = getParticipants(state).find(p => p.botType === 'poltergeist'); - if (poltergeist) { - const { id } = poltergeist; - - return { - _callee: { - id, - name: getParticipantDisplayName(state, id), - status: getParticipantPresenceStatus(state, id) - }, - _isVideoMuted - }; + // 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 { diff --git a/react/features/invite/middleware.any.js b/react/features/invite/middleware.any.js index e82a61de5..4485b52a2 100644 --- a/react/features/invite/middleware.any.js +++ b/react/features/invite/middleware.any.js @@ -6,8 +6,9 @@ import { } from '../base/conference'; import { getLocalParticipant, + getParticipantCount, getParticipantPresenceStatus, - getParticipants, + getRemoteParticipants, PARTICIPANT_JOINED, PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_LEFT, @@ -167,13 +168,19 @@ function _maybeHideCalleeInfo(action, store) { if (!state['features/invite'].calleeInfoVisible) { return; } - const participants = getParticipants(state); - const numberOfPoltergeists - = participants.filter(p => p.botType === 'poltergeist').length; - const numberOfRealParticipants = participants.length - numberOfPoltergeists; + 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 && participants.length === 1)) { + || (action.type === PARTICIPANT_LEFT && participantCount === 1)) { store.dispatch(setCalleeInfoVisible(false)); } } diff --git a/react/features/large-video/actions.any.js b/react/features/large-video/actions.any.js index 315500c90..0c0b3f6ff 100644 --- a/react/features/large-video/actions.any.js +++ b/react/features/large-video/actions.any.js @@ -3,6 +3,12 @@ import type { Dispatch } from 'redux'; import { MEDIA_TYPE } from '../base/media'; +import { + getDominantSpeakerParticipant, + getLocalParticipant, + getPinnedParticipant, + getRemoteParticipants +} from '../base/participants'; import { SELECT_LARGE_VIDEO_PARTICIPANT, @@ -92,8 +98,7 @@ function _electLastVisibleRemoteVideo(tracks) { function _electParticipantInLargeVideo(state) { // 1. If a participant is pinned, they will be shown in the LargeVideo // (regardless of whether they are local or remote). - const participants = state['features/base/participants']; - let participant = participants.find(p => p.pinned); + let participant = getPinnedParticipant(state); if (participant) { return participant.id; @@ -107,11 +112,14 @@ function _electParticipantInLargeVideo(state) { } // 3. Next, pick the dominant speaker (other than self). - participant = participants.find(p => p.dominantSpeaker && !p.local); - if (participant) { + participant = getDominantSpeakerParticipant(state); + if (participant && !participant.local) { return participant.id; } + // In case this is the local participant. + participant = undefined; + // 4. Next, pick the most recent participant with video. const tracks = state['features/base/tracks']; const videoTrack = _electLastVisibleRemoteVideo(tracks); @@ -122,6 +130,9 @@ function _electParticipantInLargeVideo(state) { // 5. As a last resort, select the participant that joined last (other than poltergist or other bot type // participants). + + const participants = [ ...getRemoteParticipants(state).values() ]; + for (let i = participants.length; i > 0 && !participant; i--) { const p = participants[i - 1]; @@ -131,5 +142,5 @@ function _electParticipantInLargeVideo(state) { return participant.id; } - return participants.find(p => p.local)?.id; + return getLocalParticipant(state)?.id; } diff --git a/react/features/mobile/external-api/middleware.js b/react/features/mobile/external-api/middleware.js index 93d89d8e4..2e8ff42a1 100644 --- a/react/features/mobile/external-api/middleware.js +++ b/react/features/mobile/external-api/middleware.js @@ -28,7 +28,13 @@ import { import { JitsiConferenceEvents } from '../../base/lib-jitsi-meet'; import { MEDIA_TYPE } from '../../base/media'; import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes'; -import { PARTICIPANT_JOINED, PARTICIPANT_LEFT, getParticipants, getParticipantById } from '../../base/participants'; +import { + PARTICIPANT_JOINED, + PARTICIPANT_LEFT, + getParticipantById, + getRemoteParticipants, + getLocalParticipant +} from '../../base/participants'; import { MiddlewareRegistry, StateListenerRegistry } from '../../base/redux'; import { toggleScreensharing } from '../../base/tracks'; import { OPEN_CHAT, CLOSE_CHAT } from '../../chat'; @@ -268,6 +274,24 @@ StateListenerRegistry.register( }, 100)); +/** + * Returns a participant info object based on the passed participant object from redux. + * + * @param {Participant} participant - The participant object from the redux store. + * @returns {Object} - The participant info object. + */ +function _participantToParticipantInfo(participant) { + return { + isLocal: participant.local, + email: participant.email, + name: participant.name, + participantId: participant.id, + displayName: participant.displayName, + avatarUrl: participant.avatarURL, + role: participant.role + }; +} + /** * Registers for events sent from the native side via NativeEventEmitter. * @@ -309,16 +333,15 @@ function _registerForNativeEvents(store) { eventEmitter.addListener(ExternalAPI.RETRIEVE_PARTICIPANTS_INFO, ({ requestId }) => { - const participantsInfo = getParticipants(store).map(participant => { - return { - isLocal: participant.local, - email: participant.email, - name: participant.name, - participantId: participant.id, - displayName: participant.displayName, - avatarUrl: participant.avatarURL, - role: participant.role - }; + const participantsInfo = []; + const remoteParticipants = getRemoteParticipants(store); + const localParticipant = getLocalParticipant(store); + + participantsInfo.push(_participantToParticipantInfo(localParticipant)); + remoteParticipants.forEach(participant => { + if (!participant.isFakeParticipant) { + participantsInfo.push(_participantToParticipantInfo(participant)); + } }); sendEvent( diff --git a/react/features/participants-pane/components/AskToUnmuteButton.js b/react/features/participants-pane/components/AskToUnmuteButton.js index 266038651..0200751cf 100644 --- a/react/features/participants-pane/components/AskToUnmuteButton.js +++ b/react/features/participants-pane/components/AskToUnmuteButton.js @@ -1,7 +1,6 @@ // @flow import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { approveParticipant } from '../../av-moderation/actions'; @@ -10,6 +9,11 @@ import { QuickActionButton } from './styled'; type Props = { + /** + * The translated ask unmute text. + */ + askUnmuteText: string, + /** * Participant id. */ @@ -22,10 +26,8 @@ type Props = { * @param {Object} participant - Participant reference. * @returns {React$Element<'button'>} */ -export default function({ id }: Props) { +export default function AskToUnmuteButton({ id, askUnmuteText }: Props) { const dispatch = useDispatch(); - const { t } = useTranslation(); - const askToUnmute = useCallback(() => { dispatch(approveParticipant(id)); }, [ dispatch, id ]); @@ -37,7 +39,7 @@ export default function({ id }: Props) { theme = {{ panePadding: 16 }}> - {t('participantsPane.actions.askUnmute')} + { askUnmuteText } ); } diff --git a/react/features/participants-pane/components/FooterContextMenu.js b/react/features/participants-pane/components/FooterContextMenu.js index aeefaa575..0115eafcd 100644 --- a/react/features/participants-pane/components/FooterContextMenu.js +++ b/react/features/participants-pane/components/FooterContextMenu.js @@ -6,11 +6,17 @@ import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions'; -import { isEnabled as isAvModerationEnabled } from '../../av-moderation/functions'; +import { + isEnabled as isAvModerationEnabled, + isSupported as isAvModerationSupported +} from '../../av-moderation/functions'; import { openDialog } from '../../base/dialog'; import { Icon, IconCheck, IconVideoOff } from '../../base/icons'; import { MEDIA_TYPE } from '../../base/media'; -import { getLocalParticipant } from '../../base/participants'; +import { + getLocalParticipant, + isEveryoneModerator +} from '../../base/participants'; import { MuteEveryonesVideoDialog } from '../../video-menu/components'; import { @@ -49,6 +55,8 @@ type Props = { export const FooterContextMenu = ({ onMouseLeave }: Props) => { const dispatch = useDispatch(); + const isModerationSupported = useSelector(isAvModerationSupported()); + const allModerators = useSelector(isEveryoneModerator); const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO)); const { id } = useSelector(getLocalParticipant); const { t } = useTranslation(); @@ -75,27 +83,32 @@ export const FooterContextMenu = ({ onMouseLeave }: Props) => { { t('participantsPane.actions.stopEveryonesVideo') } -
- {t('participantsPane.actions.allow')} -
- { isModerationEnabled ? ( - - - { t('participantsPane.actions.startModeration') } - - - ) : ( - - - { t('participantsPane.actions.startModeration') } - - )} + { isModerationSupported && !allModerators ? ( + <> +
+ {t('participantsPane.actions.allow')} +
+ { isModerationEnabled ? ( + + + { t('participantsPane.actions.startModeration') } + + + ) : ( + + + { t('participantsPane.actions.startModeration') } + + )} + + ) : undefined + } ); }; diff --git a/react/features/participants-pane/components/LobbyParticipantItem.js b/react/features/participants-pane/components/LobbyParticipantItem.js index fed8eba09..30a7a502d 100644 --- a/react/features/participants-pane/components/LobbyParticipantItem.js +++ b/react/features/participants-pane/components/LobbyParticipantItem.js @@ -7,7 +7,7 @@ import { useDispatch } from 'react-redux'; import { approveKnockingParticipant, rejectKnockingParticipant } from '../../lobby/actions'; import { ACTION_TRIGGER, MEDIA_STATE } from '../constants'; -import { ParticipantItem } from './ParticipantItem'; +import ParticipantItem from './ParticipantItem'; import { ParticipantActionButton } from './styled'; type Props = { @@ -28,9 +28,12 @@ export const LobbyParticipantItem = ({ participant: p }: Props) => { + displayName = { p.name } + local = { p.local } + participantID = { p.id } + raisedHand = { p.raisedHand } + videoMuteState = { MEDIA_STATE.NONE } + youText = { t('chat.you') }> {t('lobby.reject')} diff --git a/react/features/participants-pane/components/MeetingParticipantContextMenu.js b/react/features/participants-pane/components/MeetingParticipantContextMenu.js index 1f7d4de51..13eec8de5 100644 --- a/react/features/participants-pane/components/MeetingParticipantContextMenu.js +++ b/react/features/participants-pane/components/MeetingParticipantContextMenu.js @@ -1,11 +1,10 @@ // @flow -import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { Component } from 'react'; import { isToolbarButtonEnabled } from '../../base/config/functions.web'; import { openDialog } from '../../base/dialog'; +import { translate } from '../../base/i18n'; import { IconCloseCircle, IconCrown, @@ -14,8 +13,13 @@ import { IconMuteEveryoneElse, IconVideoOff } from '../../base/icons'; -import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants'; -import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks'; +import { + getParticipantByIdOrUndefined, + isLocalParticipantModerator, + isParticipantModerator +} from '../../base/participants'; +import { connect } from '../../base/redux'; +import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks'; import { openChat } from '../../chat/actions'; import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu'; import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog'; @@ -31,6 +35,41 @@ import { type Props = { + /** + * True if the local participant is moderator and false otherwise. + */ + _isLocalModerator: boolean, + + /** + * True if the chat button is enabled and false otherwise. + */ + _isChatButtonEnabled: boolean, + + /** + * True if the participant is moderator and false otherwise. + */ + _isParticipantModerator: boolean, + + /** + * True if the participant is video muted and false otherwise. + */ + _isParticipantVideoMuted: boolean, + + /** + * True if the participant is audio muted and false otherwise. + */ + _isParticipantAudioMuted: boolean, + + /** + * Participant reference + */ + _participant: Object, + + /** + * The dispatch function from redux. + */ + dispatch: Function, + /** * Callback used to open a confirmation dialog for audio muting. */ @@ -57,35 +96,145 @@ type Props = { onSelect: Function, /** - * Participant reference + * The ID of the participant. */ - participant: Object + participantID: string, + + /** + * The translate function. + */ + t: Function }; -export const MeetingParticipantContextMenu = ({ - offsetTarget, - onEnter, - onLeave, - onSelect, - muteAudio, - participant -}: Props) => { - const dispatch = useDispatch(); - const containerRef = useRef(null); - const isLocalModerator = useSelector(isLocalParticipantModerator); - const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat')); - const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant)); - const isParticipantAudioMuted = useSelector(getIsParticipantAudioMuted(participant)); - const [ isHidden, setIsHidden ] = useState(true); - const { t } = useTranslation(); +type State = { - useLayoutEffect(() => { - if (participant - && containerRef.current + /** + * If true the context menu will be hidden. + */ + isHidden: boolean +}; + +/** + * Implements the MeetingParticipantContextMenu component. + */ +class MeetingParticipantContextMenu extends Component { + + /** + * Reference to the context menu container div. + */ + _containerRef: Object; + + /** + * Creates new instance of MeetingParticipantContextMenu. + * + * @param {Props} props - The props. + */ + constructor(props: Props) { + super(props); + + this.state = { + isHidden: true + }; + + this._containerRef = React.createRef(); + + this._onGrantModerator = this._onGrantModerator.bind(this); + this._onKick = this._onKick.bind(this); + this._onMuteEveryoneElse = this._onMuteEveryoneElse.bind(this); + this._onMuteVideo = this._onMuteVideo.bind(this); + this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this); + this._position = this._position.bind(this); + } + + _onGrantModerator: () => void; + + /** + * Grant moderator permissions. + * + * @returns {void} + */ + _onGrantModerator() { + const { _participant, dispatch } = this.props; + + dispatch(openDialog(GrantModeratorDialog, { + participantID: _participant?.id + })); + } + + _onKick: () => void; + + /** + * Kicks the participant. + * + * @returns {void} + */ + _onKick() { + const { _participant, dispatch } = this.props; + + dispatch(openDialog(KickRemoteParticipantDialog, { + participantID: _participant?.id + })); + } + + _onMuteEveryoneElse: () => void; + + /** + * Mutes everyone else. + * + * @returns {void} + */ + _onMuteEveryoneElse() { + const { _participant, dispatch } = this.props; + + dispatch(openDialog(MuteEveryoneDialog, { + exclude: [ _participant?.id ] + })); + } + + _onMuteVideo: () => void; + + /** + * Mutes the video of the selected participant. + * + * @returns {void} + */ + _onMuteVideo() { + const { _participant, dispatch } = this.props; + + dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { + participantID: _participant?.id + })); + } + + _onSendPrivateMessage: () => void; + + /** + * Sends private message. + * + * @returns {void} + */ + _onSendPrivateMessage() { + const { _participant, dispatch } = this.props; + + dispatch(openChat(_participant)); + } + + _position: () => void; + + /** + * Positions the context menu. + * + * @returns {void} + */ + _position() { + const { _participant, offsetTarget } = this.props; + + if (_participant + && this._containerRef.current && offsetTarget?.offsetParent && offsetTarget.offsetParent instanceof HTMLElement ) { - const { current: container } = containerRef; + const { current: container } = this._containerRef; const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget; const outerHeight = getComputedOuterHeight(container); @@ -93,97 +242,158 @@ export const MeetingParticipantContextMenu = ({ ? offsetTop - outerHeight : offsetTop; - setIsHidden(false); + this.setState({ isHidden: false }); } else { - setIsHidden(true); + this.setState({ isHidden: true }); } - }, [ participant, offsetTarget ]); - - const grantModerator = useCallback(() => { - dispatch(openDialog(GrantModeratorDialog, { - participantID: participant.id - })); - }, [ dispatch, participant ]); - - const kick = useCallback(() => { - dispatch(openDialog(KickRemoteParticipantDialog, { - participantID: participant.id - })); - }, [ dispatch, participant ]); - - const muteEveryoneElse = useCallback(() => { - dispatch(openDialog(MuteEveryoneDialog, { - exclude: [ participant.id ] - })); - }, [ dispatch, participant ]); - - const muteVideo = useCallback(() => { - dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { - participantID: participant.id - })); - }, [ dispatch, participant ]); - - const sendPrivateMessage = useCallback(() => { - dispatch(openChat(participant)); - }, [ dispatch, participant ]); - - if (!participant) { - return null; } - return ( - - - {isLocalModerator && ( - <> - {!isParticipantAudioMuted - && - - {t('dialog.muteParticipantButton')} - } + /** + * Implements React Component's componentDidMount. + * + * @inheritdoc + * @returns {void} + */ + componentDidMount() { + this._position(); + } - - - {t('toolbar.accessibilityLabel.muteEveryoneElse')} - - - )} + /** + * Implements React Component's componentDidUpdate. + * + * @inheritdoc + */ + componentDidUpdate(prevProps: Props) { + if (prevProps.offsetTarget !== this.props.offsetTarget || prevProps._participant !== this.props._participant) { + this._position(); + } + } - {isLocalModerator && (isParticipantVideoMuted || ( - - - {t('participantsPane.actions.stopVideo')} - - ))} - + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + _isLocalModerator, + _isChatButtonEnabled, + _isParticipantModerator, + _isParticipantVideoMuted, + _isParticipantAudioMuted, + _participant, + onEnter, + onLeave, + onSelect, + muteAudio, + t + } = this.props; - - {isLocalModerator && ( - <> - {!isParticipantModerator(participant) - && - - {t('toolbar.accessibilityLabel.grantModerator')} - } - - - {t('videothumbnail.kick')} - - - )} - {isChatButtonEnabled && ( - - - {t('toolbar.accessibilityLabel.privateMessage')} - - )} - - - ); -}; + if (!_participant) { + return null; + } + + return ( + + + { + _isLocalModerator && ( + <> + { + !_isParticipantAudioMuted + && + + {t('dialog.muteParticipantButton')} + + } + + + + {t('toolbar.accessibilityLabel.muteEveryoneElse')} + + + ) + } + + { + _isLocalModerator && ( + _isParticipantVideoMuted || ( + + + {t('participantsPane.actions.stopVideo')} + + ) + ) + } + + + + { + _isLocalModerator && ( + <> + { + !_isParticipantModerator && ( + + + {t('toolbar.accessibilityLabel.grantModerator')} + + ) + } + + + { t('videothumbnail.kick') } + + + ) + } + { + _isChatButtonEnabled && ( + + + {t('toolbar.accessibilityLabel.privateMessage')} + + ) + } + + + ); + } +} + +/** + * Maps (parts of) the redux state to the associated props for this component. + * + * @param {Object} state - The Redux state. + * @param {Object} ownProps - The own props of the component. + * @private + * @returns {Props} + */ +function _mapStateToProps(state, ownProps): Object { + const { participantID } = ownProps; + + const participant = getParticipantByIdOrUndefined(state, participantID); + + const _isLocalModerator = isLocalParticipantModerator(state); + const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state); + const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state); + const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state); + const _isParticipantModerator = isParticipantModerator(participant); + + return { + _isLocalModerator, + _isChatButtonEnabled, + _isParticipantModerator, + _isParticipantVideoMuted, + _isParticipantAudioMuted, + _participant: participant + }; +} + +export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu)); diff --git a/react/features/participants-pane/components/MeetingParticipantItem.js b/react/features/participants-pane/components/MeetingParticipantItem.js index 276d62ec1..ae3133bc6 100644 --- a/react/features/participants-pane/components/MeetingParticipantItem.js +++ b/react/features/participants-pane/components/MeetingParticipantItem.js @@ -1,19 +1,62 @@ // @flow import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; -import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks'; -import { ACTION_TRIGGER, MEDIA_STATE } from '../constants'; -import { getParticipantAudioMediaState } from '../functions'; +import { getParticipantByIdOrUndefined, getParticipantDisplayName } from '../../base/participants'; +import { connect } from '../../base/redux'; +import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks'; +import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../constants'; +import { getParticipantAudioMediaState, getQuickActionButtonType } from '../functions'; -import { ParticipantItem } from './ParticipantItem'; +import ParticipantItem from './ParticipantItem'; import ParticipantQuickAction from './ParticipantQuickAction'; import { ParticipantActionEllipsis } from './styled'; type Props = { + /** + * Media state for audio. + */ + _audioMediaState: MediaState, + + /** + * The display name of the participant. + */ + _displayName: string, + + /** + * True if the participant is video muted. + */ + _isVideoMuted: boolean, + + /** + * True if the participant is the local participant. + */ + _local: boolean, + + /** + * The participant ID. + * + * NOTE: This ID may be different from participantID prop in the case when we pass undefined for the local + * participant. In this case the local participant ID will be filled trough _participantID prop. + */ + _participantID: string, + + /** + * The type of button to be rendered for the quick action. + */ + _quickActionButtonType: string, + + /** + * True if the participant have raised hand. + */ + _raisedHand: boolean, + + /** + * The translated ask unmute text for the qiuck action buttons. + */ + askUnmuteText: string, + /** * Is this item highlighted */ @@ -24,6 +67,11 @@ type Props = { */ muteAudio: Function, + /** + * The translated text for the mute participant button. + */ + muteParticipantButtonText: string, + /** * Callback for the activation of this item's context menu */ @@ -35,38 +83,97 @@ type Props = { onLeave: Function, /** - * Participant reference + * The aria-label for the ellipsis action. */ - participant: Object + participantActionEllipsisLabel: string, + + /** + * The ID of the participant. + */ + participantID: ?string, + + /** + * The translated "you" text. + */ + youText: string }; -export const MeetingParticipantItem = ({ +/** + * Implements the MeetingParticipantItem component. + * + * @param {Props} props - The props of the component. + * @returns {ReactElement} + */ +function MeetingParticipantItem({ + _audioMediaState, + _displayName, + _isVideoMuted, + _local, + _participantID, + _quickActionButtonType, + _raisedHand, + askUnmuteText, isHighlighted, onContextMenu, onLeave, muteAudio, - participant -}: Props) => { - const { t } = useTranslation(); - const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant)); - const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant)); - const audioMediaState = useSelector(getParticipantAudioMediaState(participant, isAudioMuted)); - + muteParticipantButtonText, + participantActionEllipsisLabel, + youText +}: Props) { return ( + participantID = { _participantID } + raisedHand = { _raisedHand } + videoMuteState = { _isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED } + youText = { youText }> + muteParticipantButtonText = { muteParticipantButtonText } + participantID = { _participantID } /> ); -}; +} + +/** + * Maps (parts of) the redux state to the associated props for this component. + * + * @param {Object} state - The Redux state. + * @param {Object} ownProps - The own props of the component. + * @private + * @returns {Props} + */ +function _mapStateToProps(state, ownProps): Object { + const { participantID } = ownProps; + + const participant = getParticipantByIdOrUndefined(state, participantID); + + const _isAudioMuted = isParticipantAudioMuted(participant, state); + const _isVideoMuted = isParticipantVideoMuted(participant, state); + const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state); + const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, state); + + return { + _audioMediaState, + _displayName: getParticipantDisplayName(state, participant?.id), + _isAudioMuted, + _isVideoMuted, + _local: Boolean(participant?.local), + _participantID: participant?.id, + _quickActionButtonType, + _raisedHand: Boolean(participant?.raisedHand) + }; +} + +export default connect(_mapStateToProps)(MeetingParticipantItem); diff --git a/react/features/participants-pane/components/MeetingParticipantList.js b/react/features/participants-pane/components/MeetingParticipantList.js index edb18dfed..2eb96da0d 100644 --- a/react/features/participants-pane/components/MeetingParticipantList.js +++ b/react/features/participants-pane/components/MeetingParticipantList.js @@ -1,18 +1,21 @@ // @flow -import _ from 'lodash'; import React, { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector, useDispatch } from 'react-redux'; import { openDialog } from '../../base/dialog'; -import { getParticipants } from '../../base/participants'; +import { + getLocalParticipant, + getParticipantCountWithFake, + getRemoteParticipants +} from '../../base/participants'; import MuteRemoteParticipantDialog from '../../video-menu/components/web/MuteRemoteParticipantDialog'; import { findStyledAncestor, shouldRenderInviteButton } from '../functions'; import { InviteButton } from './InviteButton'; -import { MeetingParticipantContextMenu } from './MeetingParticipantContextMenu'; -import { MeetingParticipantItem } from './MeetingParticipantItem'; +import MeetingParticipantContextMenu from './MeetingParticipantContextMenu'; +import MeetingParticipantItem from './MeetingParticipantItem'; import { Heading, ParticipantContainer } from './styled'; type NullProto = { @@ -20,7 +23,7 @@ type NullProto = { __proto__: null }; -type RaiseContext = NullProto | { +type RaiseContext = NullProto | {| /** * Target elements against which positioning calculations are made @@ -28,17 +31,28 @@ type RaiseContext = NullProto | { offsetTarget?: HTMLElement, /** - * Participant reference + * The ID of the participant. */ - participant?: Object, -}; + participantID?: String, +|}; const initialState = Object.freeze(Object.create(null)); -export const MeetingParticipantList = () => { +/** + * Renders the MeetingParticipantList component. + * + * @returns {ReactNode} - The component. + */ +export function MeetingParticipantList() { const dispatch = useDispatch(); const isMouseOverMenu = useRef(false); - const participants = useSelector(getParticipants, _.isEqual); + const participants = useSelector(getRemoteParticipants); + const localParticipant = useSelector(getLocalParticipant); + + // This is very important as getRemoteParticipants is not changing its reference object + // and we will not re-render on change, but if count changes we will do + const participantsCount = useSelector(getParticipantCountWithFake); + const showInviteButton = useSelector(shouldRenderInviteButton); const [ raiseContext, setRaiseContext ] = useState(initialState); const { t } = useTranslation(); @@ -61,20 +75,20 @@ export const MeetingParticipantList = () => { }); }, [ raiseContext ]); - const raiseMenu = useCallback((participant, target) => { + const raiseMenu = useCallback((participantID, target) => { setRaiseContext({ - participant, + participantID, offsetTarget: findStyledAncestor(target, ParticipantContainer) }); }, [ raiseContext ]); - const toggleMenu = useCallback(participant => e => { - const { participant: raisedParticipant } = raiseContext; + const toggleMenu = useCallback(participantID => e => { + const { participantID: raisedParticipant } = raiseContext; - if (raisedParticipant && raisedParticipant === participant) { + if (raisedParticipant && raisedParticipant === participantID) { lowerMenu(); } else { - raiseMenu(participant, e.target); + raiseMenu(participantID, e.target); } }, [ raiseContext ]); @@ -91,20 +105,44 @@ export const MeetingParticipantList = () => { dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id })); }); + // FIXME: + // It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is + // taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty + // solution!!! + // One potential proper fix would be to use react-window component in order to lower the number of components + // mounted. + const participantActionEllipsisLabel = t('MeetingParticipantItem.ParticipantActionEllipsis.options'); + const youText = t('chat.you'); + const askUnmuteText = t('participantsPane.actions.askUnmute'); + const muteParticipantButtonText = t('dialog.muteParticipantButton'); + + const renderParticipant = id => ( + + ); + + const items = []; + + localParticipant && items.push(renderParticipant(localParticipant?.id)); + participants.forEach(p => { + items.push(renderParticipant(p?.id)); + }); + return ( <> - {t('participantsPane.headings.participantsList', { count: participants.length })} + {t('participantsPane.headings.participantsList', { count: participantsCount })} {showInviteButton && }
- {participants.map(p => ( - - ))} + { items }
{ { ...raiseContext } /> ); -}; +} diff --git a/react/features/participants-pane/components/ParticipantItem.js b/react/features/participants-pane/components/ParticipantItem.js index 665bf598e..7baa9174a 100644 --- a/react/features/participants-pane/components/ParticipantItem.js +++ b/react/features/participants-pane/components/ParticipantItem.js @@ -1,8 +1,6 @@ // @flow import React, { type Node } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import { Avatar } from '../../base/avatar'; import { @@ -12,7 +10,6 @@ import { IconMicrophoneEmpty, IconMicrophoneEmptySlash } from '../../base/icons'; -import { getParticipantDisplayNameWithId } from '../../base/participants'; import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../constants'; import { RaisedHandIndicator } from './RaisedHandIndicator'; @@ -100,15 +97,20 @@ type Props = { */ children: Node, + /** + * The name of the participant. Used for showing lobby names. + */ + displayName: string, + /** * Is this item highlighted/raised */ isHighlighted?: boolean, /** - * The name of the participant. Used for showing lobby names. + * True if the participant is local. */ - name?: string, + local: boolean, /** * Callback for when the mouse leaves this component @@ -116,29 +118,46 @@ type Props = { onLeave?: Function, /** - * Participant reference + * The ID of the participant. */ - participant: Object, + participantID: string, + + /** + * True if the participant have raised hand. + */ + raisedHand: boolean, /** * Media state for video */ - videoMuteState: MediaState + videoMuteState: MediaState, + + /** + * The translated "you" text. + */ + youText: string } -export const ParticipantItem = ({ +/** + * A component representing a participant entry in ParticipantPane and Lobby. + * + * @param {Props} props - The props of the component. + * @returns {ReactNode} + */ +export default function ParticipantItem({ children, isHighlighted, onLeave, actionsTrigger = ACTION_TRIGGER.HOVER, audioMediaState = MEDIA_STATE.NONE, videoMuteState = MEDIA_STATE.NONE, - name, - participant: p -}: Props) => { + displayName, + participantID, + local, + raisedHand, + youText +}: Props) { const ParticipantActions = Actions[actionsTrigger]; - const { t } = useTranslation(); - const displayName = name || useSelector(getParticipantDisplayNameWithId(p.id)); return ( { displayName } - { p.local ?  ({t('chat.you')}) : null } + { local ?  ({ youText }) : null } - { !p.local && } + { !local && } - {p.raisedHand && } - {VideoStateIcons[videoMuteState]} - {AudioStateIcons[audioMediaState]} + { raisedHand && } + { VideoStateIcons[videoMuteState] } + { AudioStateIcons[audioMediaState] } ); -}; +} diff --git a/react/features/participants-pane/components/ParticipantQuickAction.js b/react/features/participants-pane/components/ParticipantQuickAction.js index c34e580e3..8396473b7 100644 --- a/react/features/participants-pane/components/ParticipantQuickAction.js +++ b/react/features/participants-pane/components/ParticipantQuickAction.js @@ -1,11 +1,8 @@ // @flow import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import { QUICK_ACTION_BUTTON } from '../constants'; -import { getQuickActionButtonType } from '../functions'; import AskToUnmuteButton from './AskToUnmuteButton'; import { QuickActionButton } from './styled'; @@ -13,19 +10,26 @@ import { QuickActionButton } from './styled'; type Props = { /** - * If audio is muted for the current participant. + * The translated "ask unmute" text. */ - isAudioMuted: Boolean, + askUnmuteText: string, + + /** + * The type of button to be displayed. + */ + buttonType: string, /** * Callback used to open a confirmation dialog for audio muting. */ muteAudio: Function, + muteParticipantButtonText: string, + /** - * Participant. + * The ID of the participant. */ - participant: Object, + participantID: string, } /** @@ -34,23 +38,29 @@ type Props = { * @param {Props} props - The props of the component. * @returns {React$Element<'button'>} */ -export default function({ isAudioMuted, muteAudio, participant }: Props) { - const buttonType = useSelector(getQuickActionButtonType(participant, isAudioMuted)); - const { id } = participant; - const { t } = useTranslation(); - +export default function ParticipantQuickAction({ + askUnmuteText, + buttonType, + muteAudio, + muteParticipantButtonText, + participantID +}: Props) { switch (buttonType) { case QUICK_ACTION_BUTTON.MUTE: { return ( - {t('dialog.muteParticipantButton')} + { muteParticipantButtonText } ); } case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: { - return ; + return ( + + ); } default: { return null; diff --git a/react/features/participants-pane/components/ParticipantsPane.js b/react/features/participants-pane/components/ParticipantsPane.js index f76efd0df..8410479cd 100644 --- a/react/features/participants-pane/components/ParticipantsPane.js +++ b/react/features/participants-pane/components/ParticipantsPane.js @@ -1,16 +1,15 @@ // @flow -import React, { useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { Component } from 'react'; import { ThemeProvider } from 'styled-components'; import { openDialog } from '../../base/dialog'; +import { translate } from '../../base/i18n'; import { getParticipantCount, - isEveryoneModerator, isLocalParticipantModerator } from '../../base/participants'; +import { connect } from '../../base/redux'; import { MuteEveryoneDialog } from '../../video-menu/components/'; import { close } from '../actions'; import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions'; @@ -30,74 +29,238 @@ import { Header } from './styled'; -export const ParticipantsPane = () => { - const dispatch = useDispatch(); - const paneOpen = useSelector(getParticipantsPaneOpen); - const isLocalModerator = useSelector(isLocalParticipantModerator); - const participantsCount = useSelector(getParticipantCount); - const everyoneModerator = useSelector(isEveryoneModerator); - const showContextMenu = !everyoneModerator && participantsCount > 2; +/** + * The type of the React {@code Component} props of {@link ParticipantsPane}. + */ +type Props = { - const [ contextOpen, setContextOpen ] = useState(false); - const { t } = useTranslation(); + /** + * Is the participants pane open. + */ + _paneOpen: boolean, - const closePane = useCallback(() => dispatch(close(), [ dispatch ])); - const closePaneKeyPress = useCallback(e => { - if (closePane && (e.key === ' ' || e.key === 'Enter')) { - e.preventDefault(); - closePane(); - } - }, [ closePane ]); - const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]); + /** + * Whether to show context menu. + */ + _showContextMenu: boolean, - useEffect(() => { - const handler = [ 'click', e => { - if (!findStyledAncestor(e.target, FooterEllipsisContainer)) { - setContextOpen(false); - } - } ]; + /** + * Whether to show the footer menu. + */ + _showFooter: boolean, - window.addEventListener(...handler); + /** + * The Redux dispatch function. + */ + dispatch: Function, - return () => window.removeEventListener(...handler); - }, [ contextOpen ]); - - const toggleContext = useCallback(() => setContextOpen(!contextOpen), [ contextOpen, setContextOpen ]); - - return ( - -
-
-
- -
- - - - - - {isLocalModerator && ( -
- - {t('participantsPane.actions.muteAll')} - - {showContextMenu && ( - - - {contextOpen && } - - )} -
- )} -
-
-
- ); + /** + * The i18n translate function. + */ + t: Function }; + +/** + * The type of the React {@code Component} state of {@link ParticipantsPane}. + */ +type State = { + + /** + * Indicates if the footer context menu is open. + */ + contextOpen: boolean, +}; + +/** + * Implements the participants list. + */ +class ParticipantsPane extends Component { + /** + * Initializes a new {@code ParticipantsPane} instance. + * + * @inheritdoc + */ + constructor(props) { + super(props); + + this.state = { + contextOpen: false + }; + + // Bind event handlers so they are only bound once per instance. + this._onClosePane = this._onClosePane.bind(this); + this._onKeyPress = this._onKeyPress.bind(this); + this._onMuteAll = this._onMuteAll.bind(this); + this._onToggleContext = this._onToggleContext.bind(this); + this._onWindowClickListener = this._onWindowClickListener.bind(this); + } + + + /** + * Implements React's {@link Component#componentDidMount()}. + * + * @inheritdoc + */ + componentDidMount() { + window.addEventListener('click', this._onWindowClickListener); + } + + /** + * Implements React's {@link Component#componentWillUnmount()}. + * + * @inheritdoc + */ + componentWillUnmount() { + window.removeEventListener('click', this._onWindowClickListener); + } + + /** + * Implements React's {@link Component#render}. + * + * @inheritdoc + */ + render() { + const { + _paneOpen, + _showContextMenu, + _showFooter, + t + } = this.props; + + // when the pane is not open optimize to not + // execute the MeetingParticipantList render for large list of participants + if (!_paneOpen) { + return null; + } + + return ( + +
+
+
+ +
+ + + + + + {_showFooter && ( +
+ + {t('participantsPane.actions.muteAll')} + + {_showContextMenu && ( + + + {this.state.contextOpen + && } + + )} +
+ )} +
+
+
+ ); + } + + _onClosePane: () => void; + + /** + * Callback for closing the participant pane. + * + * @private + * @returns {void} + */ + _onClosePane() { + this.props.dispatch(close()); + } + + _onKeyPress: (Object) => void; + + /** + * KeyPress handler for accessibility for closing the participants pane. + * + * @param {Object} e - The key event to handle. + * + * @returns {void} + */ + _onKeyPress(e) { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + this._onClosePane(); + } + } + + _onMuteAll: () => void; + + /** + * The handler for clicking mute all button. + * + * @returns {void} + */ + _onMuteAll() { + this.props.dispatch(openDialog(MuteEveryoneDialog)); + } + + _onToggleContext: () => void; + + /** + * Handler for toggling open/close of the footer context menu. + * + * @returns {void} + */ + _onToggleContext() { + this.setState({ + contextOpen: !this.state.contextOpen + }); + } + + _onWindowClickListener: (event: Object) => void; + + /** + * Window click event listener. + * + * @param {Event} e - The click event. + * @returns {void} + */ + _onWindowClickListener(e) { + if (this.state.contextOpen && !findStyledAncestor(e.target, FooterEllipsisContainer)) { + this.setState({ + contextOpen: false + }); + } + } +} + +/** + * Maps (parts of) the redux state to the React {@code Component} props of + * {@code ParticipantsPane}. + * + * @param {Object} state - The redux state. + * @protected + * @returns {{ + * _paneOpen: boolean, + * _showContextMenu: boolean, + * _showFooter: boolean + * }} + */ +function _mapStateToProps(state: Object) { + const isPaneOpen = getParticipantsPaneOpen(state); + + return { + _paneOpen: isPaneOpen, + _showContextMenu: isPaneOpen && getParticipantCount(state) > 2, + _showFooter: isPaneOpen && isLocalParticipantModerator(state) + }; +} + +export default translate(connect(_mapStateToProps)(ParticipantsPane)); diff --git a/react/features/participants-pane/components/index.js b/react/features/participants-pane/components/index.js index 0b623880f..e3d2551a9 100644 --- a/react/features/participants-pane/components/index.js +++ b/react/features/participants-pane/components/index.js @@ -1,10 +1,7 @@ export * from './InviteButton'; export * from './LobbyParticipantItem'; export * from './LobbyParticipantList'; -export * from './MeetingParticipantContextMenu'; -export * from './MeetingParticipantItem'; export * from './MeetingParticipantList'; -export * from './ParticipantItem'; -export * from './ParticipantsPane'; +export { default as ParticipantsPane } from './ParticipantsPane'; export * from './ParticipantsPaneButton'; export * from './RaisedHandIndicator'; diff --git a/react/features/participants-pane/functions.js b/react/features/participants-pane/functions.js index c91e031f6..6b3081050 100644 --- a/react/features/participants-pane/functions.js +++ b/react/features/participants-pane/functions.js @@ -41,13 +41,14 @@ export const findStyledAncestor = (target: Object, component: any) => { }; /** - * Returns a selector used to determine if a participant is force muted. + * Checks if a participant is force muted. * - * @param {Object} participant - The participant id. + * @param {Object} participant - The participant. * @param {MediaType} mediaType - The media type. - * @returns {MediaState}. + * @param {Object} state - The redux state. + * @returns {MediaState} */ -export const isForceMuted = (participant: Object, mediaType: MediaType) => (state: Object) => { +export function isForceMuted(participant: Object, mediaType: MediaType, state: Object) { if (getParticipantCount(state) > 2 && isEnabledFromState(mediaType, state)) { if (participant.local) { return !isLocalParticipantApprovedFromState(mediaType, state); @@ -62,18 +63,19 @@ export const isForceMuted = (participant: Object, mediaType: MediaType) => (stat } return false; -}; +} /** - * Returns a selector used to determine the audio media state (the mic icon) for a participant. + * Determines the audio media state (the mic icon) for a participant. * * @param {Object} participant - The participant. * @param {boolean} muted - The mute state of the participant. - * @returns {MediaState}. + * @param {Object} state - The redux state. + * @returns {MediaState} */ -export const getParticipantAudioMediaState = (participant: Object, muted: Boolean) => (state: Object) => { +export function getParticipantAudioMediaState(participant: Object, muted: Boolean, state: Object) { if (muted) { - if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) { + if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { return MEDIA_STATE.FORCE_MUTED; } @@ -81,7 +83,7 @@ export const getParticipantAudioMediaState = (participant: Object, muted: Boolea } return MEDIA_STATE.UNMUTED; -}; +} /** @@ -125,17 +127,18 @@ const getState = (state: Object) => state[REDUCER_KEY]; export const getParticipantsPaneOpen = (state: Object) => Boolean(getState(state)?.isOpen); /** - * Returns a selector used to determine the type of quick action button to be displayed for a participant. + * Returns the type of quick action button to be displayed for a participant. * The button is displayed when hovering a participant from the participant list. * * @param {Object} participant - The participant. * @param {boolean} isAudioMuted - If audio is muted for the participant. - * @returns {Function} + * @param {Object} state - The redux state. + * @returns {string} - The type of the quick action button. */ -export const getQuickActionButtonType = (participant: Object, isAudioMuted: Boolean) => (state: Object) => { +export function getQuickActionButtonType(participant: Object, isAudioMuted: Boolean, state: Object) { // handled only by moderators if (isLocalParticipantModerator(state)) { - if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) { + if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE; } if (!isAudioMuted) { @@ -144,7 +147,7 @@ export const getQuickActionButtonType = (participant: Object, isAudioMuted: Bool } return QUICK_ACTION_BUTTON.NONE; -}; +} /** * Returns true if the invite button should be rendered. diff --git a/react/features/shared-video/components/web/SharedVideo.js b/react/features/shared-video/components/web/SharedVideo.js index bd05fde8e..8375db0b9 100644 --- a/react/features/shared-video/components/web/SharedVideo.js +++ b/react/features/shared-video/components/web/SharedVideo.js @@ -144,7 +144,7 @@ function _mapStateToProps(state) { clientHeight, clientWidth, filmstripVisible: visible, - isOwner: ownerId === localParticipant.id, + isOwner: ownerId === localParticipant?.id, videoUrl }; } diff --git a/react/features/shared-video/functions.js b/react/features/shared-video/functions.js index aeb892f7c..ed35f8843 100644 --- a/react/features/shared-video/functions.js +++ b/react/features/shared-video/functions.js @@ -1,6 +1,6 @@ // @flow -import { getParticipants } from '../base/participants'; +import { getFakeParticipants } from '../base/participants'; import { VIDEO_PLAYER_PARTICIPANT_NAME, YOUTUBE_PLAYER_PARTICIPANT_NAME } from './constants'; @@ -41,7 +41,15 @@ export function isSharingStatus(status: string) { * @returns {boolean} */ export function isVideoPlaying(stateful: Object | Function): boolean { - return Boolean(getParticipants(stateful).find(p => p.isFakeParticipant - && (p.name === VIDEO_PLAYER_PARTICIPANT_NAME || p.name === YOUTUBE_PLAYER_PARTICIPANT_NAME)) - ); + let videoPlaying = false; + + // eslint-disable-next-line no-unused-vars + for (const [ id, p ] of getFakeParticipants(stateful)) { + if (p.name === VIDEO_PLAYER_PARTICIPANT_NAME || p.name === YOUTUBE_PLAYER_PARTICIPANT_NAME) { + videoPlaying = true; + break; + } + } + + return videoPlaying; } diff --git a/react/features/subtitles/components/Captions.web.js b/react/features/subtitles/components/Captions.web.js index 0228c37f5..6071771e0 100644 --- a/react/features/subtitles/components/Captions.web.js +++ b/react/features/subtitles/components/Captions.web.js @@ -2,6 +2,7 @@ import React from 'react'; +import { getParticipantCountWithFake } from '../../base/participants'; import { connect } from '../../base/redux'; import { @@ -75,7 +76,7 @@ class Captions function mapStateToProps(state) { return { ..._abstractMapStateToProps(state), - _isLifted: state['features/base/participants'].length < 2 + _isLifted: getParticipantCountWithFake(state) < 2 }; } diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index 7552b9be9..f6d9de5e5 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -17,7 +17,7 @@ import { translate } from '../../../base/i18n'; import JitsiMeetJS from '../../../base/lib-jitsi-meet'; import { getLocalParticipant, - getParticipants, + haveParticipantWithScreenSharingFeature, raiseHand } from '../../../base/participants'; import { connect } from '../../../base/redux'; @@ -183,15 +183,20 @@ type Props = { _raisedHand: boolean, /** - * Whether or not the local participant is screensharing. + * Whether or not the local participant is screenSharing. */ - _screensharing: boolean, + _screenSharing: boolean, /** * Whether or not the local participant is sharing a YouTube video. */ _sharingVideo: boolean, + /** + * The enabled buttons. + */ + _toolbarButtons: Array, + /** * Flag showing whether toolbar is visible. */ @@ -202,11 +207,6 @@ type Props = { */ _visibleButtons: Array, - /** - * Handler to check if a button is enabled. - */ - _shouldShowButton: Function, - /** * Returns the selected virtual source object. */ @@ -269,38 +269,39 @@ class Toolbox extends Component { * @returns {void} */ componentDidMount() { + const { _toolbarButtons } = this.props; const KEYBOARD_SHORTCUTS = [ - this.props._shouldShowButton('videoquality') && { + isToolbarButtonEnabled('videoquality', _toolbarButtons) && { character: 'A', exec: this._onShortcutToggleVideoQuality, helpDescription: 'toolbar.callQuality' }, - this.props._shouldShowButton('chat') && { + isToolbarButtonEnabled('chat', _toolbarButtons) && { character: 'C', exec: this._onShortcutToggleChat, helpDescription: 'keyboardShortcuts.toggleChat' }, - this.props._shouldShowButton('desktop') && { + isToolbarButtonEnabled('desktop', _toolbarButtons) && { character: 'D', exec: this._onShortcutToggleScreenshare, helpDescription: 'keyboardShortcuts.toggleScreensharing' }, - this.props._shouldShowButton('participants-pane') && { + isToolbarButtonEnabled('participants-pane', _toolbarButtons) && { character: 'P', exec: this._onShortcutToggleParticipantsPane, helpDescription: 'keyboardShortcuts.toggleParticipantsPane' }, - this.props._shouldShowButton('raisehand') && { + isToolbarButtonEnabled('raisehand', _toolbarButtons) && { character: 'R', exec: this._onShortcutToggleRaiseHand, helpDescription: 'keyboardShortcuts.raiseHand' }, - this.props._shouldShowButton('fullscreen') && { + isToolbarButtonEnabled('fullscreen', _toolbarButtons) && { character: 'S', exec: this._onShortcutToggleFullScreen, helpDescription: 'keyboardShortcuts.fullScreen' }, - this.props._shouldShowButton('tileview') && { + isToolbarButtonEnabled('tileview', _toolbarButtons) && { character: 'W', exec: this._onShortcutToggleTileView, helpDescription: 'toolbar.tileViewToggle' @@ -509,7 +510,7 @@ class Toolbox extends Component { const { _feedbackConfigured, _isMobile, - _screensharing + _screenSharing } = this.props; const microphone = { @@ -644,7 +645,7 @@ class Toolbox extends Component { group: 3 }; - const virtualBackground = !_screensharing && checkBlurSupport() && { + const virtualBackground = !_screenSharing && checkBlurSupport() && { key: 'select-background', Content: VideoBackgroundButton, group: 3 @@ -734,12 +735,12 @@ class Toolbox extends Component { _getVisibleButtons() { const { _clientWidth, - _shouldShowButton + _toolbarButtons } = this.props; const buttons = this._getAllButtons(); - const isHangupVisible = _shouldShowButton('hangup'); + const isHangupVisible = isToolbarButtonEnabled('hangup', _toolbarButtons); const { order } = THRESHOLDS.find(({ width }) => _clientWidth > width) || THRESHOLDS[THRESHOLDS.length - 1]; let sliceIndex = order.length + 2; @@ -749,7 +750,7 @@ class Toolbox extends Component { const filtered = [ ...order.map(key => buttons[key]), ...Object.values(buttons).filter((button, index) => !order.includes(keys[index])) - ].filter(Boolean).filter(({ key }) => _shouldShowButton(key)); + ].filter(Boolean).filter(({ key }) => isToolbarButtonEnabled(key, _toolbarButtons)); if (isHangupVisible) { sliceIndex -= 1; @@ -934,7 +935,7 @@ class Toolbox extends Component { 'toggle.screen.sharing', ACTION_SHORTCUT_TRIGGERED, { - enable: !this.props._screensharing + enable: !this.props._screenSharing })); this._doToggleScreenshare(); @@ -1053,7 +1054,7 @@ class Toolbox extends Component { sendAnalytics(createToolbarEvent( 'toggle.screen.sharing', ACTION_SHORTCUT_TRIGGERED, - { enable: !this.props._screensharing })); + { enable: !this.props._screenSharing })); this._closeOverflowMenuIfOpen(); this._doToggleScreenshare(); @@ -1116,6 +1117,7 @@ class Toolbox extends Component { const { _isMobile, _overflowMenuVisible, + _toolbarButtons, t } = this.props; @@ -1169,7 +1171,7 @@ class Toolbox extends Component { + visible = { isToolbarButtonEnabled('hangup', _toolbarButtons) } /> @@ -1203,12 +1205,12 @@ function _mapStateToProps(state) { let desktopSharingDisabledTooltipKey; if (enableFeaturesBasedOnToken) { - // we enable desktop sharing if any participant already have this - // feature enabled - desktopSharingEnabled = getParticipants(state) - .find(({ features = {} }) => - String(features['screen-sharing']) === 'true') !== undefined; - desktopSharingDisabledTooltipKey = 'dialog.shareYourScreenDisabled'; + if (desktopSharingEnabled) { + // we enable desktop sharing if any participant already have this + // feature enabled and if the user supports it. + desktopSharingEnabled = haveParticipantWithScreenSharingFeature(state); + desktopSharingDisabledTooltipKey = 'dialog.shareYourScreenDisabled'; + } } return { @@ -1226,13 +1228,13 @@ function _mapStateToProps(state) { _isVpaasMeeting: isVpaasMeeting(state), _fullScreen: fullScreen, _tileViewEnabled: shouldDisplayTileView(state), - _localParticipantID: localParticipant.id, + _localParticipantID: localParticipant?.id, _localVideo: localVideo, _overflowMenuVisible: overflowMenuVisible, _participantsPaneOpen: getParticipantsPaneOpen(state), - _raisedHand: localParticipant.raisedHand, - _screensharing: isScreenVideoShared(state), - _shouldShowButton: buttonName => isToolbarButtonEnabled(buttonName)(state), + _raisedHand: localParticipant?.raisedHand, + _screenSharing: isScreenVideoShared(state), + _toolbarButtons: getToolbarButtons(state), _visible: isToolboxVisible(state), _visibleButtons: getToolbarButtons(state) }; diff --git a/react/features/toolbox/functions.native.js b/react/features/toolbox/functions.native.js index 83a1bac7d..e845529dc 100644 --- a/react/features/toolbox/functions.native.js +++ b/react/features/toolbox/functions.native.js @@ -2,6 +2,7 @@ import { hasAvailableDevices } from '../base/devices'; import { TOOLBOX_ALWAYS_VISIBLE, getFeatureFlag, TOOLBOX_ENABLED } from '../base/flags'; +import { getParticipantCountWithFake } from '../base/participants'; import { toState } from '../base/redux'; import { isLocalVideoTrackDesktop } from '../base/tracks'; @@ -60,7 +61,7 @@ export function getMovableButtons(width: number): Set { export function isToolboxVisible(stateful: Object | Function) { const state = toState(stateful); const { alwaysVisible, enabled, visible } = state['features/toolbox']; - const { length: participantCount } = state['features/base/participants']; + const participantCount = getParticipantCountWithFake(state); const alwaysVisibleFlag = getFeatureFlag(state, TOOLBOX_ALWAYS_VISIBLE, false); const enabledFlag = getFeatureFlag(state, TOOLBOX_ENABLED, true); diff --git a/react/features/video-layout/functions.js b/react/features/video-layout/functions.js index 2568325bf..995d26f50 100644 --- a/react/features/video-layout/functions.js +++ b/react/features/video-layout/functions.js @@ -5,7 +5,8 @@ import { getFeatureFlag, TILE_VIEW_ENABLED } from '../base/flags'; import { getPinnedParticipant, getParticipantCount, - pinParticipant + pinParticipant, + getParticipantCountWithFake } from '../base/participants'; import { ASPECT_RATIO_BREAKPOINT, @@ -101,7 +102,7 @@ export function getTileViewGridDimensions(state: Object) { // When in tile view mode, we must discount ourselves (the local participant) because our // tile is not visible. const { iAmRecorder } = state['features/base/config']; - const numberOfParticipants = state['features/base/participants'].length - (iAmRecorder ? 1 : 0); + const numberOfParticipants = getParticipantCountWithFake(state) - (iAmRecorder ? 1 : 0); const columnsToMaintainASquare = Math.ceil(Math.sqrt(numberOfParticipants)); const columns = Math.min(columnsToMaintainASquare, maxColumns); diff --git a/react/features/video-menu/actions.any.js b/react/features/video-menu/actions.any.js index f7fdd7152..74ea078ce 100644 --- a/react/features/video-menu/actions.any.js +++ b/react/features/video-menu/actions.any.js @@ -20,6 +20,7 @@ import { } from '../base/media'; import { getLocalParticipant, + getRemoteParticipants, muteRemoteParticipant } from '../base/participants'; @@ -91,14 +92,17 @@ export function muteAllParticipants(exclude: Array, mediaType: MEDIA_TYP return (dispatch: Dispatch, getState: Function) => { const state = getState(); const localId = getLocalParticipant(state).id; - const participantIds = state['features/base/participants'] - .map(p => p.id); - /* eslint-disable no-confusing-arrow */ - participantIds - .filter(id => !exclude.includes(id)) - .map(id => id === localId ? muteLocal(true, mediaType) : muteRemote(id, mediaType)) - .map(dispatch); - /* eslint-enable no-confusing-arrow */ + if (!exclude.includes(localId)) { + dispatch(muteLocal(true, mediaType)); + } + + getRemoteParticipants(state).forEach((p, id) => { + if (exclude.includes(id)) { + return; + } + + dispatch(muteRemote(id, mediaType)); + }); }; }