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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// @flow
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';
@ -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'];
/**
* We use to construct once the empty array so we can keep the same instance between calls
* of getParticipantsAskingToAudioUnmute.
*
* @type {*[]}
*/
const EMPTY_ARRAY = [];
/**
* Returns whether moderation is enabled per media type.
*
@ -33,6 +41,17 @@ export const isEnabledFromState = (mediaType: MediaType, state: Object) =>
*/
export const isEnabled = (mediaType: MediaType) => (state: Object) => isEnabledFromState(mediaType, state);
/**
* Returns whether moderation is supported by the backend.
*
* @returns {null|boolean}
*/
export const isSupported = () => (state: Object) => {
const { conference } = state['features/base/conference'];
return conference ? conference.isAVModerationSupported() : false;
};
/**
* Returns whether local participant is approved to unmute a media type.
*
@ -74,15 +93,15 @@ export const isParticipantApproved = (id: string, mediaType: MediaType) => (stat
/**
* Returns a selector creator which determines if the participant is pending or not for a media type.
*
* @param {string} id - The participant id.
* @param {Participant} participant - The participant.
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @returns {boolean}
*/
export const isParticipantPending = (id: string, mediaType: MediaType) => (state: Object) => {
export const isParticipantPending = (participant: Object, mediaType: MediaType) => (state: Object) => {
const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
const arr = getState(state)[storeKey];
return Boolean(arr.find(pending => pending === id));
return Boolean(arr.find(pending => pending.id === participant.id));
};
/**
@ -94,12 +113,10 @@ export const isParticipantPending = (id: string, mediaType: MediaType) => (state
*/
export const getParticipantsAskingToAudioUnmute = (state: Object) => {
if (isLocalParticipantModerator(state)) {
const ids = getState(state).pendingAudio;
return ids.map(id => getParticipantById(state, id)).filter(Boolean);
return getState(state).pendingAudio;
}
return [];
return EMPTY_ARRAY;
};
/**

View File

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

View File

@ -1,6 +1,11 @@
/* @flow */
import { MEDIA_TYPE } from '../base/media/constants';
import type { MediaType } from '../base/media/constants';
import {
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED
} from '../base/participants';
import { ReducerRegistry } from '../base/redux';
import {
@ -11,6 +16,7 @@ import {
PARTICIPANT_APPROVED,
PARTICIPANT_PENDING_AUDIO
} from './actionTypes';
import { MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants';
const initialState = {
audioModerationEnabled: false,
@ -21,6 +27,41 @@ const initialState = {
pendingVideo: []
};
/**
Updates a participant in the state for the specified media type.
*
* @param {MediaType} mediaType - The media type.
* @param {Object} participant - Information about participant to be modified.
* @param {Object} state - The current state.
* @private
* @returns {boolean} - Whether state instance was modified.
*/
function _updatePendingParticipant(mediaType: MediaType, participant, state: Object = {}) {
let arrayItemChanged = false;
const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
const arr = state[storeKey];
const newArr = arr.map(pending => {
if (pending.id === participant.id) {
arrayItemChanged = true;
return {
...pending,
...participant
};
}
return pending;
});
if (arrayItemChanged) {
state[storeKey] = newArr;
return true;
}
return false;
}
ReducerRegistry.register('features/av-moderation', (state = initialState, action) => {
switch (action.type) {
@ -65,13 +106,13 @@ ReducerRegistry.register('features/av-moderation', (state = initialState, action
}
case PARTICIPANT_PENDING_AUDIO: {
const { id } = action;
const { participant } = action;
// Add participant to pendigAudio array only if it's not already added
if (!state.pendingAudio.find(pending => pending === id)) {
// Add participant to pendingAudio array only if it's not already added
if (!state.pendingAudio.find(pending => pending.id === participant.id)) {
const updated = [ ...state.pendingAudio ];
updated.push(id);
updated.push(participant);
return {
...state,
@ -82,20 +123,79 @@ ReducerRegistry.register('features/av-moderation', (state = initialState, action
return state;
}
case PARTICIPANT_UPDATED: {
const participant = action.participant;
const { audioModerationEnabled, videoModerationEnabled } = state;
let hasStateChanged = false;
// skips changing the reference of pendingAudio or pendingVideo,
// if there is no change in the elements
if (audioModerationEnabled) {
hasStateChanged = _updatePendingParticipant(MEDIA_TYPE.AUDIO, participant, state);
}
if (videoModerationEnabled) {
hasStateChanged = _updatePendingParticipant(MEDIA_TYPE.VIDEO, participant, state);
}
// If the state has changed we need to return a new object reference in order to trigger subscriber updates.
if (hasStateChanged) {
return {
...state
};
}
return state;
}
case PARTICIPANT_LEFT: {
const participant = action.participant;
const { audioModerationEnabled, videoModerationEnabled } = state;
let hasStateChanged = false;
// skips changing the reference of pendingAudio or pendingVideo,
// if there is no change in the elements
if (audioModerationEnabled) {
const newPendingAudio = state.pendingAudio.filter(pending => pending.id !== participant.id);
if (state.pendingAudio.length !== newPendingAudio.length) {
state.pendingAudio = newPendingAudio;
hasStateChanged = true;
}
}
if (videoModerationEnabled) {
const newPendingVideo = state.pendingVideo.filter(pending => pending.id !== participant.id);
if (state.pendingVideo.length !== newPendingVideo.length) {
state.pendingVideo = newPendingVideo;
hasStateChanged = true;
}
}
// If the state has changed we need to return a new object reference in order to trigger subscriber updates.
if (hasStateChanged) {
return {
...state
};
}
return state;
}
case DISMISS_PENDING_PARTICIPANT: {
const { id, mediaType } = action;
const { participant, mediaType } = action;
if (mediaType === MEDIA_TYPE.AUDIO) {
return {
...state,
pendingAudio: state.pendingAudio.filter(pending => pending !== id)
pendingAudio: state.pendingAudio.filter(pending => pending.id !== participant.id)
};
}
if (mediaType === MEDIA_TYPE.VIDEO) {
return {
...state,
pendingAudio: state.pendingVideo.filter(pending => pending !== id)
pendingVideo: state.pendingVideo.filter(pending => pending.id !== participant.id)
};
}

View File

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

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.
* {@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) =>
(state: Object): boolean =>
getToolbarButtons(state).includes(buttonName);
export function isToolbarButtonEnabled(buttonName: string, state: Object | Array<string>) {
const buttons = Array.isArray(state) ? state : getToolbarButtons(state);
return buttons.includes(buttonName);
}

View File

@ -79,16 +79,15 @@ export function getFirstLoadableAvatarUrl(participant: Object, store: Store<any,
/**
* Returns local participant from Redux state.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {(Participant|undefined)}
*/
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.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @param {string} id - The ID of the participant to retrieve.
@ -119,37 +117,82 @@ export function getNormalizedDisplayName(name: string) {
*/
export function getParticipantById(
stateful: Object | Function, id: string): ?Object {
const participants = _getAllParticipants(stateful);
const state = toState(stateful)['features/base/participants'];
const { local, remote } = state;
return participants.find(p => p.id === id);
return remote.get(id) || (local?.id === id ? local : undefined);
}
/**
* Returns the participant with the ID matching the passed ID or the local participant if the ID is
* undefined.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @param {string|undefined} [participantID] - An optional partipantID argument.
* @returns {Participant|undefined}
*/
export function getParticipantByIdOrUndefined(stateful: Object | Function, participantID: ?string) {
return participantID ? getParticipantById(stateful, participantID) : getLocalParticipant(stateful);
}
/**
* Returns a count of the known participants in the passed in redux state,
* excluding any fake participants.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {number}
*/
export function getParticipantCount(stateful: Object | Function) {
return getParticipants(stateful).length;
const state = toState(stateful)['features/base/participants'];
const { local, remote, fakeParticipants } = state;
return remote.size - fakeParticipants.size + (local ? 1 : 0);
}
/**
* Returns the Map with fake participants.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {Map<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,
* including fake participants.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {number}
*/
export function getParticipantCountWithFake(stateful: Object | Function) {
return _getAllParticipants(stateful).length;
const state = toState(stateful)['features/base/participants'];
const { local, remote } = state;
return remote.size + (local ? 1 : 0);
}
/**
@ -185,17 +228,6 @@ export function getParticipantDisplayName(
: 'Fellow Jitster';
}
/**
* Curried version of getParticipantDisplayName.
*
* @see {@link getParticipantDisplayName}
* @param {string} id - The ID of the participant's display name to retrieve.
* @returns {Function}
*/
export const getParticipantDisplayNameWithId = (id: string) =>
(state: Object | Function) =>
getParticipantDisplayName(state, id);
/**
* Returns the presence status of a participant associated with the passed id.
*
@ -219,64 +251,45 @@ export function getParticipantPresenceStatus(
}
/**
* Selectors for getting all known participants with fake participants filtered
* out.
* Returns true if there is at least 1 participant with screen sharing feature and false otherwise.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {boolean}
*/
export function haveParticipantWithScreenSharingFeature(stateful: Object | Function) {
return toState(stateful)['features/base/participants'].haveParticipantWithScreenSharingFeature;
}
/**
* Selectors for getting all remote participants.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {Participant[]}
* @returns {Map<string, Object>}
*/
export function getParticipants(stateful: Object | Function) {
return _getAllParticipants(stateful).filter(p => !p.isFakeParticipant);
export function getRemoteParticipants(stateful: Object | Function) {
return toState(stateful)['features/base/participants'].remote;
}
/**
* Returns the participant which has its pinned state set to truthy.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {(Participant|undefined)}
*/
export function getPinnedParticipant(stateful: Object | Function) {
return _getAllParticipants(stateful).find(p => p.pinned);
}
const state = toState(stateful)['features/base/participants'];
const { pinnedParticipant } = state;
/**
* Returns array of participants from Redux state.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @private
* @returns {Participant[]}
*/
function _getAllParticipants(stateful) {
return (
Array.isArray(stateful)
? stateful
: toState(stateful)['features/base/participants'] || []);
}
if (!pinnedParticipant) {
return undefined;
}
/**
* Returns the youtube fake participant.
* At the moment it is considered the youtube participant the only fake participant in the list.
*
* @param {(Function|Object|Participant[])} stateful - The redux state
* features/base/participants, the (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @private
* @returns {Participant}
*/
export function getYoutubeParticipant(stateful: Object | Function) {
const participants = _getAllParticipants(stateful);
return participants.filter(p => p.isFakeParticipant)[0];
return getParticipantById(stateful, pinnedParticipant);
}
/**
@ -289,6 +302,24 @@ export function isParticipantModerator(participant: Object) {
return participant?.role === PARTICIPANT_ROLE.MODERATOR;
}
/**
* Returns the dominant speaker participant.
*
* @param {(Function|Object)} stateful - The (whole) redux state or redux's
* {@code getState} function to be used to retrieve the state features/base/participants.
* @returns {Participant} - The participant from the redux store.
*/
export function getDominantSpeakerParticipant(stateful: Object | Function) {
const state = toState(stateful)['features/base/participants'];
const { dominantSpeaker } = state;
if (!dominantSpeaker) {
return undefined;
}
return getParticipantById(stateful, dominantSpeaker);
}
/**
* Returns true if all of the meeting participants are moderators.
*
@ -297,9 +328,9 @@ export function isParticipantModerator(participant: Object) {
* @returns {boolean}
*/
export function isEveryoneModerator(stateful: Object | Function) {
const participants = _getAllParticipants(stateful);
const state = toState(stateful)['features/base/participants'];
return participants.every(isParticipantModerator);
return state.everyoneIsModerator === true;
}
/**
@ -321,14 +352,15 @@ export function isIconUrl(icon: ?string | ?Object) {
* @returns {boolean}
*/
export function isLocalParticipantModerator(stateful: Object | Function) {
const state = toState(stateful);
const localParticipant = getLocalParticipant(state);
const state = toState(stateful)['features/base/participants'];
if (!localParticipant) {
const { local } = state;
if (!local) {
return false;
}
return isParticipantModerator(localParticipant);
return isParticipantModerator(local);
}
/**
@ -390,7 +422,7 @@ async function _getFirstLoadableAvatarUrl(participant, store) {
for (let i = 0; i < AVATAR_CHECKER_FUNCTIONS.length; i++) {
const url = AVATAR_CHECKER_FUNCTIONS[i](participant, store);
if (url) {
if (url !== null) {
if (AVATAR_CHECKED_URLS.has(url)) {
if (AVATAR_CHECKED_URLS.get(url)) {
return url;

View File

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

View File

@ -12,6 +12,7 @@ import {
SET_LOADABLE_AVATAR_URL
} from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
import { isParticipantModerator } from './functions';
/**
* Participant object.
@ -51,6 +52,16 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
'pinned'
];
const DEFAULT_STATE = {
haveParticipantWithScreenSharingFeature: false,
dominantSpeaker: undefined,
everyoneIsModerator: false,
pinnedParticipant: undefined,
local: undefined,
remote: new Map(),
fakeParticipants: new Map()
};
/**
* Listen for actions which add, remove, or update the set of participants in
* the conference.
@ -62,18 +73,157 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
* added/removed/modified.
* @returns {Participant[]}
*/
ReducerRegistry.register('features/base/participants', (state = [], action) => {
ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, action) => {
switch (action.type) {
case PARTICIPANT_ID_CHANGED: {
const { local } = state;
if (local) {
state.local = {
...local,
id: action.newValue
};
return {
...state
};
}
return state;
}
case DOMINANT_SPEAKER_CHANGED: {
const { participant } = action;
const { id } = participant;
const { dominantSpeaker } = state;
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
_updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
}
if (_updateParticipantProperty(state, id, 'dominantSpeaker', true)) {
return {
...state,
dominantSpeaker: id
};
}
delete state.dominantSpeaker;
return {
...state
};
}
case PIN_PARTICIPANT: {
const { participant } = action;
const { id } = participant;
const { pinnedParticipant } = state;
// Only one pinned participant is allowed.
if (pinnedParticipant) {
_updateParticipantProperty(state, pinnedParticipant, 'pinned', false);
}
if (_updateParticipantProperty(state, id, 'pinned', true)) {
return {
...state,
pinnedParticipant: id
};
}
delete state.pinnedParticipant;
return {
...state
};
}
case SET_LOADABLE_AVATAR_URL:
case DOMINANT_SPEAKER_CHANGED:
case PARTICIPANT_ID_CHANGED:
case PARTICIPANT_UPDATED:
case PIN_PARTICIPANT:
return state.map(p => _participant(p, action));
case PARTICIPANT_UPDATED: {
const { participant } = action;
let { id } = participant;
const { local } = participant;
case PARTICIPANT_JOINED:
return [ ...state, _participantJoined(action) ];
if (!id && local) {
id = LOCAL_PARTICIPANT_DEFAULT_ID;
}
let newParticipant;
if (state.remote.has(id)) {
newParticipant = _participant(state.remote.get(id), action);
state.remote.set(id, newParticipant);
} else if (id === state.local?.id) {
newParticipant = state.local = _participant(state.local, action);
}
if (newParticipant) {
// everyoneIsModerator calculation:
const isModerator = isParticipantModerator(newParticipant);
if (state.everyoneIsModerator && !isModerator) {
state.everyoneIsModerator = false;
} else if (!state.everyoneIsModerator && isModerator) {
state.everyoneIsModerator = _isEveryoneModerator(state);
}
// haveParticipantWithScreenSharingFeature calculation:
const { features = {} } = participant;
// Currently we use only PARTICIPANT_UPDATED to set a feature to enabled and we never disable it.
if (String(features['screen-sharing']) === 'true') {
state.haveParticipantWithScreenSharingFeature = true;
}
}
return {
...state
};
}
case PARTICIPANT_JOINED: {
const participant = _participantJoined(action);
const { pinnedParticipant, dominantSpeaker } = state;
if (participant.pinned) {
if (pinnedParticipant) {
_updateParticipantProperty(state, pinnedParticipant, 'pinned', false);
}
state.pinnedParticipant = participant.id;
}
if (participant.dominantSpeaker) {
if (dominantSpeaker) {
_updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
}
state.dominantSpeaker = participant.id;
}
const isModerator = isParticipantModerator(participant);
const { local, remote } = state;
if (state.everyoneIsModerator && !isModerator) {
state.everyoneIsModerator = false;
} else if (!local && remote.size === 0 && isModerator) {
state.everyoneIsModerator = true;
}
if (participant.local) {
return {
...state,
local: participant
};
}
state.remote.set(participant.id, participant);
if (participant.isFakeParticipant) {
state.fakeParticipants.set(participant.id, participant);
}
return { ...state };
}
case PARTICIPANT_LEFT: {
// XXX A remote participant is uniquely identified by their id in a
// specific JitsiConference instance. The local participant is uniquely
@ -81,23 +231,111 @@ ReducerRegistry.register('features/base/participants', (state = [], action) => {
// (and the fact that the local participant "joins" at the beginning of
// the app and "leaves" at the end of the app).
const { conference, id } = action.participant;
const { fakeParticipants, remote, local, dominantSpeaker, pinnedParticipant } = state;
let oldParticipant = remote.get(id);
return state.filter(p =>
!(
p.id === id
if (oldParticipant && oldParticipant.conference === conference) {
remote.delete(id);
} else if (local?.id === id) {
oldParticipant = state.local;
delete state.local;
} else {
// no participant found
return state;
}
// XXX Do not allow collisions in the IDs of the local
// participant and a remote participant cause the removal of
// the local participant when the remote participant's
// removal is requested.
&& p.conference === conference
&& (conference || p.local)));
if (!state.everyoneIsModerator && !isParticipantModerator(oldParticipant)) {
state.everyoneIsModerator = _isEveryoneModerator(state);
}
const { features = {} } = oldParticipant || {};
if (state.haveParticipantWithScreenSharingFeature && String(features['screen-sharing']) === 'true') {
const { features: localFeatures = {} } = state.local || {};
if (String(localFeatures['screen-sharing']) !== 'true') {
state.haveParticipantWithScreenSharingFeature = false;
// eslint-disable-next-line no-unused-vars
for (const [ key, participant ] of state.remote) {
const { features: f = {} } = participant;
if (String(f['screen-sharing']) === 'true') {
state.haveParticipantWithScreenSharingFeature = true;
break;
}
}
}
}
if (dominantSpeaker === id) {
state.dominantSpeaker = undefined;
}
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;
}
if (fakeParticipants.has(id)) {
fakeParticipants.delete(id);
}
return { ...state };
}
}
return state;
});
/**
* Loops trough the participants in the state in order to check if all participants are moderators.
*
* @param {Object} state - The local participant redux state.
* @returns {boolean}
*/
function _isEveryoneModerator(state) {
if (isParticipantModerator(state.local)) {
// eslint-disable-next-line no-unused-vars
for (const [ k, p ] of state.remote) {
if (!isParticipantModerator(p)) {
return false;
}
}
return true;
}
return false;
}
/**
* Updates a specific property for a participant.
*
* @param {State} state - The redux state.
* @param {string} id - The ID of the participant.
* @param {string} property - The property to update.
* @param {*} value - The new value.
* @returns {boolean} - True if a participant was updated and false otherwise.
*/
function _updateParticipantProperty(state, id, property, value) {
const { remote, local } = state;
if (remote.has(id)) {
remote.set(id, set(remote.get(id), property, value));
return true;
} else if (local?.id === id) {
state.local = set(local, property, value);
return true;
}
return false;
}
/**
* Reducer function for a single participant.
*
@ -112,38 +350,10 @@ ReducerRegistry.register('features/base/participants', (state = [], action) => {
*/
function _participant(state: Object = {}, action) {
switch (action.type) {
case DOMINANT_SPEAKER_CHANGED:
// Only one dominant speaker is allowed.
return (
set(state, 'dominantSpeaker', state.id === action.participant.id));
case PARTICIPANT_ID_CHANGED: {
// A participant is identified by an id-conference pair. Only the local
// participant is with an undefined conference.
const { conference } = action;
if (state.id === action.oldValue
&& state.conference === conference
&& (conference || state.local)) {
return {
...state,
id: action.newValue
};
}
break;
}
case SET_LOADABLE_AVATAR_URL:
case PARTICIPANT_UPDATED: {
const { participant } = action; // eslint-disable-line no-shadow
let { id } = participant;
const { local } = participant;
if (!id && local) {
id = LOCAL_PARTICIPANT_DEFAULT_ID;
}
if (state.id === id) {
const newState = { ...state };
for (const key in participant) {
@ -156,12 +366,6 @@ function _participant(state: Object = {}, action) {
return newState;
}
break;
}
case PIN_PARTICIPANT:
// Currently, only one pinned participant is allowed.
return set(state, 'pinned', state.id === action.participant.id);
}
return state;

View File

@ -21,24 +21,16 @@ import logger from './logger';
export const getTrackState = state => state['features/base/tracks'];
/**
* Higher-order function that returns a selector for a specific participant
* and media type.
* Checks if the passed media type is muted for the participant.
*
* @param {Object} participant - Participant reference.
* @param {MEDIA_TYPE} mediaType - Media type.
* @returns {Function} Selector.
*/
export const getIsParticipantMediaMuted = (participant, mediaType) =>
/**
* Bound selector.
*
* @param {Object} state - Global state.
* @returns {boolean} Is the media type muted for the participant.
* @returns {boolean} - Is the media type muted for the participant.
*/
state => {
export function isParticipantMediaMuted(participant, mediaType, state) {
if (!participant) {
return;
return false;
}
const tracks = getTrackState(state);
@ -50,39 +42,29 @@ export const getIsParticipantMediaMuted = (participant, mediaType) =>
}
return true;
};
}
/**
* Higher-order function that returns a selector for a specific participant.
* Checks if the participant is audio muted.
*
* @param {Object} participant - Participant reference.
* @returns {Function} Selector.
*/
export const getIsParticipantAudioMuted = participant =>
/**
* Bound selector.
*
* @param {Object} state - Global state.
* @returns {boolean} Is audio muted for the participant.
* @returns {boolean} - Is audio muted for the participant.
*/
state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.AUDIO)(state);
export function isParticipantAudioMuted(participant, state) {
return isParticipantMediaMuted(participant, MEDIA_TYPE.AUDIO, state);
}
/**
* Higher-order function that returns a selector for a specific participant.
* Checks if the participant is video muted.
*
* @param {Object} participant - Participant reference.
* @returns {Function} Selector.
*/
export const getIsParticipantVideoMuted = participant =>
/**
* Bound selector.
*
* @param {Object} state - Global state.
* @returns {boolean} Is video muted for the participant.
* @returns {boolean} - Is video muted for the participant.
*/
state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO)(state);
export function isParticipantVideoMuted(participant, state) {
return isParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO, state);
}
/**
* Creates a local video track for presenter. The constraints are computed based

View File

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

View File

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

View File

@ -6,3 +6,22 @@
* }
*/
export const TOGGLE_E2EE = 'TOGGLE_E2EE';
/**
* The type of the action which signals to set new value whether everyone has E2EE enabled.
*
* {
* type: SET_EVERYONE_ENABLED_E2EE,
* everyoneEnabledE2EE: boolean
* }
*/
export const SET_EVERYONE_ENABLED_E2EE = 'SET_EVERYONE_ENABLED_E2EE';
/**
* The type of the action which signals to set new value whether everyone supports E2EE.
*
* {
* type: SET_EVERYONE_SUPPORT_E2EE
* }
*/
export const SET_EVERYONE_SUPPORT_E2EE = 'SET_EVERYONE_SUPPORT_E2EE';

View File

@ -1,6 +1,6 @@
// @flow
import { TOGGLE_E2EE } from './actionTypes';
import { SET_EVERYONE_ENABLED_E2EE, SET_EVERYONE_SUPPORT_E2EE, TOGGLE_E2EE } from './actionTypes';
/**
* Dispatches an action to enable / disable E2EE.
@ -14,3 +14,35 @@ export function toggleE2EE(enabled: boolean) {
enabled
};
}
/**
* Set new value whether everyone has E2EE enabled.
*
* @param {boolean} everyoneEnabledE2EE - The new value.
* @returns {{
* type: SET_EVERYONE_ENABLED_E2EE,
* everyoneEnabledE2EE: boolean
* }}
*/
export function setEveryoneEnabledE2EE(everyoneEnabledE2EE: boolean) {
return {
type: SET_EVERYONE_ENABLED_E2EE,
everyoneEnabledE2EE
};
}
/**
* Set new value whether everyone support E2EE.
*
* @param {boolean} everyoneSupportE2EE - The new value.
* @returns {{
* type: SET_EVERYONE_SUPPORT_E2EE,
* everyoneSupportE2EE: boolean
* }}
*/
export function setEveryoneSupportE2EE(everyoneSupportE2EE: boolean) {
return {
type: SET_EVERYONE_SUPPORT_E2EE,
everyoneSupportE2EE
};
}

View File

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

View File

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

View File

@ -1,13 +1,24 @@
// @flow
import { batch } from 'react-redux';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app';
import { getCurrentConference } from '../base/conference';
import { getLocalParticipant, participantUpdated } from '../base/participants';
import {
getLocalParticipant,
getParticipantById,
getParticipantCount,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
participantUpdated,
getRemoteParticipants
} from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { TOGGLE_E2EE } from './actionTypes';
import { toggleE2EE } from './actions';
import { setEveryoneEnabledE2EE, setEveryoneSupportE2EE, toggleE2EE } from './actions';
import { E2EE_OFF_SOUND_ID, E2EE_ON_SOUND_ID } from './constants';
import logger from './logger';
import { E2EE_OFF_SOUND_FILE, E2EE_ON_SOUND_FILE } from './sounds';
@ -35,6 +46,128 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
dispatch(unregisterSound(E2EE_ON_SOUND_ID));
break;
case PARTICIPANT_UPDATED: {
const { id, e2eeEnabled, e2eeSupported } = action.participant;
const oldParticipant = getParticipantById(getState(), id);
const result = next(action);
if (e2eeEnabled !== oldParticipant?.e2eeEnabled
|| e2eeSupported !== oldParticipant?.e2eeSupported) {
const state = getState();
let newEveryoneSupportE2EE = true;
let newEveryoneEnabledE2EE = true;
// eslint-disable-next-line no-unused-vars
for (const [ key, p ] of getRemoteParticipants(state)) {
if (!p.e2eeEnabled) {
newEveryoneEnabledE2EE = false;
}
if (!p.e2eeSupported) {
newEveryoneSupportE2EE = false;
}
if (!newEveryoneEnabledE2EE && !newEveryoneSupportE2EE) {
break;
}
}
if (!getLocalParticipant(state)?.e2eeEnabled) {
newEveryoneEnabledE2EE = false;
}
batch(() => {
dispatch(setEveryoneEnabledE2EE(newEveryoneEnabledE2EE));
dispatch(setEveryoneSupportE2EE(newEveryoneSupportE2EE));
});
}
return result;
}
case PARTICIPANT_JOINED: {
const result = next(action);
const { e2eeEnabled, e2eeSupported, local } = action.participant;
const { everyoneEnabledE2EE } = getState()['features/e2ee'];
const participantCount = getParticipantCount(getState());
// the initial values
if (participantCount === 1) {
batch(() => {
dispatch(setEveryoneEnabledE2EE(e2eeEnabled));
dispatch(setEveryoneSupportE2EE(e2eeSupported));
});
}
// if all had it enabled and this one disabled it, change value in store
// otherwise there is no change in the value we store
if (everyoneEnabledE2EE && !e2eeEnabled) {
dispatch(setEveryoneEnabledE2EE(false));
}
if (local) {
return result;
}
const { everyoneSupportE2EE } = getState()['features/e2ee'];
// if all supported it and this one does not, change value in store
// otherwise there is no change in the value we store
if (everyoneSupportE2EE && !e2eeSupported) {
dispatch(setEveryoneSupportE2EE(false));
}
return result;
}
case PARTICIPANT_LEFT: {
const previosState = getState();
const participant = getParticipantById(previosState, action.participant?.id) || {};
const result = next(action);
const newState = getState();
const { e2eeEnabled = false, e2eeSupported = false } = participant;
const { everyoneEnabledE2EE, everyoneSupportE2EE } = newState['features/e2ee'];
// if it was not enabled by everyone, and the participant leaving had it disabled, or if it was not supported
// by everyone, and the participant leaving had it not supported let's check is it enabled for all that stay
if ((!everyoneEnabledE2EE && !e2eeEnabled) || (!everyoneSupportE2EE && !e2eeSupported)) {
let latestEveryoneEnabledE2EE = true;
let latestEveryoneSupportE2EE = true;
// eslint-disable-next-line no-unused-vars
for (const [ key, p ] of getRemoteParticipants(newState)) {
if (!p.e2eeEnabled) {
latestEveryoneEnabledE2EE = false;
}
if (!p.e2eeSupported) {
latestEveryoneSupportE2EE = false;
}
if (!latestEveryoneEnabledE2EE && !latestEveryoneSupportE2EE) {
break;
}
}
if (!getLocalParticipant(newState)?.e2eeEnabled) {
latestEveryoneEnabledE2EE = false;
}
batch(() => {
if (!everyoneEnabledE2EE && latestEveryoneEnabledE2EE) {
dispatch(setEveryoneEnabledE2EE(true));
}
if (!everyoneSupportE2EE && latestEveryoneSupportE2EE) {
dispatch(setEveryoneSupportE2EE(true));
}
});
}
return result;
}
case TOGGLE_E2EE: {
const conference = getCurrentConference(getState);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,11 +6,17 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions';
import { isEnabled as isAvModerationEnabled } from '../../av-moderation/functions';
import {
isEnabled as isAvModerationEnabled,
isSupported as isAvModerationSupported
} from '../../av-moderation/functions';
import { openDialog } from '../../base/dialog';
import { Icon, IconCheck, IconVideoOff } from '../../base/icons';
import { MEDIA_TYPE } from '../../base/media';
import { getLocalParticipant } from '../../base/participants';
import {
getLocalParticipant,
isEveryoneModerator
} from '../../base/participants';
import { MuteEveryonesVideoDialog } from '../../video-menu/components';
import {
@ -49,6 +55,8 @@ type Props = {
export const FooterContextMenu = ({ onMouseLeave }: Props) => {
const dispatch = useDispatch();
const isModerationSupported = useSelector(isAvModerationSupported());
const allModerators = useSelector(isEveryoneModerator);
const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
const { id } = useSelector(getLocalParticipant);
const { t } = useTranslation();
@ -75,6 +83,8 @@ export const FooterContextMenu = ({ onMouseLeave }: Props) => {
<span>{ t('participantsPane.actions.stopEveryonesVideo') }</span>
</ContextMenuItem>
{ isModerationSupported && !allModerators ? (
<>
<div className = { classes.text }>
{t('participantsPane.actions.allow')}
</div>
@ -96,6 +106,9 @@ export const FooterContextMenu = ({ onMouseLeave }: Props) => {
<span>{ t('participantsPane.actions.startModeration') }</span>
</ContextMenuItem>
)}
</>
) : undefined
}
</ContextMenu>
);
};

View File

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

View File

@ -1,11 +1,10 @@
// @flow
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import React, { Component } from 'react';
import { isToolbarButtonEnabled } from '../../base/config/functions.web';
import { openDialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
import {
IconCloseCircle,
IconCrown,
@ -14,8 +13,13 @@ import {
IconMuteEveryoneElse,
IconVideoOff
} from '../../base/icons';
import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants';
import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
import {
getParticipantByIdOrUndefined,
isLocalParticipantModerator,
isParticipantModerator
} from '../../base/participants';
import { connect } from '../../base/redux';
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks';
import { openChat } from '../../chat/actions';
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
@ -31,6 +35,41 @@ import {
type Props = {
/**
* True if the local participant is moderator and false otherwise.
*/
_isLocalModerator: boolean,
/**
* True if the chat button is enabled and false otherwise.
*/
_isChatButtonEnabled: boolean,
/**
* True if the participant is moderator and false otherwise.
*/
_isParticipantModerator: boolean,
/**
* True if the participant is video muted and false otherwise.
*/
_isParticipantVideoMuted: boolean,
/**
* True if the participant is audio muted and false otherwise.
*/
_isParticipantAudioMuted: boolean,
/**
* Participant reference
*/
_participant: Object,
/**
* The dispatch function from redux.
*/
dispatch: Function,
/**
* Callback used to open a confirmation dialog for audio muting.
*/
@ -57,35 +96,145 @@ type Props = {
onSelect: Function,
/**
* Participant reference
* The ID of the participant.
*/
participant: Object
participantID: string,
/**
* The translate function.
*/
t: Function
};
export const MeetingParticipantContextMenu = ({
offsetTarget,
onEnter,
onLeave,
onSelect,
muteAudio,
participant
}: Props) => {
const dispatch = useDispatch();
const containerRef = useRef(null);
const isLocalModerator = useSelector(isLocalParticipantModerator);
const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat'));
const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
const isParticipantAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
const [ isHidden, setIsHidden ] = useState(true);
const { t } = useTranslation();
type State = {
useLayoutEffect(() => {
if (participant
&& containerRef.current
/**
* If true the context menu will be hidden.
*/
isHidden: boolean
};
/**
* Implements the MeetingParticipantContextMenu component.
*/
class MeetingParticipantContextMenu extends Component<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 instanceof HTMLElement
) {
const { current: container } = containerRef;
const { current: container } = this._containerRef;
const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget;
const outerHeight = getComputedOuterHeight(container);
@ -93,97 +242,158 @@ export const MeetingParticipantContextMenu = ({
? offsetTop - outerHeight
: offsetTop;
setIsHidden(false);
this.setState({ isHidden: false });
} else {
setIsHidden(true);
this.setState({ isHidden: true });
}
}
}, [ participant, offsetTarget ]);
const grantModerator = useCallback(() => {
dispatch(openDialog(GrantModeratorDialog, {
participantID: participant.id
}));
}, [ dispatch, participant ]);
/**
* Implements React Component's componentDidMount.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
this._position();
}
const kick = useCallback(() => {
dispatch(openDialog(KickRemoteParticipantDialog, {
participantID: participant.id
}));
}, [ dispatch, participant ]);
/**
* Implements React Component's componentDidUpdate.
*
* @inheritdoc
*/
componentDidUpdate(prevProps: Props) {
if (prevProps.offsetTarget !== this.props.offsetTarget || prevProps._participant !== this.props._participant) {
this._position();
}
}
const muteEveryoneElse = useCallback(() => {
dispatch(openDialog(MuteEveryoneDialog, {
exclude: [ participant.id ]
}));
}, [ dispatch, participant ]);
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
_isLocalModerator,
_isChatButtonEnabled,
_isParticipantModerator,
_isParticipantVideoMuted,
_isParticipantAudioMuted,
_participant,
onEnter,
onLeave,
onSelect,
muteAudio,
t
} = this.props;
const muteVideo = useCallback(() => {
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
participantID: participant.id
}));
}, [ dispatch, participant ]);
const sendPrivateMessage = useCallback(() => {
dispatch(openChat(participant));
}, [ dispatch, participant ]);
if (!participant) {
if (!_participant) {
return null;
}
return (
<ContextMenu
className = { ignoredChildClassName }
innerRef = { containerRef }
isHidden = { isHidden }
innerRef = { this._containerRef }
isHidden = { this.state.isHidden }
onClick = { onSelect }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
<ContextMenuItemGroup>
{isLocalModerator && (
{
_isLocalModerator && (
<>
{!isParticipantAudioMuted
&& <ContextMenuItem onClick = { muteAudio(participant) }>
{
!_isParticipantAudioMuted
&& <ContextMenuItem onClick = { muteAudio(_participant) }>
<ContextMenuIcon src = { IconMicDisabled } />
<span>{t('dialog.muteParticipantButton')}</span>
</ContextMenuItem>}
</ContextMenuItem>
}
<ContextMenuItem onClick = { muteEveryoneElse }>
<ContextMenuItem onClick = { this._onMuteEveryoneElse }>
<ContextMenuIcon src = { IconMuteEveryoneElse } />
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
</ContextMenuItem>
</>
)}
)
}
{isLocalModerator && (isParticipantVideoMuted || (
<ContextMenuItem onClick = { muteVideo }>
{
_isLocalModerator && (
_isParticipantVideoMuted || (
<ContextMenuItem onClick = { this._onMuteVideo }>
<ContextMenuIcon src = { IconVideoOff } />
<span>{t('participantsPane.actions.stopVideo')}</span>
</ContextMenuItem>
))}
)
)
}
</ContextMenuItemGroup>
<ContextMenuItemGroup>
{isLocalModerator && (
{
_isLocalModerator && (
<>
{!isParticipantModerator(participant)
&& <ContextMenuItem onClick = { grantModerator }>
{
!_isParticipantModerator && (
<ContextMenuItem onClick = { this._onGrantModerator }>
<ContextMenuIcon src = { IconCrown } />
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
</ContextMenuItem>}
<ContextMenuItem onClick = { kick }>
</ContextMenuItem>
)
}
<ContextMenuItem onClick = { this._onKick }>
<ContextMenuIcon src = { IconCloseCircle } />
<span>{t('videothumbnail.kick')}</span>
<span>{ t('videothumbnail.kick') }</span>
</ContextMenuItem>
</>
)}
{isChatButtonEnabled && (
<ContextMenuItem onClick = { sendPrivateMessage }>
)
}
{
_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
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
import { getParticipantAudioMediaState } from '../functions';
import { getParticipantByIdOrUndefined, getParticipantDisplayName } from '../../base/participants';
import { connect } from '../../base/redux';
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../base/tracks';
import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../constants';
import { getParticipantAudioMediaState, getQuickActionButtonType } from '../functions';
import { ParticipantItem } from './ParticipantItem';
import ParticipantItem from './ParticipantItem';
import ParticipantQuickAction from './ParticipantQuickAction';
import { ParticipantActionEllipsis } from './styled';
type Props = {
/**
* Media state for audio.
*/
_audioMediaState: MediaState,
/**
* The display name of the participant.
*/
_displayName: string,
/**
* True if the participant is video muted.
*/
_isVideoMuted: boolean,
/**
* True if the participant is the local participant.
*/
_local: boolean,
/**
* The participant ID.
*
* NOTE: This ID may be different from participantID prop in the case when we pass undefined for the local
* participant. In this case the local participant ID will be filled trough _participantID prop.
*/
_participantID: string,
/**
* The type of button to be rendered for the quick action.
*/
_quickActionButtonType: string,
/**
* True if the participant have raised hand.
*/
_raisedHand: boolean,
/**
* The translated ask unmute text for the qiuck action buttons.
*/
askUnmuteText: string,
/**
* Is this item highlighted
*/
@ -24,6 +67,11 @@ type Props = {
*/
muteAudio: Function,
/**
* The translated text for the mute participant button.
*/
muteParticipantButtonText: string,
/**
* Callback for the activation of this item's context menu
*/
@ -35,38 +83,97 @@ type Props = {
onLeave: Function,
/**
* Participant reference
* The aria-label for the ellipsis action.
*/
participant: Object
participantActionEllipsisLabel: string,
/**
* The ID of the participant.
*/
participantID: ?string,
/**
* The translated "you" text.
*/
youText: string
};
export const MeetingParticipantItem = ({
/**
* Implements the MeetingParticipantItem component.
*
* @param {Props} props - The props of the component.
* @returns {ReactElement}
*/
function MeetingParticipantItem({
_audioMediaState,
_displayName,
_isVideoMuted,
_local,
_participantID,
_quickActionButtonType,
_raisedHand,
askUnmuteText,
isHighlighted,
onContextMenu,
onLeave,
muteAudio,
participant
}: Props) => {
const { t } = useTranslation();
const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
const audioMediaState = useSelector(getParticipantAudioMediaState(participant, isAudioMuted));
muteParticipantButtonText,
participantActionEllipsisLabel,
youText
}: Props) {
return (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMediaState = { audioMediaState }
audioMediaState = { _audioMediaState }
displayName = { _displayName }
isHighlighted = { isHighlighted }
local = { _local }
onLeave = { onLeave }
participant = { participant }
videoMuteState = { isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED }>
participantID = { _participantID }
raisedHand = { _raisedHand }
videoMuteState = { _isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED }
youText = { youText }>
<ParticipantQuickAction
isAudioMuted = { isAudioMuted }
askUnmuteText = { askUnmuteText }
buttonType = { _quickActionButtonType }
muteAudio = { muteAudio }
participant = { participant } />
muteParticipantButtonText = { muteParticipantButtonText }
participantID = { _participantID } />
<ParticipantActionEllipsis
aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') }
aria-label = { participantActionEllipsisLabel }
onClick = { onContextMenu } />
</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
import _ from 'lodash';
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector, useDispatch } from 'react-redux';
import { openDialog } from '../../base/dialog';
import { getParticipants } from '../../base/participants';
import {
getLocalParticipant,
getParticipantCountWithFake,
getRemoteParticipants
} from '../../base/participants';
import MuteRemoteParticipantDialog from '../../video-menu/components/web/MuteRemoteParticipantDialog';
import { findStyledAncestor, shouldRenderInviteButton } from '../functions';
import { InviteButton } from './InviteButton';
import { MeetingParticipantContextMenu } from './MeetingParticipantContextMenu';
import { MeetingParticipantItem } from './MeetingParticipantItem';
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
import MeetingParticipantItem from './MeetingParticipantItem';
import { Heading, ParticipantContainer } from './styled';
type NullProto = {
@ -20,7 +23,7 @@ type NullProto = {
__proto__: null
};
type RaiseContext = NullProto | {
type RaiseContext = NullProto | {|
/**
* Target elements against which positioning calculations are made
@ -28,17 +31,28 @@ type RaiseContext = NullProto | {
offsetTarget?: HTMLElement,
/**
* Participant reference
* The ID of the participant.
*/
participant?: Object,
};
participantID?: String,
|};
const initialState = Object.freeze(Object.create(null));
export const MeetingParticipantList = () => {
/**
* Renders the MeetingParticipantList component.
*
* @returns {ReactNode} - The component.
*/
export function MeetingParticipantList() {
const dispatch = useDispatch();
const isMouseOverMenu = useRef(false);
const participants = useSelector(getParticipants, _.isEqual);
const participants = useSelector(getRemoteParticipants);
const localParticipant = useSelector(getLocalParticipant);
// This is very important as getRemoteParticipants is not changing its reference object
// and we will not re-render on change, but if count changes we will do
const participantsCount = useSelector(getParticipantCountWithFake);
const showInviteButton = useSelector(shouldRenderInviteButton);
const [ raiseContext, setRaiseContext ] = useState<RaiseContext>(initialState);
const { t } = useTranslation();
@ -61,20 +75,20 @@ export const MeetingParticipantList = () => {
});
}, [ raiseContext ]);
const raiseMenu = useCallback((participant, target) => {
const raiseMenu = useCallback((participantID, target) => {
setRaiseContext({
participant,
participantID,
offsetTarget: findStyledAncestor(target, ParticipantContainer)
});
}, [ raiseContext ]);
const toggleMenu = useCallback(participant => e => {
const { participant: raisedParticipant } = raiseContext;
const toggleMenu = useCallback(participantID => e => {
const { participantID: raisedParticipant } = raiseContext;
if (raisedParticipant && raisedParticipant === participant) {
if (raisedParticipant && raisedParticipant === participantID) {
lowerMenu();
} else {
raiseMenu(participant, e.target);
raiseMenu(participantID, e.target);
}
}, [ raiseContext ]);
@ -91,20 +105,44 @@ export const MeetingParticipantList = () => {
dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id }));
});
// FIXME:
// It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is
// taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
// solution!!!
// One potential proper fix would be to use react-window component in order to lower the number of components
// mounted.
const participantActionEllipsisLabel = t('MeetingParticipantItem.ParticipantActionEllipsis.options');
const youText = t('chat.you');
const askUnmuteText = t('participantsPane.actions.askUnmute');
const muteParticipantButtonText = t('dialog.muteParticipantButton');
const renderParticipant = id => (
<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 (
<>
<Heading>{t('participantsPane.headings.participantsList', { count: participants.length })}</Heading>
<Heading>{t('participantsPane.headings.participantsList', { count: participantsCount })}</Heading>
{showInviteButton && <InviteButton />}
<div>
{participants.map(p => (
<MeetingParticipantItem
isHighlighted = { raiseContext.participant === p }
key = { p.id }
muteAudio = { muteAudio }
onContextMenu = { toggleMenu(p) }
onLeave = { lowerMenu }
participant = { p } />
))}
{ items }
</div>
<MeetingParticipantContextMenu
muteAudio = { muteAudio }
@ -114,4 +152,4 @@ export const MeetingParticipantList = () => {
{ ...raiseContext } />
</>
);
};
}

View File

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

View File

@ -1,11 +1,8 @@
// @flow
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { QUICK_ACTION_BUTTON } from '../constants';
import { getQuickActionButtonType } from '../functions';
import AskToUnmuteButton from './AskToUnmuteButton';
import { QuickActionButton } from './styled';
@ -13,19 +10,26 @@ import { QuickActionButton } from './styled';
type Props = {
/**
* If audio is muted for the current participant.
* The translated "ask unmute" text.
*/
isAudioMuted: Boolean,
askUnmuteText: string,
/**
* The type of button to be displayed.
*/
buttonType: string,
/**
* Callback used to open a confirmation dialog for audio muting.
*/
muteAudio: Function,
muteParticipantButtonText: string,
/**
* Participant.
* The ID of the participant.
*/
participant: Object,
participantID: string,
}
/**
@ -34,23 +38,29 @@ type Props = {
* @param {Props} props - The props of the component.
* @returns {React$Element<'button'>}
*/
export default function({ isAudioMuted, muteAudio, participant }: Props) {
const buttonType = useSelector(getQuickActionButtonType(participant, isAudioMuted));
const { id } = participant;
const { t } = useTranslation();
export default function ParticipantQuickAction({
askUnmuteText,
buttonType,
muteAudio,
muteParticipantButtonText,
participantID
}: Props) {
switch (buttonType) {
case QUICK_ACTION_BUTTON.MUTE: {
return (
<QuickActionButton
onClick = { muteAudio(id) }
onClick = { muteAudio(participantID) }
primary = { true }>
{t('dialog.muteParticipantButton')}
{ muteParticipantButtonText }
</QuickActionButton>
);
}
case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
return <AskToUnmuteButton id = { id } />;
return (
<AskToUnmuteButton
askUnmuteText = { askUnmuteText }
id = { participantID } />
);
}
default: {
return null;

View File

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

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

View File

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

View File

@ -1,6 +1,6 @@
// @flow
import { getParticipants } from '../base/participants';
import { getFakeParticipants } from '../base/participants';
import { VIDEO_PLAYER_PARTICIPANT_NAME, YOUTUBE_PLAYER_PARTICIPANT_NAME } from './constants';
@ -41,7 +41,15 @@ export function isSharingStatus(status: string) {
* @returns {boolean}
*/
export function isVideoPlaying(stateful: Object | Function): boolean {
return Boolean(getParticipants(stateful).find(p => p.isFakeParticipant
&& (p.name === VIDEO_PLAYER_PARTICIPANT_NAME || p.name === YOUTUBE_PLAYER_PARTICIPANT_NAME))
);
let videoPlaying = false;
// eslint-disable-next-line no-unused-vars
for (const [ id, p ] of getFakeParticipants(stateful)) {
if (p.name === VIDEO_PLAYER_PARTICIPANT_NAME || p.name === YOUTUBE_PLAYER_PARTICIPANT_NAME) {
videoPlaying = true;
break;
}
}
return videoPlaying;
}

View File

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

View File

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

View File

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

View File

@ -5,7 +5,8 @@ import { getFeatureFlag, TILE_VIEW_ENABLED } from '../base/flags';
import {
getPinnedParticipant,
getParticipantCount,
pinParticipant
pinParticipant,
getParticipantCountWithFake
} from '../base/participants';
import {
ASPECT_RATIO_BREAKPOINT,
@ -101,7 +102,7 @@ export function getTileViewGridDimensions(state: Object) {
// When in tile view mode, we must discount ourselves (the local participant) because our
// tile is not visible.
const { iAmRecorder } = state['features/base/config'];
const numberOfParticipants = state['features/base/participants'].length - (iAmRecorder ? 1 : 0);
const numberOfParticipants = getParticipantCountWithFake(state) - (iAmRecorder ? 1 : 0);
const columnsToMaintainASquare = Math.ceil(Math.sqrt(numberOfParticipants));
const columns = Math.min(columnsToMaintainASquare, maxColumns);

View File

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