feat: Participants optimisations (#9515)

* fix(participants): Change from array to Map

* fix(unload): optimise

* feat: Introduces new states for e2ee feature.

Stores everyoneSupportsE2EE and everyoneEnabledE2EE to minimize looping through participants list.

squash: Uses participants map and go over the elements only once.

* feat: Optimizes isEveryoneModerator to do less frequent checks in all participants.

* fix: Drops deep equal from participants pane and uses the map.

* fix(SharedVideo): isVideoPlaying

* fix(participants): Optimise isEveryoneModerator

* fix(e2e): Optimise everyoneEnabledE2EE

* fix: JS errors.

* ref(participants): remove getParticipants

* fix(participants): Prepare for PR.

* fix: Changes participants pane to be component.

The functional component was always rendered:
`prev props: {} !== {} :next props`.

* feat: Optimization to skip participants list on pane closed.

* fix: The participants list shows and the local participant.

* fix: Fix wrong action name for av-moderation.

* fix: Minimizes the number of render calls of av moderation notification.

* fix: Fix iterating over remote participants.

* fix: Fixes lint error.

* fix: Reflects participant updates for av-moderation.

* fix(ParticipantPane): to work with IDs.

* fix(av-moderation): on PARTCIPANT_UPDATE

* fix(ParticipantPane): close delay.

* fix: address code review comments

* fix(API): mute-everyone

* fix: bugs

* fix(Thumbnail): on mobile.

* fix(ParticipantPane): Close context menu on click.

* fix: Handles few error when local participant is undefined.

* feat: Hides AV moderation if not supported.

* fix: Show mute all video.

* fix: Fixes updating participant for av moderation.

Co-authored-by: damencho <damencho@jitsi.org>
This commit is contained in:
Hristo Terezov 2021-07-09 07:36:19 -05:00 committed by GitHub
parent d87a40e77e
commit 0bdc7d42c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1911 additions and 818 deletions

View File

@ -23,7 +23,8 @@ import {
getParticipantById, getParticipantById,
pinParticipant, pinParticipant,
kickParticipant, kickParticipant,
raiseHand raiseHand,
isParticipantModerator
} from '../../react/features/base/participants'; } from '../../react/features/base/participants';
import { updateSettings } from '../../react/features/base/settings'; import { updateSettings } from '../../react/features/base/settings';
import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks'; import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks';
@ -105,13 +106,14 @@ function initCommands() {
const muteMediaType = mediaType ? mediaType : MEDIA_TYPE.AUDIO; const muteMediaType = mediaType ? mediaType : MEDIA_TYPE.AUDIO;
sendAnalytics(createApiEvent('muted-everyone')); sendAnalytics(createApiEvent('muted-everyone'));
const participants = APP.store.getState()['features/base/participants']; const localParticipant = getLocalParticipant(APP.store.getState());
const localIds = participants const exclude = [];
.filter(participant => participant.local)
.filter(participant => participant.role === 'moderator')
.map(participant => participant.id);
APP.store.dispatch(muteAllParticipants(localIds, muteMediaType)); if (localParticipant && isParticipantModerator(localParticipant)) {
exclude.push(localParticipant.id);
}
APP.store.dispatch(muteAllParticipants(exclude, muteMediaType));
}, },
'toggle-lobby': isLobbyEnabled => { 'toggle-lobby': isLobbyEnabled => {
APP.store.dispatch(toggleLobbyMode(isLobbyEnabled)); APP.store.dispatch(toggleLobbyMode(isLobbyEnabled));

View File

@ -49,24 +49,24 @@ export const disableModeration = (mediaType: MediaType, actor: Object) => {
/** /**
* Hides the notification with the participant that asked to unmute audio. * 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} * @returns {Object}
*/ */
export function dismissPendingAudioParticipant(id: string) { export function dismissPendingAudioParticipant(participant: Object) {
return dismissPendingParticipant(id, MEDIA_TYPE.AUDIO); return dismissPendingParticipant(participant, MEDIA_TYPE.AUDIO);
} }
/** /**
* Hides the notification with the participant that asked to unmute. * 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. * @param {MediaType} mediaType - The media type.
* @returns {Object} * @returns {Object}
*/ */
export function dismissPendingParticipant(id: string, mediaType: MediaType) { export function dismissPendingParticipant(participant: Object, mediaType: MediaType) {
return { return {
type: DISMISS_PENDING_PARTICIPANT, type: DISMISS_PENDING_PARTICIPANT,
id, participant,
mediaType mediaType
}; };
} }
@ -145,13 +145,13 @@ export function showModeratedNotification(mediaType: MediaType) {
/** /**
* Shows a notification with the participant that asked to audio unmute. * 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} * @returns {Object}
*/ */
export function participantPendingAudio(id: string) { export function participantPendingAudio(participant: Object) {
return { return {
type: PARTICIPANT_PENDING_AUDIO, type: PARTICIPANT_PENDING_AUDIO,
id participant
}; };
} }

View File

@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import NotificationWithParticipants from '../../notifications/components/web/NotificationWithParticipants'; import NotificationWithParticipants from '../../notifications/components/web/NotificationWithParticipants';
import { approveAudio, dismissPendingAudioParticipant } from '../actions'; import {
approveParticipant,
dismissPendingAudioParticipant
} from '../actions';
import { getParticipantsAskingToAudioUnmute } from '../functions'; import { getParticipantsAskingToAudioUnmute } from '../functions';
@ -25,7 +28,7 @@ export default function() {
</div> </div>
<NotificationWithParticipants <NotificationWithParticipants
approveButtonText = { t('notify.unmute') } approveButtonText = { t('notify.unmute') }
onApprove = { approveAudio } onApprove = { approveParticipant }
onReject = { dismissPendingAudioParticipant } onReject = { dismissPendingAudioParticipant }
participants = { participants } participants = { participants }
rejectButtonText = { t('dialog.dismiss') } rejectButtonText = { t('dialog.dismiss') }

View File

@ -1,7 +1,7 @@
// @flow // @flow
import { MEDIA_TYPE, type MediaType } from '../base/media/constants'; import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import { getParticipantById, isLocalParticipantModerator } from '../base/participants/functions'; import { isLocalParticipantModerator } from '../base/participants/functions';
import { MEDIA_TYPE_TO_WHITELIST_STORE_KEY, MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants'; import { MEDIA_TYPE_TO_WHITELIST_STORE_KEY, MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants';
@ -13,6 +13,14 @@ import { MEDIA_TYPE_TO_WHITELIST_STORE_KEY, MEDIA_TYPE_TO_PENDING_STORE_KEY } fr
*/ */
const getState = state => state['features/av-moderation']; const getState = state => 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. * 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); 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. * 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. * 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. * @param {MEDIA_TYPE} mediaType - The media type to check.
* @returns {boolean} * @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 storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
const arr = getState(state)[storeKey]; 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) => { export const getParticipantsAskingToAudioUnmute = (state: Object) => {
if (isLocalParticipantModerator(state)) { if (isLocalParticipantModerator(state)) {
const ids = getState(state).pendingAudio; return getState(state).pendingAudio;
return ids.map(id => getParticipantById(state, id)).filter(Boolean);
} }
return []; return EMPTY_ARRAY;
}; };
/** /**

View File

@ -127,14 +127,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
// this is handled only by moderators // this is handled only by moderators
if (audioModerationEnabled && isLocalParticipantModerator(state)) { if (audioModerationEnabled && isLocalParticipantModerator(state)) {
const { participant: { id, raisedHand } } = action; const participant = action.participant;
if (raisedHand) { if (participant.raisedHand) {
// if participant raises hand show notification // 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 { } else {
// if participant lowers hand hide notification // if participant lowers hand hide notification
isParticipantPending(id, MEDIA_TYPE.AUDIO)(state) && dispatch(dismissPendingAudioParticipant(id)); isParticipantPending(participant, MEDIA_TYPE.AUDIO)(state)
&& dispatch(dismissPendingAudioParticipant(participant));
} }
} }

View File

@ -1,6 +1,11 @@
/* @flow */ /* @flow */
import { MEDIA_TYPE } from '../base/media/constants'; 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 { ReducerRegistry } from '../base/redux';
import { import {
@ -11,6 +16,7 @@ import {
PARTICIPANT_APPROVED, PARTICIPANT_APPROVED,
PARTICIPANT_PENDING_AUDIO PARTICIPANT_PENDING_AUDIO
} from './actionTypes'; } from './actionTypes';
import { MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants';
const initialState = { const initialState = {
audioModerationEnabled: false, audioModerationEnabled: false,
@ -21,6 +27,41 @@ const initialState = {
pendingVideo: [] 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) => { ReducerRegistry.register('features/av-moderation', (state = initialState, action) => {
switch (action.type) { switch (action.type) {
@ -65,13 +106,13 @@ ReducerRegistry.register('features/av-moderation', (state = initialState, action
} }
case PARTICIPANT_PENDING_AUDIO: { case PARTICIPANT_PENDING_AUDIO: {
const { id } = action; const { participant } = action;
// Add participant to pendigAudio array only if it's not already added // Add participant to pendingAudio array only if it's not already added
if (!state.pendingAudio.find(pending => pending === id)) { if (!state.pendingAudio.find(pending => pending.id === participant.id)) {
const updated = [ ...state.pendingAudio ]; const updated = [ ...state.pendingAudio ];
updated.push(id); updated.push(participant);
return { return {
...state, ...state,
@ -82,20 +123,79 @@ ReducerRegistry.register('features/av-moderation', (state = initialState, action
return state; 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: { case DISMISS_PENDING_PARTICIPANT: {
const { id, mediaType } = action; const { participant, mediaType } = action;
if (mediaType === MEDIA_TYPE.AUDIO) { if (mediaType === MEDIA_TYPE.AUDIO) {
return { return {
...state, ...state,
pendingAudio: state.pendingAudio.filter(pending => pending !== id) pendingAudio: state.pendingAudio.filter(pending => pending.id !== participant.id)
}; };
} }
if (mediaType === MEDIA_TYPE.VIDEO) { if (mediaType === MEDIA_TYPE.VIDEO) {
return { return {
...state, ...state,
pendingAudio: state.pendingVideo.filter(pending => pending !== id) pendingVideo: state.pendingVideo.filter(pending => pending.id !== participant.id)
}; };
} }

View File

@ -398,10 +398,9 @@ function _pinParticipant({ getState }, next, action) {
return next(action); return next(action);
} }
const participants = state['features/base/participants'];
const id = action.participant.id; const id = action.participant.id;
const participantById = getParticipantById(participants, id); const participantById = getParticipantById(state, id);
const pinnedParticipant = getPinnedParticipant(participants); const pinnedParticipant = getPinnedParticipant(state);
const actionName = id ? ACTION_PINNED : ACTION_UNPINNED; const actionName = id ? ACTION_PINNED : ACTION_UNPINNED;
const local const local
= (participantById && participantById.local) = (participantById && participantById.local)

View File

@ -56,12 +56,15 @@ export function getToolbarButtons(state: Object): Array<string> {
} }
/** /**
* 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. * @param {string} buttonName - The name of the button.
* {@link interfaceConfig}. * {@link interfaceConfig}.
* @returns {Function} - Selector that returns a boolean. * @param {Object|Array<string>} 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) => export function isToolbarButtonEnabled(buttonName: string, state: Object | Array<string>) {
(state: Object): boolean => const buttons = Array.isArray(state) ? state : getToolbarButtons(state);
getToolbarButtons(state).includes(buttonName);
return buttons.includes(buttonName);
}

View File

@ -79,16 +79,15 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store<any,
/** /**
* Returns local participant from Redux state. * Returns local participant from Redux state.
* *
* @param {(Function|Object|Participant[])} stateful - The redux state * @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state * {@code getState} function to be used to retrieve the state
* features/base/participants. * features/base/participants.
* @returns {(Participant|undefined)} * @returns {(Participant|undefined)}
*/ */
export function getLocalParticipant(stateful: Object | Function) { export function getLocalParticipant(stateful: Object | Function) {
const participants = _getAllParticipants(stateful); const state = toState(stateful)['features/base/participants'];
return participants.find(p => p.local); return state.local;
} }
/** /**
@ -109,8 +108,7 @@ export function getNormalizedDisplayName(name: string) {
/** /**
* Returns participant by ID from Redux state. * Returns participant by ID from Redux state.
* *
* @param {(Function|Object|Participant[])} stateful - The redux state * @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state * {@code getState} function to be used to retrieve the state
* features/base/participants. * features/base/participants.
* @param {string} id - The ID of the participant to retrieve. * @param {string} id - The ID of the participant to retrieve.
@ -119,37 +117,82 @@ export function getNormalizedDisplayName(name: string) {
*/ */
export function getParticipantById( export function getParticipantById(
stateful: Object | Function, id: string): ?Object { 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, * Returns a count of the known participants in the passed in redux state,
* excluding any fake participants. * excluding any fake participants.
* *
* @param {(Function|Object|Participant[])} stateful - The redux state * @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state * {@code getState} function to be used to retrieve the state
* features/base/participants. * features/base/participants.
* @returns {number} * @returns {number}
*/ */
export function getParticipantCount(stateful: Object | Function) { 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<string, Participant>} - 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, * Returns a count of the known participants in the passed in redux state,
* including fake participants. * including fake participants.
* *
* @param {(Function|Object|Participant[])} stateful - The redux state * @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state * {@code getState} function to be used to retrieve the state
* features/base/participants. * features/base/participants.
* @returns {number} * @returns {number}
*/ */
export function getParticipantCountWithFake(stateful: Object | Function) { 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'; : '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. * 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 * Returns true if there is at least 1 participant with screen sharing feature and false otherwise.
* out.
* *
* @param {(Function|Object|Participant[])} stateful - The redux state * @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* features/base/participants, 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 * {@code getState} function to be used to retrieve the state
* features/base/participants. * features/base/participants.
* @returns {Participant[]} * @returns {Map<string, Object>}
*/ */
export function getParticipants(stateful: Object | Function) { export function getRemoteParticipants(stateful: Object | Function) {
return _getAllParticipants(stateful).filter(p => !p.isFakeParticipant); return toState(stateful)['features/base/participants'].remote;
} }
/** /**
* Returns the participant which has its pinned state set to truthy. * Returns the participant which has its pinned state set to truthy.
* *
* @param {(Function|Object|Participant[])} stateful - The redux state * @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state * {@code getState} function to be used to retrieve the state
* features/base/participants. * features/base/participants.
* @returns {(Participant|undefined)} * @returns {(Participant|undefined)}
*/ */
export function getPinnedParticipant(stateful: Object | Function) { export function getPinnedParticipant(stateful: Object | Function) {
return _getAllParticipants(stateful).find(p => p.pinned); const state = toState(stateful)['features/base/participants'];
} const { pinnedParticipant } = state;
/** if (!pinnedParticipant) {
* Returns array of participants from Redux state. return undefined;
* }
* @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'] || []);
}
/** return getParticipantById(stateful, pinnedParticipant);
* 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];
} }
/** /**
@ -289,6 +302,24 @@ export function isParticipantModerator(participant: Object) {
return participant?.role === PARTICIPANT_ROLE.MODERATOR; 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. * Returns true if all of the meeting participants are moderators.
* *
@ -297,9 +328,9 @@ export function isParticipantModerator(participant: Object) {
* @returns {boolean} * @returns {boolean}
*/ */
export function isEveryoneModerator(stateful: Object | Function) { 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} * @returns {boolean}
*/ */
export function isLocalParticipantModerator(stateful: Object | Function) { export function isLocalParticipantModerator(stateful: Object | Function) {
const state = toState(stateful); const state = toState(stateful)['features/base/participants'];
const localParticipant = getLocalParticipant(state);
if (!localParticipant) { const { local } = state;
if (!local) {
return false; 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++) { for (let i = 0; i < AVATAR_CHECKER_FUNCTIONS.length; i++) {
const url = AVATAR_CHECKER_FUNCTIONS[i](participant, store); const url = AVATAR_CHECKER_FUNCTIONS[i](participant, store);
if (url) { if (url !== null) {
if (AVATAR_CHECKED_URLS.has(url)) { if (AVATAR_CHECKED_URLS.has(url)) {
if (AVATAR_CHECKED_URLS.get(url)) { if (AVATAR_CHECKED_URLS.get(url)) {
return url; return url;

View File

@ -1,5 +1,7 @@
// @flow // @flow
import { batch } from 'react-redux';
import UIEvents from '../../../../service/UI/UIEvents'; import UIEvents from '../../../../service/UI/UIEvents';
import { toggleE2EE } from '../../e2ee/actions'; import { toggleE2EE } from '../../e2ee/actions';
import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications'; import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications';
@ -43,7 +45,8 @@ import {
getLocalParticipant, getLocalParticipant,
getParticipantById, getParticipantById,
getParticipantCount, getParticipantCount,
getParticipantDisplayName getParticipantDisplayName,
getRemoteParticipants
} from './functions'; } from './functions';
import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds'; import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds';
@ -182,11 +185,12 @@ MiddlewareRegistry.register(store => next => action => {
StateListenerRegistry.register( StateListenerRegistry.register(
/* selector */ state => getCurrentConference(state), /* selector */ state => getCurrentConference(state),
/* listener */ (conference, { dispatch, getState }) => { /* listener */ (conference, { dispatch, getState }) => {
for (const p of getState()['features/base/participants']) { batch(() => {
!p.local for (const [ id, p ] of getRemoteParticipants(getState())) {
&& (!conference || p.conference !== conference) (!conference || p.conference !== conference)
&& dispatch(participantLeft(p.id, p.conference, p.isReplaced)); && dispatch(participantLeft(id, p.conference, p.isReplaced));
} }
});
}); });
/** /**

View File

@ -12,6 +12,7 @@ import {
SET_LOADABLE_AVATAR_URL SET_LOADABLE_AVATAR_URL
} from './actionTypes'; } from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants'; import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
import { isParticipantModerator } from './functions';
/** /**
* Participant object. * Participant object.
@ -51,6 +52,16 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
'pinned' '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 * Listen for actions which add, remove, or update the set of participants in
* the conference. * the conference.
@ -62,18 +73,157 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
* added/removed/modified. * added/removed/modified.
* @returns {Participant[]} * @returns {Participant[]}
*/ */
ReducerRegistry.register('features/base/participants', (state = [], action) => { ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, action) => {
switch (action.type) { 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 SET_LOADABLE_AVATAR_URL:
case DOMINANT_SPEAKER_CHANGED: case PARTICIPANT_UPDATED: {
case PARTICIPANT_ID_CHANGED: const { participant } = action;
case PARTICIPANT_UPDATED: let { id } = participant;
case PIN_PARTICIPANT: const { local } = participant;
return state.map(p => _participant(p, action));
case PARTICIPANT_JOINED: if (!id && local) {
return [ ...state, _participantJoined(action) ]; 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: { case PARTICIPANT_LEFT: {
// XXX A remote participant is uniquely identified by their id in a // XXX A remote participant is uniquely identified by their id in a
// specific JitsiConference instance. The local participant is uniquely // 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 // (and the fact that the local participant "joins" at the beginning of
// the app and "leaves" at the end of the app). // the app and "leaves" at the end of the app).
const { conference, id } = action.participant; const { conference, id } = action.participant;
const { fakeParticipants, remote, local, dominantSpeaker, pinnedParticipant } = state;
let oldParticipant = remote.get(id);
return state.filter(p => if (oldParticipant && oldParticipant.conference === conference) {
!( remote.delete(id);
p.id === 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 if (!state.everyoneIsModerator && !isParticipantModerator(oldParticipant)) {
// participant and a remote participant cause the removal of state.everyoneIsModerator = _isEveryoneModerator(state);
// the local participant when the remote participant's }
// removal is requested.
&& p.conference === conference const { features = {} } = oldParticipant || {};
&& (conference || p.local)));
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; 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. * Reducer function for a single participant.
* *
@ -112,56 +350,22 @@ ReducerRegistry.register('features/base/participants', (state = [], action) => {
*/ */
function _participant(state: Object = {}, action) { function _participant(state: Object = {}, action) {
switch (action.type) { 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 SET_LOADABLE_AVATAR_URL:
case PARTICIPANT_UPDATED: { case PARTICIPANT_UPDATED: {
const { participant } = action; // eslint-disable-line no-shadow const { participant } = action; // eslint-disable-line no-shadow
let { id } = participant;
const { local } = participant;
if (!id && local) { const newState = { ...state };
id = LOCAL_PARTICIPANT_DEFAULT_ID;
}
if (state.id === id) { for (const key in participant) {
const newState = { ...state }; if (participant.hasOwnProperty(key)
&& PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE.indexOf(key)
for (const key in participant) { === -1) {
if (participant.hasOwnProperty(key) newState[key] = participant[key];
&& PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE.indexOf(key)
=== -1) {
newState[key] = participant[key];
}
} }
return newState;
} }
break;
}
case PIN_PARTICIPANT: return newState;
// Currently, only one pinned participant is allowed. }
return set(state, 'pinned', state.id === action.participant.id);
} }
return state; return state;

View File

@ -21,68 +21,50 @@ import logger from './logger';
export const getTrackState = state => state['features/base/tracks']; export const getTrackState = state => state['features/base/tracks'];
/** /**
* Higher-order function that returns a selector for a specific participant * Checks if the passed media type is muted for the participant.
* and media type.
* *
* @param {Object} participant - Participant reference. * @param {Object} participant - Participant reference.
* @param {MEDIA_TYPE} mediaType - Media type. * @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;
}
/** const tracks = getTrackState(state);
* 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); if (participant?.local) {
return isLocalTrackMuted(tracks, mediaType);
} else if (!participant?.isFakeParticipant) {
return isRemoteTrackMuted(tracks, mediaType, participant.id);
}
if (participant?.local) { return true;
return isLocalTrackMuted(tracks, mediaType); }
} else if (!participant?.isFakeParticipant) {
return isRemoteTrackMuted(tracks, mediaType, participant.id);
}
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. * @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 => export function isParticipantAudioMuted(participant, state) {
return isParticipantMediaMuted(participant, MEDIA_TYPE.AUDIO, state);
/** }
* Bound selector.
*
* @param {Object} state - Global state.
* @returns {boolean} Is audio muted for the participant.
*/
state => getIsParticipantMediaMuted(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. * @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 => export function isParticipantVideoMuted(participant, state) {
return isParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO, state);
/** }
* Bound selector.
*
* @param {Object} state - Global state.
* @returns {boolean} Is video muted for the participant.
*/
state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO)(state);
/** /**
* Creates a local video track for presenter. The constraints are computed based * Creates a local video track for presenter. The constraints are computed based

View File

@ -108,6 +108,6 @@ export function _mapStateToProps(state: Object) {
_isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD, _isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
_isOpen: isOpen, _isOpen: isOpen,
_messages: messages, _messages: messages,
_showNamePrompt: !_localParticipant.name _showNamePrompt: !_localParticipant?.name
}; };
} }

View File

@ -288,8 +288,7 @@ function _mapStateToProps(state, ownProps) {
return { return {
_configuredDisplayName: participant && participant.name, _configuredDisplayName: participant && participant.name,
_nameToDisplay: getParticipantDisplayName( _nameToDisplay: getParticipantDisplayName(state, participantID)
state, participantID)
}; };
} }

View File

@ -6,3 +6,22 @@
* } * }
*/ */
export const TOGGLE_E2EE = 'TOGGLE_E2EE'; 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';

View File

@ -1,6 +1,6 @@
// @flow // @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. * Dispatches an action to enable / disable E2EE.
@ -14,3 +14,35 @@ export function toggleE2EE(enabled: boolean) {
enabled 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
};
}

View File

@ -22,9 +22,7 @@ export type Props = {
* @returns {Props} * @returns {Props}
*/ */
export function _mapStateToProps(state: Object) { export function _mapStateToProps(state: Object) {
const participants = state['features/base/participants'];
return { return {
_showLabel: participants.every(p => p.e2eeEnabled) _showLabel: state['features/e2ee'].everyoneEnabledE2EE
}; };
} }

View File

@ -5,7 +5,6 @@ import type { Dispatch } from 'redux';
import { createE2EEEvent, sendAnalytics } from '../../analytics'; import { createE2EEEvent, sendAnalytics } from '../../analytics';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { getParticipants } from '../../base/participants';
import { Switch } from '../../base/react'; import { Switch } from '../../base/react';
import { connect } from '../../base/redux'; import { connect } from '../../base/redux';
import { toggleE2EE } from '../actions'; import { toggleE2EE } from '../actions';
@ -21,7 +20,7 @@ type Props = {
/** /**
* Indicates whether all participants in the conference currently support E2EE. * Indicates whether all participants in the conference currently support E2EE.
*/ */
_everyoneSupportsE2EE: boolean, _everyoneSupportE2EE: boolean,
/** /**
* The redux {@code dispatch} function. * The redux {@code dispatch} function.
@ -96,7 +95,7 @@ class E2EESection extends Component<Props, State> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { _everyoneSupportsE2EE, t } = this.props; const { _everyoneSupportE2EE, t } = this.props;
const { enabled, expand } = this.state; const { enabled, expand } = this.state;
const description = t('dialog.e2eeDescription'); const description = t('dialog.e2eeDescription');
@ -120,7 +119,7 @@ class E2EESection extends Component<Props, State> {
</span> } </span> }
</p> </p>
{ {
!_everyoneSupportsE2EE !_everyoneSupportE2EE
&& <span className = 'warning'> && <span className = 'warning'>
{ t('dialog.e2eeWarning') } { t('dialog.e2eeWarning') }
</span> </span>
@ -195,12 +194,11 @@ class E2EESection extends Component<Props, State> {
* @returns {Props} * @returns {Props}
*/ */
function mapStateToProps(state) { function mapStateToProps(state) {
const { enabled } = state['features/e2ee']; const { enabled, everyoneSupportE2EE } = state['features/e2ee'];
const participants = getParticipants(state).filter(p => !p.local);
return { return {
_enabled: enabled, _enabled: enabled,
_everyoneSupportsE2EE: participants.every(p => Boolean(p.e2eeSupported)) _everyoneSupportE2EE: everyoneSupportE2EE
}; };
} }

View File

@ -1,13 +1,24 @@
// @flow // @flow
import { batch } from 'react-redux';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app';
import { getCurrentConference } from '../base/conference'; 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 { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { TOGGLE_E2EE } from './actionTypes'; 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 { E2EE_OFF_SOUND_ID, E2EE_ON_SOUND_ID } from './constants';
import logger from './logger'; import logger from './logger';
import { E2EE_OFF_SOUND_FILE, E2EE_ON_SOUND_FILE } from './sounds'; 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)); dispatch(unregisterSound(E2EE_ON_SOUND_ID));
break; 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: { case TOGGLE_E2EE: {
const conference = getCurrentConference(getState); const conference = getCurrentConference(getState);

View File

@ -2,7 +2,11 @@
import { ReducerRegistry } from '../base/redux'; 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 = { const DEFAULT_STATE = {
enabled: false enabled: false
@ -18,6 +22,16 @@ ReducerRegistry.register('features/e2ee', (state = DEFAULT_STATE, action) => {
...state, ...state,
enabled: action.enabled enabled: action.enabled
}; };
case SET_EVERYONE_ENABLED_E2EE:
return {
...state,
everyoneEnabledE2EE: action.everyoneEnabledE2EE
};
case SET_EVERYONE_SUPPORT_E2EE:
return {
...state,
everyoneSupportE2EE: action.everyoneSupportE2EE
};
default: default:
return state; return state;

View File

@ -1,7 +1,7 @@
// @flow // @flow
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { pinParticipant } from '../base/participants'; import { getLocalParticipant, getRemoteParticipants, pinParticipant } from '../base/participants';
import { import {
SET_HORIZONTAL_VIEW_DIMENSIONS, SET_HORIZONTAL_VIEW_DIMENSIONS,
@ -127,7 +127,8 @@ export function setHorizontalViewDimensions() {
*/ */
export function clickOnVideo(n: number) { export function clickOnVideo(n: number) {
return (dispatch: Function, getState: Function) => { 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 nThParticipant = participants[n];
const { id, pinned } = nThParticipant; const { id, pinned } = nThParticipant;

View File

@ -109,10 +109,10 @@ class Filmstrip extends Component<Props> {
{ {
this._sort(_participants, isNarrowAspectRatio) this._sort(_participants, isNarrowAspectRatio)
.map(p => ( .map(id => (
<Thumbnail <Thumbnail
key = { p.id } key = { id }
participant = { p } />)) participantID = { id } />))
} }
{ {
@ -166,12 +166,11 @@ class Filmstrip extends Component<Props> {
* @returns {Props} * @returns {Props}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
const participants = state['features/base/participants']; const { enabled, remoteParticipants } = state['features/filmstrip'];
const { enabled } = state['features/filmstrip'];
return { return {
_aspectRatio: state['features/base/responsive-ui'].aspectRatio, _aspectRatio: state['features/base/responsive-ui'].aspectRatio,
_participants: participants.filter(p => !p.local), _participants: remoteParticipants,
_visible: enabled && isFilmstripVisible(state) _visible: enabled && isFilmstripVisible(state)
}; };
} }

View File

@ -1,63 +1,21 @@
// @flow // @flow
import React, { Component } from 'react'; import React from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import { getLocalParticipant } from '../../../base/participants';
import { connect } from '../../../base/redux';
import Thumbnail from './Thumbnail'; import Thumbnail from './Thumbnail';
import styles from './styles'; import styles from './styles';
type Props = {
/**
* The local participant.
*/
_localParticipant: Object
};
/** /**
* Component to render a local thumbnail that can be separated from the * Component to render a local thumbnail that can be separated from the
* remote thumbnails later. * remote thumbnails later.
*/
class LocalThumbnail extends Component<Props> {
/**
* Implements React Component's render.
*
* @inheritdoc
*/
render() {
const { _localParticipant } = this.props;
return (
<View style = { styles.localThumbnail }>
<Thumbnail participant = { _localParticipant } />
</View>
);
}
}
/**
* Maps (parts of) the redux state to the associated {@code LocalThumbnail}'s
* props.
* *
* @param {Object} state - The redux state. * @returns {ReactElement}
* @private
* @returns {{
* _localParticipant: Participant
* }}
*/ */
function _mapStateToProps(state) { export default function LocalThumbnail() {
return { return (
/** <View style = { styles.localThumbnail }>
* The local participant. <Thumbnail />
* </View>
* @private );
* @type {Participant}
*/
_localParticipant: getLocalParticipant(state)
};
} }
export default connect(_mapStateToProps)(LocalThumbnail);

View File

@ -1,6 +1,6 @@
// @flow // @flow
import React from 'react'; import React, { useCallback } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
@ -12,7 +12,8 @@ import {
ParticipantView, ParticipantView,
getParticipantCount, getParticipantCount,
isEveryoneModerator, isEveryoneModerator,
pinParticipant pinParticipant,
getParticipantByIdOrUndefined
} from '../../../base/participants'; } from '../../../base/participants';
import { Container } from '../../../base/react'; import { Container } from '../../../base/react';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
@ -48,14 +49,9 @@ type Props = {
_largeVideo: Object, _largeVideo: Object,
/** /**
* Handles click/tap event on the thumbnail. * The Redux representation of the participant to display.
*/ */
_onClick: ?Function, _participant: Object,
/**
* Handles long press on the thumbnail.
*/
_onThumbnailLongPress: ?Function,
/** /**
* Whether to show the dominant speaker indicator or not. * Whether to show the dominant speaker indicator or not.
@ -90,9 +86,9 @@ type Props = {
dispatch: Dispatch<any>, dispatch: Dispatch<any>,
/** /**
* 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. * Whether to display or hide the display name of the participant in the thumbnail.
@ -120,14 +116,13 @@ function Thumbnail(props: Props) {
const { const {
_audioMuted: audioMuted, _audioMuted: audioMuted,
_largeVideo: largeVideo, _largeVideo: largeVideo,
_onClick,
_onThumbnailLongPress,
_renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator,
_renderModeratorIndicator: renderModeratorIndicator, _renderModeratorIndicator: renderModeratorIndicator,
_participant: participant,
_styles, _styles,
_videoTrack: videoTrack, _videoTrack: videoTrack,
dispatch,
disableTint, disableTint,
participant,
renderDisplayName, renderDisplayName,
tileView tileView
} = props; } = props;
@ -137,11 +132,29 @@ function Thumbnail(props: Props) {
= participantId === largeVideo.participantId; = participantId === largeVideo.participantId;
const videoMuted = !videoTrack || videoTrack.muted; const videoMuted = !videoTrack || videoTrack.muted;
const isScreenShare = videoTrack && videoTrack.videoType === VIDEO_TYPE.DESKTOP; 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 ( return (
<Container <Container
onClick = { _onClick } onClick = { onClick }
onLongPress = { _onThumbnailLongPress } onLongPress = { onThumbnailLongPress }
style = { [ style = { [
styles.thumbnail, styles.thumbnail,
participant.pinned && !tileView participant.pinned && !tileView
@ -198,55 +211,6 @@ function Thumbnail(props: Props) {
); );
} }
/**
* Maps part of redux actions to component's props.
*
* @param {Function} dispatch - Redux's {@code dispatch} function.
* @param {Props} ownProps - The own props of the component.
* @returns {{
* _onClick: Function,
* _onShowRemoteVideoMenu: Function
* }}
*/
function _mapDispatchToProps(dispatch: Function, ownProps): Object {
return {
/**
* Handles click/tap event on the thumbnail.
*
* @protected
* @returns {void}
*/
_onClick() {
const { participant, tileView } = ownProps;
if (tileView) {
dispatch(toggleToolboxVisible());
} else {
dispatch(pinParticipant(participant.pinned ? null : participant.id));
}
},
/**
* Handles long press on the thumbnail.
*
* @returns {void}
*/
_onThumbnailLongPress() {
const { participant } = ownProps;
if (participant.local) {
dispatch(openDialog(ConnectionStatusComponent, {
participantID: participant.id
}));
} else {
dispatch(openDialog(RemoteVideoMenu, {
participant
}));
}
}
};
}
/** /**
* Function that maps parts of Redux state tree into component props. * Function that maps parts of Redux state tree into component props.
* *
@ -260,20 +224,23 @@ function _mapStateToProps(state, ownProps) {
// the stage i.e. as a large video. // the stage i.e. as a large video.
const largeVideo = state['features/large-video']; const largeVideo = state['features/large-video'];
const tracks = state['features/base/tracks']; const tracks = state['features/base/tracks'];
const { participant } = ownProps; const { participantID } = ownProps;
const id = participant.id; const participant = getParticipantByIdOrUndefined(state, participantID);
const id = participant?.id;
const audioTrack const audioTrack
= getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, id); = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, id);
const videoTrack const videoTrack
= getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, id); = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, id);
const participantCount = getParticipantCount(state); const participantCount = getParticipantCount(state);
const renderDominantSpeakerIndicator = participant.dominantSpeaker && participantCount > 2; const renderDominantSpeakerIndicator = participant && participant.dominantSpeaker && participantCount > 2;
const _isEveryoneModerator = isEveryoneModerator(state); const _isEveryoneModerator = isEveryoneModerator(state);
const renderModeratorIndicator = !_isEveryoneModerator && participant.role === PARTICIPANT_ROLE.MODERATOR; const renderModeratorIndicator = !_isEveryoneModerator
&& participant && participant.role === PARTICIPANT_ROLE.MODERATOR;
return { return {
_audioMuted: audioTrack?.muted ?? true, _audioMuted: audioTrack?.muted ?? true,
_largeVideo: largeVideo, _largeVideo: largeVideo,
_participant: participant,
_renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator,
_renderModeratorIndicator: renderModeratorIndicator, _renderModeratorIndicator: renderModeratorIndicator,
_styles: ColorSchemeRegistry.get(state, 'Thumbnail'), _styles: ColorSchemeRegistry.get(state, 'Thumbnail'),
@ -281,4 +248,4 @@ function _mapStateToProps(state, ownProps) {
}; };
} }
export default connect(_mapStateToProps, _mapDispatchToProps)(Thumbnail); export default connect(_mapStateToProps)(Thumbnail);

View File

@ -8,6 +8,7 @@ import {
} from 'react-native'; } from 'react-native';
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { getLocalParticipant, getParticipantCountWithFake } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants'; import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants';
import { setTileViewDimensions } from '../../actions.native'; import { setTileViewDimensions } from '../../actions.native';
@ -15,6 +16,7 @@ import { setTileViewDimensions } from '../../actions.native';
import Thumbnail from './Thumbnail'; import Thumbnail from './Thumbnail';
import styles from './styles'; import styles from './styles';
/** /**
* The type of the React {@link Component} props of {@link TileView}. * The type of the React {@link Component} props of {@link TileView}.
*/ */
@ -31,9 +33,19 @@ type Props = {
_height: number, _height: number,
/** /**
* The participants in the conference. * The local participant.
*/ */
_participants: Array<Object>, _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<string>,
/** /**
* Application's viewport height. * Application's viewport height.
@ -131,7 +143,7 @@ class TileView extends Component<Props> {
* @private * @private
*/ */
_getColumnCount() { _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 // 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 // call and a 1:1 call. Otherwise tiles should be grouped into rows of
@ -155,18 +167,10 @@ class TileView extends Component<Props> {
* @returns {Participant[]} * @returns {Participant[]}
*/ */
_getSortedParticipants() { _getSortedParticipants() {
const participants = []; const { _localParticipant, _remoteParticipants } = this.props;
let localParticipant; const participants = [ ..._remoteParticipants ];
for (const participant of this.props._participants) { _localParticipant && participants.push(_localParticipant.id);
if (participant.local) {
localParticipant = participant;
} else {
participants.push(participant);
}
}
localParticipant && participants.push(localParticipant);
return participants; return participants;
} }
@ -178,16 +182,15 @@ class TileView extends Component<Props> {
* @returns {Object} * @returns {Object}
*/ */
_getTileDimensions() { _getTileDimensions() {
const { _height, _participants, _width } = this.props; const { _height, _participantCount, _width } = this.props;
const columns = this._getColumnCount(); const columns = this._getColumnCount();
const participantCount = _participants.length;
const heightToUse = _height - (MARGIN * 2); const heightToUse = _height - (MARGIN * 2);
const widthToUse = _width - (MARGIN * 2); const widthToUse = _width - (MARGIN * 2);
let tileWidth; let tileWidth;
// If there is going to be at least two rows, ensure that at least two // If there is going to be at least two rows, ensure that at least two
// rows display fully on screen. // rows display fully on screen.
if (participantCount / columns > 1) { if (_participantCount / columns > 1) {
tileWidth = Math.min(widthToUse / columns, heightToUse / 2); tileWidth = Math.min(widthToUse / columns, heightToUse / 2);
} else { } else {
tileWidth = Math.min(widthToUse / columns, heightToUse); tileWidth = Math.min(widthToUse / columns, heightToUse);
@ -247,11 +250,11 @@ class TileView extends Component<Props> {
}; };
return this._getSortedParticipants() return this._getSortedParticipants()
.map(participant => ( .map(id => (
<Thumbnail <Thumbnail
disableTint = { true } disableTint = { true }
key = { participant.id } key = { id }
participant = { participant } participantID = { id }
renderDisplayName = { true } renderDisplayName = { true }
styleOverrides = { styleOverrides } styleOverrides = { styleOverrides }
tileView = { true } />)); tileView = { true } />));
@ -285,11 +288,14 @@ class TileView extends Component<Props> {
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
const responsiveUi = state['features/base/responsive-ui']; const responsiveUi = state['features/base/responsive-ui'];
const { remoteParticipants } = state['features/filmstrip'];
return { return {
_aspectRatio: responsiveUi.aspectRatio, _aspectRatio: responsiveUi.aspectRatio,
_height: responsiveUi.clientHeight, _height: responsiveUi.clientHeight,
_participants: state['features/base/participants'], _localParticipant: getLocalParticipant(state),
_participantCount: getParticipantCountWithFake(state),
_remoteParticipants: remoteParticipants,
_width: responsiveUi.clientWidth _width: responsiveUi.clientWidth
}; };
} }

View File

@ -3,7 +3,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { MEDIA_TYPE } from '../../../base/media'; 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 { connect } from '../../../base/redux';
import { getTrackByMediaTypeAndParticipant, isLocalTrackMuted, isRemoteTrackMuted } from '../../../base/tracks'; import { getTrackByMediaTypeAndParticipant, isLocalTrackMuted, isRemoteTrackMuted } from '../../../base/tracks';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
@ -111,7 +111,7 @@ function _mapStateToProps(state, ownProps) {
const { participantID } = ownProps; const { participantID } = ownProps;
// Only the local participant won't have id for the time when the conference is not yet joined. // 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']; const tracks = state['features/base/tracks'];
let isVideoMuted = true; let isVideoMuted = true;

View File

@ -9,8 +9,7 @@ import { Avatar } from '../../../base/avatar';
import JitsiMeetJS from '../../../base/lib-jitsi-meet/_'; import JitsiMeetJS from '../../../base/lib-jitsi-meet/_';
import { MEDIA_TYPE, VideoTrack } from '../../../base/media'; import { MEDIA_TYPE, VideoTrack } from '../../../base/media';
import { import {
getLocalParticipant, getParticipantByIdOrUndefined,
getParticipantById,
getParticipantCount, getParticipantCount,
pinParticipant pinParticipant
} from '../../../base/participants'; } from '../../../base/participants';
@ -1012,9 +1011,8 @@ class Thumbnail extends Component<Props, State> {
function _mapStateToProps(state, ownProps): Object { function _mapStateToProps(state, ownProps): Object {
const { participantID } = ownProps; const { participantID } = ownProps;
// Only the local participant won't have id for the time when the conference is not yet joined. const participant = getParticipantByIdOrUndefined(state, participantID);
const participant = participantID ? getParticipantById(state, participantID) : getLocalParticipant(state); const id = participant?.id;
const { id } = participant;
const isLocal = participant?.local ?? true; const isLocal = participant?.local ?? true;
const tracks = state['features/base/tracks']; const tracks = state['features/base/tracks'];
const { participantsVolume } = state['features/filmstrip']; const { participantsVolume } = state['features/filmstrip'];
@ -1085,14 +1083,14 @@ function _mapStateToProps(state, ownProps): Object {
_isDominantSpeakerDisabled: interfaceConfig.DISABLE_DOMINANT_SPEAKER_INDICATOR, _isDominantSpeakerDisabled: interfaceConfig.DISABLE_DOMINANT_SPEAKER_INDICATOR,
_isScreenSharing: _videoTrack?.videoType === 'desktop', _isScreenSharing: _videoTrack?.videoType === 'desktop',
_isTestModeEnabled: isTestModeEnabled(state), _isTestModeEnabled: isTestModeEnabled(state),
_isVideoPlayable: isVideoPlayable(state, id), _isVideoPlayable: id && isVideoPlayable(state, id),
_indicatorIconSize: NORMAL, _indicatorIconSize: NORMAL,
_localFlipX: Boolean(localFlipX), _localFlipX: Boolean(localFlipX),
_participant: participant, _participant: participant,
_participantCountMoreThan2: getParticipantCount(state) > 2, _participantCountMoreThan2: getParticipantCount(state) > 2,
_startSilent: Boolean(startSilent), _startSilent: Boolean(startSilent),
_videoTrack, _videoTrack,
_volume: isLocal ? undefined : participantsVolume[id], _volume: isLocal ? undefined : id ? participantsVolume[id] : undefined,
...size ...size
}; };
} }

View File

@ -1,6 +1,7 @@
// @flow // @flow
import { getFeatureFlag, FILMSTRIP_ENABLED } from '../base/flags'; import { getFeatureFlag, FILMSTRIP_ENABLED } from '../base/flags';
import { getParticipantCountWithFake } from '../base/participants';
import { toState } from '../base/redux'; import { toState } from '../base/redux';
/** /**
@ -22,7 +23,5 @@ export function isFilmstripVisible(stateful: Object | Function) {
return false; return false;
} }
const { length: participantCount } = state['features/base/participants']; return getParticipantCountWithFake(state) > 1;
return participantCount > 1;
} }

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { getParticipantCountWithFake } from '../base/participants';
import { StateListenerRegistry, equals } from '../base/redux'; import { StateListenerRegistry, equals } from '../base/redux';
import { clientResized } from '../base/responsive-ui'; import { clientResized } from '../base/responsive-ui';
import { setFilmstripVisible } from '../filmstrip/actions'; 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. * Listens for changes in the number of participants to calculate the dimensions of the tile view grid and the tiles.
*/ */
StateListenerRegistry.register( StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].length, /* selector */ getParticipantCountWithFake,
/* listener */ (numberOfParticipants, store) => { /* listener */ (numberOfParticipants, store) => {
const state = store.getState(); const state = store.getState();

View File

@ -3,7 +3,7 @@
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { getInviteURL } from '../base/connection'; import { getInviteURL } from '../base/connection';
import { getLocalParticipant, getParticipants } from '../base/participants'; import { getLocalParticipant, getParticipantCount } from '../base/participants';
import { inviteVideoRooms } from '../videosipgw'; import { inviteVideoRooms } from '../videosipgw';
import { import {
@ -71,14 +71,14 @@ export function invite(
dispatch: Dispatch<any>, dispatch: Dispatch<any>,
getState: Function): Promise<Array<Object>> => { getState: Function): Promise<Array<Object>> => {
const state = getState(); const state = getState();
const participants = getParticipants(state); const participantsCount = getParticipantCount(state);
const { calleeInfoVisible } = state['features/invite']; const { calleeInfoVisible } = state['features/invite'];
if (showCalleeInfo if (showCalleeInfo
&& !calleeInfoVisible && !calleeInfoVisible
&& invitees.length === 1 && invitees.length === 1
&& invitees[0].type === INVITE_TYPES.USER && invitees[0].type === INVITE_TYPES.USER
&& participants.length === 1) { && participantsCount === 1) {
dispatch(setCalleeInfoVisible(true, invitees[0])); dispatch(setCalleeInfoVisible(true, invitees[0]));
} }

View File

@ -5,9 +5,9 @@ import React, { Component } from 'react';
import { Avatar } from '../../../base/avatar'; import { Avatar } from '../../../base/avatar';
import { MEDIA_TYPE } from '../../../base/media'; import { MEDIA_TYPE } from '../../../base/media';
import { import {
getParticipants,
getParticipantDisplayName, getParticipantDisplayName,
getParticipantPresenceStatus getParticipantPresenceStatus,
getRemoteParticipants
} from '../../../base/participants'; } from '../../../base/participants';
import { Container, Text } from '../../../base/react'; import { Container, Text } from '../../../base/react';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
@ -135,20 +135,20 @@ class CalleeInfo extends Component<Props> {
function _mapStateToProps(state) { function _mapStateToProps(state) {
const _isVideoMuted const _isVideoMuted
= isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO); = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
const poltergeist
= getParticipants(state).find(p => p.botType === 'poltergeist');
if (poltergeist) { // This would be expensive for big calls but the component will be mounted only when there are up
const { id } = poltergeist; // to 3 participants in the call.
for (const [ id, p ] of getRemoteParticipants(state)) {
return { if (p.botType === 'poltergeist') {
_callee: { return {
id, _callee: {
name: getParticipantDisplayName(state, id), id,
status: getParticipantPresenceStatus(state, id) name: getParticipantDisplayName(state, id),
}, status: getParticipantPresenceStatus(state, id)
_isVideoMuted },
}; _isVideoMuted
};
}
} }
return { return {

View File

@ -6,8 +6,9 @@ import {
} from '../base/conference'; } from '../base/conference';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipantCount,
getParticipantPresenceStatus, getParticipantPresenceStatus,
getParticipants, getRemoteParticipants,
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_SOUND_ID,
PARTICIPANT_LEFT, PARTICIPANT_LEFT,
@ -167,13 +168,19 @@ function _maybeHideCalleeInfo(action, store) {
if (!state['features/invite'].calleeInfoVisible) { if (!state['features/invite'].calleeInfoVisible) {
return; return;
} }
const participants = getParticipants(state); const participants = getRemoteParticipants(state);
const numberOfPoltergeists const participantCount = getParticipantCount(state);
= participants.filter(p => p.botType === 'poltergeist').length; let numberOfPoltergeists = 0;
const numberOfRealParticipants = participants.length - numberOfPoltergeists;
participants.forEach(p => {
if (p.botType === 'poltergeist') {
numberOfPoltergeists++;
}
});
const numberOfRealParticipants = participantCount - numberOfPoltergeists;
if ((numberOfPoltergeists > 1 || numberOfRealParticipants > 1) if ((numberOfPoltergeists > 1 || numberOfRealParticipants > 1)
|| (action.type === PARTICIPANT_LEFT && participants.length === 1)) { || (action.type === PARTICIPANT_LEFT && participantCount === 1)) {
store.dispatch(setCalleeInfoVisible(false)); store.dispatch(setCalleeInfoVisible(false));
} }
} }

View File

@ -3,6 +3,12 @@
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { MEDIA_TYPE } from '../base/media'; import { MEDIA_TYPE } from '../base/media';
import {
getDominantSpeakerParticipant,
getLocalParticipant,
getPinnedParticipant,
getRemoteParticipants
} from '../base/participants';
import { import {
SELECT_LARGE_VIDEO_PARTICIPANT, SELECT_LARGE_VIDEO_PARTICIPANT,
@ -92,8 +98,7 @@ function _electLastVisibleRemoteVideo(tracks) {
function _electParticipantInLargeVideo(state) { function _electParticipantInLargeVideo(state) {
// 1. If a participant is pinned, they will be shown in the LargeVideo // 1. If a participant is pinned, they will be shown in the LargeVideo
// (regardless of whether they are local or remote). // (regardless of whether they are local or remote).
const participants = state['features/base/participants']; let participant = getPinnedParticipant(state);
let participant = participants.find(p => p.pinned);
if (participant) { if (participant) {
return participant.id; return participant.id;
@ -107,11 +112,14 @@ function _electParticipantInLargeVideo(state) {
} }
// 3. Next, pick the dominant speaker (other than self). // 3. Next, pick the dominant speaker (other than self).
participant = participants.find(p => p.dominantSpeaker && !p.local); participant = getDominantSpeakerParticipant(state);
if (participant) { if (participant && !participant.local) {
return participant.id; return participant.id;
} }
// In case this is the local participant.
participant = undefined;
// 4. Next, pick the most recent participant with video. // 4. Next, pick the most recent participant with video.
const tracks = state['features/base/tracks']; const tracks = state['features/base/tracks'];
const videoTrack = _electLastVisibleRemoteVideo(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 // 5. As a last resort, select the participant that joined last (other than poltergist or other bot type
// participants). // participants).
const participants = [ ...getRemoteParticipants(state).values() ];
for (let i = participants.length; i > 0 && !participant; i--) { for (let i = participants.length; i > 0 && !participant; i--) {
const p = participants[i - 1]; const p = participants[i - 1];
@ -131,5 +142,5 @@ function _electParticipantInLargeVideo(state) {
return participant.id; return participant.id;
} }
return participants.find(p => p.local)?.id; return getLocalParticipant(state)?.id;
} }

View File

@ -28,7 +28,13 @@ import {
import { JitsiConferenceEvents } from '../../base/lib-jitsi-meet'; import { JitsiConferenceEvents } from '../../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes'; 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 { MiddlewareRegistry, StateListenerRegistry } from '../../base/redux';
import { toggleScreensharing } from '../../base/tracks'; import { toggleScreensharing } from '../../base/tracks';
import { OPEN_CHAT, CLOSE_CHAT } from '../../chat'; import { OPEN_CHAT, CLOSE_CHAT } from '../../chat';
@ -268,6 +274,24 @@ StateListenerRegistry.register(
}, 100)); }, 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. * Registers for events sent from the native side via NativeEventEmitter.
* *
@ -309,16 +333,15 @@ function _registerForNativeEvents(store) {
eventEmitter.addListener(ExternalAPI.RETRIEVE_PARTICIPANTS_INFO, ({ requestId }) => { eventEmitter.addListener(ExternalAPI.RETRIEVE_PARTICIPANTS_INFO, ({ requestId }) => {
const participantsInfo = getParticipants(store).map(participant => { const participantsInfo = [];
return { const remoteParticipants = getRemoteParticipants(store);
isLocal: participant.local, const localParticipant = getLocalParticipant(store);
email: participant.email,
name: participant.name, participantsInfo.push(_participantToParticipantInfo(localParticipant));
participantId: participant.id, remoteParticipants.forEach(participant => {
displayName: participant.displayName, if (!participant.isFakeParticipant) {
avatarUrl: participant.avatarURL, participantsInfo.push(_participantToParticipantInfo(participant));
role: participant.role }
};
}); });
sendEvent( sendEvent(

View File

@ -1,7 +1,6 @@
// @flow // @flow
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { approveParticipant } from '../../av-moderation/actions'; import { approveParticipant } from '../../av-moderation/actions';
@ -10,6 +9,11 @@ import { QuickActionButton } from './styled';
type Props = { type Props = {
/**
* The translated ask unmute text.
*/
askUnmuteText: string,
/** /**
* Participant id. * Participant id.
*/ */
@ -22,10 +26,8 @@ type Props = {
* @param {Object} participant - Participant reference. * @param {Object} participant - Participant reference.
* @returns {React$Element<'button'>} * @returns {React$Element<'button'>}
*/ */
export default function({ id }: Props) { export default function AskToUnmuteButton({ id, askUnmuteText }: Props) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { t } = useTranslation();
const askToUnmute = useCallback(() => { const askToUnmute = useCallback(() => {
dispatch(approveParticipant(id)); dispatch(approveParticipant(id));
}, [ dispatch, id ]); }, [ dispatch, id ]);
@ -37,7 +39,7 @@ export default function({ id }: Props) {
theme = {{ theme = {{
panePadding: 16 panePadding: 16
}}> }}>
{t('participantsPane.actions.askUnmute')} { askUnmuteText }
</QuickActionButton> </QuickActionButton>
); );
} }

View File

@ -6,11 +6,17 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions'; 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 { openDialog } from '../../base/dialog';
import { Icon, IconCheck, IconVideoOff } from '../../base/icons'; import { Icon, IconCheck, IconVideoOff } from '../../base/icons';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { getLocalParticipant } from '../../base/participants'; import {
getLocalParticipant,
isEveryoneModerator
} from '../../base/participants';
import { MuteEveryonesVideoDialog } from '../../video-menu/components'; import { MuteEveryonesVideoDialog } from '../../video-menu/components';
import { import {
@ -49,6 +55,8 @@ type Props = {
export const FooterContextMenu = ({ onMouseLeave }: Props) => { export const FooterContextMenu = ({ onMouseLeave }: Props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isModerationSupported = useSelector(isAvModerationSupported());
const allModerators = useSelector(isEveryoneModerator);
const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO)); const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
const { id } = useSelector(getLocalParticipant); const { id } = useSelector(getLocalParticipant);
const { t } = useTranslation(); const { t } = useTranslation();
@ -75,27 +83,32 @@ export const FooterContextMenu = ({ onMouseLeave }: Props) => {
<span>{ t('participantsPane.actions.stopEveryonesVideo') }</span> <span>{ t('participantsPane.actions.stopEveryonesVideo') }</span>
</ContextMenuItem> </ContextMenuItem>
<div className = { classes.text }> { isModerationSupported && !allModerators ? (
{t('participantsPane.actions.allow')} <>
</div> <div className = { classes.text }>
{ isModerationEnabled ? ( {t('participantsPane.actions.allow')}
<ContextMenuItem </div>
id = 'participants-pane-context-menu-start-moderation' { isModerationEnabled ? (
onClick = { disable }> <ContextMenuItem
<span className = { classes.paddedAction }> id = 'participants-pane-context-menu-start-moderation'
{ t('participantsPane.actions.startModeration') } onClick = { disable }>
</span> <span className = { classes.paddedAction }>
</ContextMenuItem> { t('participantsPane.actions.startModeration') }
) : ( </span>
<ContextMenuItem </ContextMenuItem>
id = 'participants-pane-context-menu-stop-moderation' ) : (
onClick = { enable }> <ContextMenuItem
<Icon id = 'participants-pane-context-menu-stop-moderation'
size = { 20 } onClick = { enable }>
src = { IconCheck } /> <Icon
<span>{ t('participantsPane.actions.startModeration') }</span> size = { 20 }
</ContextMenuItem> src = { IconCheck } />
)} <span>{ t('participantsPane.actions.startModeration') }</span>
</ContextMenuItem>
)}
</>
) : undefined
}
</ContextMenu> </ContextMenu>
); );
}; };

View File

@ -7,7 +7,7 @@ import { useDispatch } from 'react-redux';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../../lobby/actions'; import { approveKnockingParticipant, rejectKnockingParticipant } from '../../lobby/actions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../constants'; import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
import { ParticipantItem } from './ParticipantItem'; import ParticipantItem from './ParticipantItem';
import { ParticipantActionButton } from './styled'; import { ParticipantActionButton } from './styled';
type Props = { type Props = {
@ -28,9 +28,12 @@ export const LobbyParticipantItem = ({ participant: p }: Props) => {
<ParticipantItem <ParticipantItem
actionsTrigger = { ACTION_TRIGGER.PERMANENT } actionsTrigger = { ACTION_TRIGGER.PERMANENT }
audioMediaState = { MEDIA_STATE.NONE } audioMediaState = { MEDIA_STATE.NONE }
name = { p.name } displayName = { p.name }
participant = { p } local = { p.local }
videoMuteState = { MEDIA_STATE.NONE }> participantID = { p.id }
raisedHand = { p.raisedHand }
videoMuteState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }>
<ParticipantActionButton <ParticipantActionButton
onClick = { reject }> onClick = { reject }>
{t('lobby.reject')} {t('lobby.reject')}

View File

@ -1,11 +1,10 @@
// @flow // @flow
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; import React, { Component } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { isToolbarButtonEnabled } from '../../base/config/functions.web'; import { isToolbarButtonEnabled } from '../../base/config/functions.web';
import { openDialog } from '../../base/dialog'; import { openDialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
import { import {
IconCloseCircle, IconCloseCircle,
IconCrown, IconCrown,
@ -14,8 +13,13 @@ import {
IconMuteEveryoneElse, IconMuteEveryoneElse,
IconVideoOff IconVideoOff
} from '../../base/icons'; } from '../../base/icons';
import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants'; import {
import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks'; getParticipantByIdOrUndefined,
isLocalParticipantModerator,
isParticipantModerator
} from '../../base/participants';
import { connect } from '../../base/redux';
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks';
import { openChat } from '../../chat/actions'; import { openChat } from '../../chat/actions';
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu'; import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog'; import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
@ -31,6 +35,41 @@ import {
type Props = { 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. * Callback used to open a confirmation dialog for audio muting.
*/ */
@ -57,35 +96,145 @@ type Props = {
onSelect: Function, onSelect: Function,
/** /**
* Participant reference * The ID of the participant.
*/ */
participant: Object participantID: string,
/**
* The translate function.
*/
t: Function
}; };
export const MeetingParticipantContextMenu = ({ type State = {
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();
useLayoutEffect(() => { /**
if (participant * If true the context menu will be hidden.
&& containerRef.current */
isHidden: boolean
};
/**
* Implements the MeetingParticipantContextMenu component.
*/
class MeetingParticipantContextMenu extends Component<Props, State> {
/**
* 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
&& offsetTarget.offsetParent instanceof HTMLElement && offsetTarget.offsetParent instanceof HTMLElement
) { ) {
const { current: container } = containerRef; const { current: container } = this._containerRef;
const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget; const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget;
const outerHeight = getComputedOuterHeight(container); const outerHeight = getComputedOuterHeight(container);
@ -93,97 +242,158 @@ export const MeetingParticipantContextMenu = ({
? offsetTop - outerHeight ? offsetTop - outerHeight
: offsetTop; : offsetTop;
setIsHidden(false); this.setState({ isHidden: false });
} else { } 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 ( /**
<ContextMenu * Implements React Component's componentDidMount.
className = { ignoredChildClassName } *
innerRef = { containerRef } * @inheritdoc
isHidden = { isHidden } * @returns {void}
onClick = { onSelect } */
onMouseEnter = { onEnter } componentDidMount() {
onMouseLeave = { onLeave }> this._position();
<ContextMenuItemGroup> }
{isLocalModerator && (
<>
{!isParticipantAudioMuted
&& <ContextMenuItem onClick = { muteAudio(participant) }>
<ContextMenuIcon src = { IconMicDisabled } />
<span>{t('dialog.muteParticipantButton')}</span>
</ContextMenuItem>}
<ContextMenuItem onClick = { muteEveryoneElse }> /**
<ContextMenuIcon src = { IconMuteEveryoneElse } /> * Implements React Component's componentDidUpdate.
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span> *
</ContextMenuItem> * @inheritdoc
</> */
)} componentDidUpdate(prevProps: Props) {
if (prevProps.offsetTarget !== this.props.offsetTarget || prevProps._participant !== this.props._participant) {
this._position();
}
}
{isLocalModerator && (isParticipantVideoMuted || ( /**
<ContextMenuItem onClick = { muteVideo }> * Implements React's {@link Component#render()}.
<ContextMenuIcon src = { IconVideoOff } /> *
<span>{t('participantsPane.actions.stopVideo')}</span> * @inheritdoc
</ContextMenuItem> * @returns {ReactElement}
))} */
</ContextMenuItemGroup> render() {
const {
_isLocalModerator,
_isChatButtonEnabled,
_isParticipantModerator,
_isParticipantVideoMuted,
_isParticipantAudioMuted,
_participant,
onEnter,
onLeave,
onSelect,
muteAudio,
t
} = this.props;
<ContextMenuItemGroup> if (!_participant) {
{isLocalModerator && ( return null;
<> }
{!isParticipantModerator(participant)
&& <ContextMenuItem onClick = { grantModerator }> return (
<ContextMenuIcon src = { IconCrown } /> <ContextMenu
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span> className = { ignoredChildClassName }
</ContextMenuItem>} innerRef = { this._containerRef }
<ContextMenuItem onClick = { kick }> isHidden = { this.state.isHidden }
<ContextMenuIcon src = { IconCloseCircle } /> onClick = { onSelect }
<span>{t('videothumbnail.kick')}</span> onMouseEnter = { onEnter }
</ContextMenuItem> onMouseLeave = { onLeave }>
</> <ContextMenuItemGroup>
)} {
{isChatButtonEnabled && ( _isLocalModerator && (
<ContextMenuItem onClick = { sendPrivateMessage }> <>
<ContextMenuIcon src = { IconMessage } /> {
<span>{t('toolbar.accessibilityLabel.privateMessage')}</span> !_isParticipantAudioMuted
</ContextMenuItem> && <ContextMenuItem onClick = { muteAudio(_participant) }>
)} <ContextMenuIcon src = { IconMicDisabled } />
</ContextMenuItemGroup> <span>{t('dialog.muteParticipantButton')}</span>
</ContextMenu> </ContextMenuItem>
); }
};
<ContextMenuItem onClick = { this._onMuteEveryoneElse }>
<ContextMenuIcon src = { IconMuteEveryoneElse } />
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
</ContextMenuItem>
</>
)
}
{
_isLocalModerator && (
_isParticipantVideoMuted || (
<ContextMenuItem onClick = { this._onMuteVideo }>
<ContextMenuIcon src = { IconVideoOff } />
<span>{t('participantsPane.actions.stopVideo')}</span>
</ContextMenuItem>
)
)
}
</ContextMenuItemGroup>
<ContextMenuItemGroup>
{
_isLocalModerator && (
<>
{
!_isParticipantModerator && (
<ContextMenuItem onClick = { this._onGrantModerator }>
<ContextMenuIcon src = { IconCrown } />
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
</ContextMenuItem>
)
}
<ContextMenuItem onClick = { this._onKick }>
<ContextMenuIcon src = { IconCloseCircle } />
<span>{ t('videothumbnail.kick') }</span>
</ContextMenuItem>
</>
)
}
{
_isChatButtonEnabled && (
<ContextMenuItem onClick = { this._onSendPrivateMessage }>
<ContextMenuIcon src = { IconMessage } />
<span>{t('toolbar.accessibilityLabel.privateMessage')}</span>
</ContextMenuItem>
)
}
</ContextMenuItemGroup>
</ContextMenu>
);
}
}
/**
* 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));

View File

@ -1,19 +1,62 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks'; import { getParticipantByIdOrUndefined, getParticipantDisplayName } from '../../base/participants';
import { ACTION_TRIGGER, MEDIA_STATE } from '../constants'; import { connect } from '../../base/redux';
import { getParticipantAudioMediaState } from '../functions'; 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 ParticipantQuickAction from './ParticipantQuickAction';
import { ParticipantActionEllipsis } from './styled'; import { ParticipantActionEllipsis } from './styled';
type Props = { 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 * Is this item highlighted
*/ */
@ -24,6 +67,11 @@ type Props = {
*/ */
muteAudio: Function, muteAudio: Function,
/**
* The translated text for the mute participant button.
*/
muteParticipantButtonText: string,
/** /**
* Callback for the activation of this item's context menu * Callback for the activation of this item's context menu
*/ */
@ -35,38 +83,97 @@ type Props = {
onLeave: Function, 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, isHighlighted,
onContextMenu, onContextMenu,
onLeave, onLeave,
muteAudio, muteAudio,
participant muteParticipantButtonText,
}: Props) => { participantActionEllipsisLabel,
const { t } = useTranslation(); youText
const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant)); }: Props) {
const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
const audioMediaState = useSelector(getParticipantAudioMediaState(participant, isAudioMuted));
return ( return (
<ParticipantItem <ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER } actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMediaState = { audioMediaState } audioMediaState = { _audioMediaState }
displayName = { _displayName }
isHighlighted = { isHighlighted } isHighlighted = { isHighlighted }
local = { _local }
onLeave = { onLeave } onLeave = { onLeave }
participant = { participant } participantID = { _participantID }
videoMuteState = { isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED }> raisedHand = { _raisedHand }
videoMuteState = { _isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED }
youText = { youText }>
<ParticipantQuickAction <ParticipantQuickAction
isAudioMuted = { isAudioMuted } askUnmuteText = { askUnmuteText }
buttonType = { _quickActionButtonType }
muteAudio = { muteAudio } muteAudio = { muteAudio }
participant = { participant } /> muteParticipantButtonText = { muteParticipantButtonText }
participantID = { _participantID } />
<ParticipantActionEllipsis <ParticipantActionEllipsis
aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') } aria-label = { participantActionEllipsisLabel }
onClick = { onContextMenu } /> onClick = { onContextMenu } />
</ParticipantItem> </ParticipantItem>
); );
}; }
/**
* 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);

View File

@ -1,18 +1,21 @@
// @flow // @flow
import _ from 'lodash';
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { openDialog } from '../../base/dialog'; 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 MuteRemoteParticipantDialog from '../../video-menu/components/web/MuteRemoteParticipantDialog';
import { findStyledAncestor, shouldRenderInviteButton } from '../functions'; import { findStyledAncestor, shouldRenderInviteButton } from '../functions';
import { InviteButton } from './InviteButton'; import { InviteButton } from './InviteButton';
import { MeetingParticipantContextMenu } from './MeetingParticipantContextMenu'; import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
import { MeetingParticipantItem } from './MeetingParticipantItem'; import MeetingParticipantItem from './MeetingParticipantItem';
import { Heading, ParticipantContainer } from './styled'; import { Heading, ParticipantContainer } from './styled';
type NullProto = { type NullProto = {
@ -20,7 +23,7 @@ type NullProto = {
__proto__: null __proto__: null
}; };
type RaiseContext = NullProto | { type RaiseContext = NullProto | {|
/** /**
* Target elements against which positioning calculations are made * Target elements against which positioning calculations are made
@ -28,17 +31,28 @@ type RaiseContext = NullProto | {
offsetTarget?: HTMLElement, offsetTarget?: HTMLElement,
/** /**
* Participant reference * The ID of the participant.
*/ */
participant?: Object, participantID?: String,
}; |};
const initialState = Object.freeze(Object.create(null)); 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 dispatch = useDispatch();
const isMouseOverMenu = useRef(false); 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 showInviteButton = useSelector(shouldRenderInviteButton);
const [ raiseContext, setRaiseContext ] = useState<RaiseContext>(initialState); const [ raiseContext, setRaiseContext ] = useState<RaiseContext>(initialState);
const { t } = useTranslation(); const { t } = useTranslation();
@ -61,20 +75,20 @@ export const MeetingParticipantList = () => {
}); });
}, [ raiseContext ]); }, [ raiseContext ]);
const raiseMenu = useCallback((participant, target) => { const raiseMenu = useCallback((participantID, target) => {
setRaiseContext({ setRaiseContext({
participant, participantID,
offsetTarget: findStyledAncestor(target, ParticipantContainer) offsetTarget: findStyledAncestor(target, ParticipantContainer)
}); });
}, [ raiseContext ]); }, [ raiseContext ]);
const toggleMenu = useCallback(participant => e => { const toggleMenu = useCallback(participantID => e => {
const { participant: raisedParticipant } = raiseContext; const { participantID: raisedParticipant } = raiseContext;
if (raisedParticipant && raisedParticipant === participant) { if (raisedParticipant && raisedParticipant === participantID) {
lowerMenu(); lowerMenu();
} else { } else {
raiseMenu(participant, e.target); raiseMenu(participantID, e.target);
} }
}, [ raiseContext ]); }, [ raiseContext ]);
@ -91,20 +105,44 @@ export const MeetingParticipantList = () => {
dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id })); 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 => (
<MeetingParticipantItem
askUnmuteText = { askUnmuteText }
isHighlighted = { raiseContext.participantID === id }
key = { id }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
onContextMenu = { toggleMenu(id) }
onLeave = { lowerMenu }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantID = { id }
youText = { youText } />
);
const items = [];
localParticipant && items.push(renderParticipant(localParticipant?.id));
participants.forEach(p => {
items.push(renderParticipant(p?.id));
});
return ( return (
<> <>
<Heading>{t('participantsPane.headings.participantsList', { count: participants.length })}</Heading> <Heading>{t('participantsPane.headings.participantsList', { count: participantsCount })}</Heading>
{showInviteButton && <InviteButton />} {showInviteButton && <InviteButton />}
<div> <div>
{participants.map(p => ( { items }
<MeetingParticipantItem
isHighlighted = { raiseContext.participant === p }
key = { p.id }
muteAudio = { muteAudio }
onContextMenu = { toggleMenu(p) }
onLeave = { lowerMenu }
participant = { p } />
))}
</div> </div>
<MeetingParticipantContextMenu <MeetingParticipantContextMenu
muteAudio = { muteAudio } muteAudio = { muteAudio }
@ -114,4 +152,4 @@ export const MeetingParticipantList = () => {
{ ...raiseContext } /> { ...raiseContext } />
</> </>
); );
}; }

View File

@ -1,8 +1,6 @@
// @flow // @flow
import React, { type Node } from 'react'; import React, { type Node } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Avatar } from '../../base/avatar'; import { Avatar } from '../../base/avatar';
import { import {
@ -12,7 +10,6 @@ import {
IconMicrophoneEmpty, IconMicrophoneEmpty,
IconMicrophoneEmptySlash IconMicrophoneEmptySlash
} from '../../base/icons'; } from '../../base/icons';
import { getParticipantDisplayNameWithId } from '../../base/participants';
import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../constants'; import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../constants';
import { RaisedHandIndicator } from './RaisedHandIndicator'; import { RaisedHandIndicator } from './RaisedHandIndicator';
@ -100,15 +97,20 @@ type Props = {
*/ */
children: Node, children: Node,
/**
* The name of the participant. Used for showing lobby names.
*/
displayName: string,
/** /**
* Is this item highlighted/raised * Is this item highlighted/raised
*/ */
isHighlighted?: boolean, 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 * Callback for when the mouse leaves this component
@ -116,29 +118,46 @@ type Props = {
onLeave?: Function, 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 * 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, children,
isHighlighted, isHighlighted,
onLeave, onLeave,
actionsTrigger = ACTION_TRIGGER.HOVER, actionsTrigger = ACTION_TRIGGER.HOVER,
audioMediaState = MEDIA_STATE.NONE, audioMediaState = MEDIA_STATE.NONE,
videoMuteState = MEDIA_STATE.NONE, videoMuteState = MEDIA_STATE.NONE,
name, displayName,
participant: p participantID,
}: Props) => { local,
raisedHand,
youText
}: Props) {
const ParticipantActions = Actions[actionsTrigger]; const ParticipantActions = Actions[actionsTrigger];
const { t } = useTranslation();
const displayName = name || useSelector(getParticipantDisplayNameWithId(p.id));
return ( return (
<ParticipantContainer <ParticipantContainer
@ -147,22 +166,22 @@ export const ParticipantItem = ({
trigger = { actionsTrigger }> trigger = { actionsTrigger }>
<Avatar <Avatar
className = 'participant-avatar' className = 'participant-avatar'
participantId = { p.id } participantId = { participantID }
size = { 32 } /> size = { 32 } />
<ParticipantContent> <ParticipantContent>
<ParticipantNameContainer> <ParticipantNameContainer>
<ParticipantName> <ParticipantName>
{ displayName } { displayName }
</ParticipantName> </ParticipantName>
{ p.local ? <span>&nbsp;({t('chat.you')})</span> : null } { local ? <span>&nbsp;({ youText })</span> : null }
</ParticipantNameContainer> </ParticipantNameContainer>
{ !p.local && <ParticipantActions children = { children } /> } { !local && <ParticipantActions children = { children } /> }
<ParticipantStates> <ParticipantStates>
{p.raisedHand && <RaisedHandIndicator />} { raisedHand && <RaisedHandIndicator /> }
{VideoStateIcons[videoMuteState]} { VideoStateIcons[videoMuteState] }
{AudioStateIcons[audioMediaState]} { AudioStateIcons[audioMediaState] }
</ParticipantStates> </ParticipantStates>
</ParticipantContent> </ParticipantContent>
</ParticipantContainer> </ParticipantContainer>
); );
}; }

View File

@ -1,11 +1,8 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { QUICK_ACTION_BUTTON } from '../constants'; import { QUICK_ACTION_BUTTON } from '../constants';
import { getQuickActionButtonType } from '../functions';
import AskToUnmuteButton from './AskToUnmuteButton'; import AskToUnmuteButton from './AskToUnmuteButton';
import { QuickActionButton } from './styled'; import { QuickActionButton } from './styled';
@ -13,19 +10,26 @@ import { QuickActionButton } from './styled';
type Props = { 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. * Callback used to open a confirmation dialog for audio muting.
*/ */
muteAudio: Function, 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. * @param {Props} props - The props of the component.
* @returns {React$Element<'button'>} * @returns {React$Element<'button'>}
*/ */
export default function({ isAudioMuted, muteAudio, participant }: Props) { export default function ParticipantQuickAction({
const buttonType = useSelector(getQuickActionButtonType(participant, isAudioMuted)); askUnmuteText,
const { id } = participant; buttonType,
const { t } = useTranslation(); muteAudio,
muteParticipantButtonText,
participantID
}: Props) {
switch (buttonType) { switch (buttonType) {
case QUICK_ACTION_BUTTON.MUTE: { case QUICK_ACTION_BUTTON.MUTE: {
return ( return (
<QuickActionButton <QuickActionButton
onClick = { muteAudio(id) } onClick = { muteAudio(participantID) }
primary = { true }> primary = { true }>
{t('dialog.muteParticipantButton')} { muteParticipantButtonText }
</QuickActionButton> </QuickActionButton>
); );
} }
case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: { case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
return <AskToUnmuteButton id = { id } />; return (
<AskToUnmuteButton
askUnmuteText = { askUnmuteText }
id = { participantID } />
);
} }
default: { default: {
return null; return null;

View File

@ -1,16 +1,15 @@
// @flow // @flow
import React, { useCallback, useEffect, useState } from 'react'; import React, { Component } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import { openDialog } from '../../base/dialog'; import { openDialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
import { import {
getParticipantCount, getParticipantCount,
isEveryoneModerator,
isLocalParticipantModerator isLocalParticipantModerator
} from '../../base/participants'; } from '../../base/participants';
import { connect } from '../../base/redux';
import { MuteEveryoneDialog } from '../../video-menu/components/'; import { MuteEveryoneDialog } from '../../video-menu/components/';
import { close } from '../actions'; import { close } from '../actions';
import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions'; import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions';
@ -30,74 +29,238 @@ import {
Header Header
} from './styled'; } from './styled';
export const ParticipantsPane = () => { /**
const dispatch = useDispatch(); * The type of the React {@code Component} props of {@link ParticipantsPane}.
const paneOpen = useSelector(getParticipantsPaneOpen); */
const isLocalModerator = useSelector(isLocalParticipantModerator); type Props = {
const participantsCount = useSelector(getParticipantCount);
const everyoneModerator = useSelector(isEveryoneModerator);
const showContextMenu = !everyoneModerator && participantsCount > 2;
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 => { * Whether to show context menu.
if (closePane && (e.key === ' ' || e.key === 'Enter')) { */
e.preventDefault(); _showContextMenu: boolean,
closePane();
}
}, [ closePane ]);
const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]);
useEffect(() => { /**
const handler = [ 'click', e => { * Whether to show the footer menu.
if (!findStyledAncestor(e.target, FooterEllipsisContainer)) { */
setContextOpen(false); _showFooter: boolean,
}
} ];
window.addEventListener(...handler); /**
* The Redux dispatch function.
*/
dispatch: Function,
return () => window.removeEventListener(...handler); /**
}, [ contextOpen ]); * The i18n translate function.
*/
const toggleContext = useCallback(() => setContextOpen(!contextOpen), [ contextOpen, setContextOpen ]); t: Function
return (
<ThemeProvider theme = { theme }>
<div className = { classList('participants_pane', !paneOpen && 'participants_pane--closed') }>
<div className = 'participants_pane-content'>
<Header>
<Close
aria-label = { t('participantsPane.close', 'Close') }
onClick = { closePane }
onKeyPress = { closePaneKeyPress }
role = 'button'
tabIndex = { 0 } />
</Header>
<Container>
<LobbyParticipantList />
<AntiCollapse />
<MeetingParticipantList />
</Container>
{isLocalModerator && (
<Footer>
<FooterButton onClick = { muteAll }>
{t('participantsPane.actions.muteAll')}
</FooterButton>
{showContextMenu && (
<FooterEllipsisContainer>
<FooterEllipsisButton
id = 'participants-pane-context-menu'
onClick = { toggleContext } />
{contextOpen && <FooterContextMenu onMouseLeave = { toggleContext } />}
</FooterEllipsisContainer>
)}
</Footer>
)}
</div>
</div>
</ThemeProvider>
);
}; };
/**
* 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<Props, State> {
/**
* 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 (
<ThemeProvider theme = { theme }>
<div className = { classList('participants_pane', !_paneOpen && 'participants_pane--closed') }>
<div className = 'participants_pane-content'>
<Header>
<Close
aria-label = { t('participantsPane.close', 'Close') }
onClick = { this._onClosePane }
onKeyPress = { this._onKeyPress }
role = 'button'
tabIndex = { 0 } />
</Header>
<Container>
<LobbyParticipantList />
<AntiCollapse />
<MeetingParticipantList />
</Container>
{_showFooter && (
<Footer>
<FooterButton onClick = { this._onMuteAll }>
{t('participantsPane.actions.muteAll')}
</FooterButton>
{_showContextMenu && (
<FooterEllipsisContainer>
<FooterEllipsisButton
id = 'participants-pane-context-menu'
onClick = { this._onToggleContext } />
{this.state.contextOpen
&& <FooterContextMenu onMouseLeave = { this._onToggleContext } />}
</FooterEllipsisContainer>
)}
</Footer>
)}
</div>
</div>
</ThemeProvider>
);
}
_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));

View File

@ -1,10 +1,7 @@
export * from './InviteButton'; export * from './InviteButton';
export * from './LobbyParticipantItem'; export * from './LobbyParticipantItem';
export * from './LobbyParticipantList'; export * from './LobbyParticipantList';
export * from './MeetingParticipantContextMenu';
export * from './MeetingParticipantItem';
export * from './MeetingParticipantList'; export * from './MeetingParticipantList';
export * from './ParticipantItem'; export { default as ParticipantsPane } from './ParticipantsPane';
export * from './ParticipantsPane';
export * from './ParticipantsPaneButton'; export * from './ParticipantsPaneButton';
export * from './RaisedHandIndicator'; export * from './RaisedHandIndicator';

View File

@ -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. * @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 (getParticipantCount(state) > 2 && isEnabledFromState(mediaType, state)) {
if (participant.local) { if (participant.local) {
return !isLocalParticipantApprovedFromState(mediaType, state); return !isLocalParticipantApprovedFromState(mediaType, state);
@ -62,18 +63,19 @@ export const isForceMuted = (participant: Object, mediaType: MediaType) => (stat
} }
return false; 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 {Object} participant - The participant.
* @param {boolean} muted - The mute state of 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 (muted) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) { if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) {
return MEDIA_STATE.FORCE_MUTED; return MEDIA_STATE.FORCE_MUTED;
} }
@ -81,7 +83,7 @@ export const getParticipantAudioMediaState = (participant: Object, muted: Boolea
} }
return MEDIA_STATE.UNMUTED; return MEDIA_STATE.UNMUTED;
}; }
/** /**
@ -125,17 +127,18 @@ const getState = (state: Object) => state[REDUCER_KEY];
export const getParticipantsPaneOpen = (state: Object) => Boolean(getState(state)?.isOpen); 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. * The button is displayed when hovering a participant from the participant list.
* *
* @param {Object} participant - The participant. * @param {Object} participant - The participant.
* @param {boolean} isAudioMuted - If audio is muted for 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 // handled only by moderators
if (isLocalParticipantModerator(state)) { if (isLocalParticipantModerator(state)) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) { if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) {
return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE; return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
} }
if (!isAudioMuted) { if (!isAudioMuted) {
@ -144,7 +147,7 @@ export const getQuickActionButtonType = (participant: Object, isAudioMuted: Bool
} }
return QUICK_ACTION_BUTTON.NONE; return QUICK_ACTION_BUTTON.NONE;
}; }
/** /**
* Returns true if the invite button should be rendered. * Returns true if the invite button should be rendered.

View File

@ -144,7 +144,7 @@ function _mapStateToProps(state) {
clientHeight, clientHeight,
clientWidth, clientWidth,
filmstripVisible: visible, filmstripVisible: visible,
isOwner: ownerId === localParticipant.id, isOwner: ownerId === localParticipant?.id,
videoUrl videoUrl
}; };
} }

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { getParticipants } from '../base/participants'; import { getFakeParticipants } from '../base/participants';
import { VIDEO_PLAYER_PARTICIPANT_NAME, YOUTUBE_PLAYER_PARTICIPANT_NAME } from './constants'; import { VIDEO_PLAYER_PARTICIPANT_NAME, YOUTUBE_PLAYER_PARTICIPANT_NAME } from './constants';
@ -41,7 +41,15 @@ export function isSharingStatus(status: string) {
* @returns {boolean} * @returns {boolean}
*/ */
export function isVideoPlaying(stateful: Object | Function): boolean { export function isVideoPlaying(stateful: Object | Function): boolean {
return Boolean(getParticipants(stateful).find(p => p.isFakeParticipant let videoPlaying = false;
&& (p.name === VIDEO_PLAYER_PARTICIPANT_NAME || p.name === YOUTUBE_PLAYER_PARTICIPANT_NAME))
); // 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;
} }

View File

@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import { getParticipantCountWithFake } from '../../base/participants';
import { connect } from '../../base/redux'; import { connect } from '../../base/redux';
import { import {
@ -75,7 +76,7 @@ class Captions
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
..._abstractMapStateToProps(state), ..._abstractMapStateToProps(state),
_isLifted: state['features/base/participants'].length < 2 _isLifted: getParticipantCountWithFake(state) < 2
}; };
} }

View File

@ -17,7 +17,7 @@ import { translate } from '../../../base/i18n';
import JitsiMeetJS from '../../../base/lib-jitsi-meet'; import JitsiMeetJS from '../../../base/lib-jitsi-meet';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipants, haveParticipantWithScreenSharingFeature,
raiseHand raiseHand
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
@ -183,15 +183,20 @@ type Props = {
_raisedHand: boolean, _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. * Whether or not the local participant is sharing a YouTube video.
*/ */
_sharingVideo: boolean, _sharingVideo: boolean,
/**
* The enabled buttons.
*/
_toolbarButtons: Array<string>,
/** /**
* Flag showing whether toolbar is visible. * Flag showing whether toolbar is visible.
*/ */
@ -202,11 +207,6 @@ type Props = {
*/ */
_visibleButtons: Array<string>, _visibleButtons: Array<string>,
/**
* Handler to check if a button is enabled.
*/
_shouldShowButton: Function,
/** /**
* Returns the selected virtual source object. * Returns the selected virtual source object.
*/ */
@ -269,38 +269,39 @@ class Toolbox extends Component<Props> {
* @returns {void} * @returns {void}
*/ */
componentDidMount() { componentDidMount() {
const { _toolbarButtons } = this.props;
const KEYBOARD_SHORTCUTS = [ const KEYBOARD_SHORTCUTS = [
this.props._shouldShowButton('videoquality') && { isToolbarButtonEnabled('videoquality', _toolbarButtons) && {
character: 'A', character: 'A',
exec: this._onShortcutToggleVideoQuality, exec: this._onShortcutToggleVideoQuality,
helpDescription: 'toolbar.callQuality' helpDescription: 'toolbar.callQuality'
}, },
this.props._shouldShowButton('chat') && { isToolbarButtonEnabled('chat', _toolbarButtons) && {
character: 'C', character: 'C',
exec: this._onShortcutToggleChat, exec: this._onShortcutToggleChat,
helpDescription: 'keyboardShortcuts.toggleChat' helpDescription: 'keyboardShortcuts.toggleChat'
}, },
this.props._shouldShowButton('desktop') && { isToolbarButtonEnabled('desktop', _toolbarButtons) && {
character: 'D', character: 'D',
exec: this._onShortcutToggleScreenshare, exec: this._onShortcutToggleScreenshare,
helpDescription: 'keyboardShortcuts.toggleScreensharing' helpDescription: 'keyboardShortcuts.toggleScreensharing'
}, },
this.props._shouldShowButton('participants-pane') && { isToolbarButtonEnabled('participants-pane', _toolbarButtons) && {
character: 'P', character: 'P',
exec: this._onShortcutToggleParticipantsPane, exec: this._onShortcutToggleParticipantsPane,
helpDescription: 'keyboardShortcuts.toggleParticipantsPane' helpDescription: 'keyboardShortcuts.toggleParticipantsPane'
}, },
this.props._shouldShowButton('raisehand') && { isToolbarButtonEnabled('raisehand', _toolbarButtons) && {
character: 'R', character: 'R',
exec: this._onShortcutToggleRaiseHand, exec: this._onShortcutToggleRaiseHand,
helpDescription: 'keyboardShortcuts.raiseHand' helpDescription: 'keyboardShortcuts.raiseHand'
}, },
this.props._shouldShowButton('fullscreen') && { isToolbarButtonEnabled('fullscreen', _toolbarButtons) && {
character: 'S', character: 'S',
exec: this._onShortcutToggleFullScreen, exec: this._onShortcutToggleFullScreen,
helpDescription: 'keyboardShortcuts.fullScreen' helpDescription: 'keyboardShortcuts.fullScreen'
}, },
this.props._shouldShowButton('tileview') && { isToolbarButtonEnabled('tileview', _toolbarButtons) && {
character: 'W', character: 'W',
exec: this._onShortcutToggleTileView, exec: this._onShortcutToggleTileView,
helpDescription: 'toolbar.tileViewToggle' helpDescription: 'toolbar.tileViewToggle'
@ -509,7 +510,7 @@ class Toolbox extends Component<Props> {
const { const {
_feedbackConfigured, _feedbackConfigured,
_isMobile, _isMobile,
_screensharing _screenSharing
} = this.props; } = this.props;
const microphone = { const microphone = {
@ -644,7 +645,7 @@ class Toolbox extends Component<Props> {
group: 3 group: 3
}; };
const virtualBackground = !_screensharing && checkBlurSupport() && { const virtualBackground = !_screenSharing && checkBlurSupport() && {
key: 'select-background', key: 'select-background',
Content: VideoBackgroundButton, Content: VideoBackgroundButton,
group: 3 group: 3
@ -734,12 +735,12 @@ class Toolbox extends Component<Props> {
_getVisibleButtons() { _getVisibleButtons() {
const { const {
_clientWidth, _clientWidth,
_shouldShowButton _toolbarButtons
} = this.props; } = this.props;
const buttons = this._getAllButtons(); const buttons = this._getAllButtons();
const isHangupVisible = _shouldShowButton('hangup'); const isHangupVisible = isToolbarButtonEnabled('hangup', _toolbarButtons);
const { order } = THRESHOLDS.find(({ width }) => _clientWidth > width) const { order } = THRESHOLDS.find(({ width }) => _clientWidth > width)
|| THRESHOLDS[THRESHOLDS.length - 1]; || THRESHOLDS[THRESHOLDS.length - 1];
let sliceIndex = order.length + 2; let sliceIndex = order.length + 2;
@ -749,7 +750,7 @@ class Toolbox extends Component<Props> {
const filtered = [ const filtered = [
...order.map(key => buttons[key]), ...order.map(key => buttons[key]),
...Object.values(buttons).filter((button, index) => !order.includes(keys[index])) ...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) { if (isHangupVisible) {
sliceIndex -= 1; sliceIndex -= 1;
@ -934,7 +935,7 @@ class Toolbox extends Component<Props> {
'toggle.screen.sharing', 'toggle.screen.sharing',
ACTION_SHORTCUT_TRIGGERED, ACTION_SHORTCUT_TRIGGERED,
{ {
enable: !this.props._screensharing enable: !this.props._screenSharing
})); }));
this._doToggleScreenshare(); this._doToggleScreenshare();
@ -1053,7 +1054,7 @@ class Toolbox extends Component<Props> {
sendAnalytics(createToolbarEvent( sendAnalytics(createToolbarEvent(
'toggle.screen.sharing', 'toggle.screen.sharing',
ACTION_SHORTCUT_TRIGGERED, ACTION_SHORTCUT_TRIGGERED,
{ enable: !this.props._screensharing })); { enable: !this.props._screenSharing }));
this._closeOverflowMenuIfOpen(); this._closeOverflowMenuIfOpen();
this._doToggleScreenshare(); this._doToggleScreenshare();
@ -1116,6 +1117,7 @@ class Toolbox extends Component<Props> {
const { const {
_isMobile, _isMobile,
_overflowMenuVisible, _overflowMenuVisible,
_toolbarButtons,
t t
} = this.props; } = this.props;
@ -1169,7 +1171,7 @@ class Toolbox extends Component<Props> {
<HangupButton <HangupButton
customClass = 'hangup-button' customClass = 'hangup-button'
key = 'hangup-button' key = 'hangup-button'
visible = { this.props._shouldShowButton('hangup') } /> visible = { isToolbarButtonEnabled('hangup', _toolbarButtons) } />
</div> </div>
</div> </div>
</div> </div>
@ -1203,12 +1205,12 @@ function _mapStateToProps(state) {
let desktopSharingDisabledTooltipKey; let desktopSharingDisabledTooltipKey;
if (enableFeaturesBasedOnToken) { if (enableFeaturesBasedOnToken) {
// we enable desktop sharing if any participant already have this if (desktopSharingEnabled) {
// feature enabled // we enable desktop sharing if any participant already have this
desktopSharingEnabled = getParticipants(state) // feature enabled and if the user supports it.
.find(({ features = {} }) => desktopSharingEnabled = haveParticipantWithScreenSharingFeature(state);
String(features['screen-sharing']) === 'true') !== undefined; desktopSharingDisabledTooltipKey = 'dialog.shareYourScreenDisabled';
desktopSharingDisabledTooltipKey = 'dialog.shareYourScreenDisabled'; }
} }
return { return {
@ -1226,13 +1228,13 @@ function _mapStateToProps(state) {
_isVpaasMeeting: isVpaasMeeting(state), _isVpaasMeeting: isVpaasMeeting(state),
_fullScreen: fullScreen, _fullScreen: fullScreen,
_tileViewEnabled: shouldDisplayTileView(state), _tileViewEnabled: shouldDisplayTileView(state),
_localParticipantID: localParticipant.id, _localParticipantID: localParticipant?.id,
_localVideo: localVideo, _localVideo: localVideo,
_overflowMenuVisible: overflowMenuVisible, _overflowMenuVisible: overflowMenuVisible,
_participantsPaneOpen: getParticipantsPaneOpen(state), _participantsPaneOpen: getParticipantsPaneOpen(state),
_raisedHand: localParticipant.raisedHand, _raisedHand: localParticipant?.raisedHand,
_screensharing: isScreenVideoShared(state), _screenSharing: isScreenVideoShared(state),
_shouldShowButton: buttonName => isToolbarButtonEnabled(buttonName)(state), _toolbarButtons: getToolbarButtons(state),
_visible: isToolboxVisible(state), _visible: isToolboxVisible(state),
_visibleButtons: getToolbarButtons(state) _visibleButtons: getToolbarButtons(state)
}; };

View File

@ -2,6 +2,7 @@
import { hasAvailableDevices } from '../base/devices'; import { hasAvailableDevices } from '../base/devices';
import { TOOLBOX_ALWAYS_VISIBLE, getFeatureFlag, TOOLBOX_ENABLED } from '../base/flags'; import { TOOLBOX_ALWAYS_VISIBLE, getFeatureFlag, TOOLBOX_ENABLED } from '../base/flags';
import { getParticipantCountWithFake } from '../base/participants';
import { toState } from '../base/redux'; import { toState } from '../base/redux';
import { isLocalVideoTrackDesktop } from '../base/tracks'; import { isLocalVideoTrackDesktop } from '../base/tracks';
@ -60,7 +61,7 @@ export function getMovableButtons(width: number): Set<string> {
export function isToolboxVisible(stateful: Object | Function) { export function isToolboxVisible(stateful: Object | Function) {
const state = toState(stateful); const state = toState(stateful);
const { alwaysVisible, enabled, visible } = state['features/toolbox']; 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 alwaysVisibleFlag = getFeatureFlag(state, TOOLBOX_ALWAYS_VISIBLE, false);
const enabledFlag = getFeatureFlag(state, TOOLBOX_ENABLED, true); const enabledFlag = getFeatureFlag(state, TOOLBOX_ENABLED, true);

View File

@ -5,7 +5,8 @@ import { getFeatureFlag, TILE_VIEW_ENABLED } from '../base/flags';
import { import {
getPinnedParticipant, getPinnedParticipant,
getParticipantCount, getParticipantCount,
pinParticipant pinParticipant,
getParticipantCountWithFake
} from '../base/participants'; } from '../base/participants';
import { import {
ASPECT_RATIO_BREAKPOINT, 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 // When in tile view mode, we must discount ourselves (the local participant) because our
// tile is not visible. // tile is not visible.
const { iAmRecorder } = state['features/base/config']; 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 columnsToMaintainASquare = Math.ceil(Math.sqrt(numberOfParticipants));
const columns = Math.min(columnsToMaintainASquare, maxColumns); const columns = Math.min(columnsToMaintainASquare, maxColumns);

View File

@ -20,6 +20,7 @@ import {
} from '../base/media'; } from '../base/media';
import { import {
getLocalParticipant, getLocalParticipant,
getRemoteParticipants,
muteRemoteParticipant muteRemoteParticipant
} from '../base/participants'; } from '../base/participants';
@ -91,14 +92,17 @@ export function muteAllParticipants(exclude: Array<string>, mediaType: MEDIA_TYP
return (dispatch: Dispatch<any>, getState: Function) => { return (dispatch: Dispatch<any>, getState: Function) => {
const state = getState(); const state = getState();
const localId = getLocalParticipant(state).id; const localId = getLocalParticipant(state).id;
const participantIds = state['features/base/participants']
.map(p => p.id);
/* eslint-disable no-confusing-arrow */ if (!exclude.includes(localId)) {
participantIds dispatch(muteLocal(true, mediaType));
.filter(id => !exclude.includes(id)) }
.map(id => id === localId ? muteLocal(true, mediaType) : muteRemote(id, mediaType))
.map(dispatch); getRemoteParticipants(state).forEach((p, id) => {
/* eslint-enable no-confusing-arrow */ if (exclude.includes(id)) {
return;
}
dispatch(muteRemote(id, mediaType));
});
}; };
} }