init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
/**
* Create an action to mark the participant as notified to speak next.
*
* {
* type: NOTIFIED_TO_SPEAK
* }
*/
export const NOTIFIED_TO_SPEAK = 'NOTIFIED_TO_SPEAK';
/**
* Create an action for when dominant speaker changes.
*
* {
* type: DOMINANT_SPEAKER_CHANGED,
* participant: {
* conference: JitsiConference,
* id: string,
* previousSpeakers: Array<string>,
* silence: boolean
* }
* }
*/
export const DOMINANT_SPEAKER_CHANGED = 'DOMINANT_SPEAKER_CHANGED';
/**
* Create an action for granting moderator to a participant.
*
* {
* type: GRANT_MODERATOR,
* id: string
* }
*/
export const GRANT_MODERATOR = 'GRANT_MODERATOR';
/**
* Create an action for removing a participant from the conference.
*
* {
* type: KICK_PARTICIPANT,
* id: string
* }
*/
export const KICK_PARTICIPANT = 'KICK_PARTICIPANT';
/**
* Create an action for muting a remote participant.
*
* {
* type: MUTE_REMOTE_PARTICIPANT,
* id: string
* }
*/
export const MUTE_REMOTE_PARTICIPANT = 'MUTE_REMOTE_PARTICIPANT';
/**
* Action to signal that ID of participant has changed. This happens when
* local participant joins a new conference or quits one.
*
* {
* type: PARTICIPANT_ID_CHANGED,
* conference: JitsiConference
* newValue: string,
* oldValue: string
* }
*/
export const PARTICIPANT_ID_CHANGED = 'PARTICIPANT_ID_CHANGED';
/**
* Action to signal that participant role has changed. e.
*
* {
* type: PARTICIPANT_ROLE_CHANGED,
* participant: {
* id: string
* }
* role: string
* }
*/
export const PARTICIPANT_ROLE_CHANGED = 'PARTICIPANT_ROLE_CHANGED';
/**
* Action to signal that a participant has joined.
*
* {
* type: PARTICIPANT_JOINED,
* participant: Participant
* }
*/
export const PARTICIPANT_JOINED = 'PARTICIPANT_JOINED';
/**
* Action to signal that a participant has been removed from a conference by
* another participant.
*
* {
* type: PARTICIPANT_KICKED,
* kicked: Object,
* kicker: Object
* }
*/
export const PARTICIPANT_KICKED = 'PARTICIPANT_KICKED';
/**
* Action to handle case when participant lefts.
*
* {
* type: PARTICIPANT_LEFT,
* participant: {
* id: string
* }
* }
*/
export const PARTICIPANT_LEFT = 'PARTICIPANT_LEFT';
/**
* Action to handle case when the remote participant mutes the local participant.
*
* {
* type: PARTICIPANT_MUTED_US,
* participant: Participant,
* track: JitsiLocalTrack
* }
*/
export const PARTICIPANT_MUTED_US = 'PARTICIPANT_MUTED_US';
/**
* Action to handle case when the sources attached to a participant are updated.
*
* {
* type: PARTICIPANT_SOURCES_UPDATED,
* participant: {
* id: string
* }
* }
*/
export const PARTICIPANT_SOURCES_UPDATED = 'PARTICIPANT_SOURCES_UPDATED';
/**
* Action to handle case when info about participant changes.
*
* {
* type: PARTICIPANT_UPDATED,
* participant: Participant
* }
*/
export const PARTICIPANT_UPDATED = 'PARTICIPANT_UPDATED';
/**
* The type of the Redux action which pins a conference participant.
*
* {
* type: PIN_PARTICIPANT,
* participant: {
* id: string
* }
* }
*/
export const PIN_PARTICIPANT = 'PIN_PARTICIPANT';
/**
* The type of Redux action which notifies the app that the loadable avatar URL has changed.
*
* {
* type: SET_LOADABLE_AVATAR_URL,
* participant: {
* id: string,
loadableAvatarUrl: string
* }
* }
*/
export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL';
/**
* The type of Redux action which notifies that the screenshare participant's display name has changed.
*
* {
* type: SCREENSHARE_PARTICIPANT_NAME_CHANGED,
* id: string,
* name: string
* }
*/
export const SCREENSHARE_PARTICIPANT_NAME_CHANGED = 'SCREENSHARE_PARTICIPANT_NAME_CHANGED';
/**
* Raises hand for the local participant.
* {
* type: LOCAL_PARTICIPANT_RAISE_HAND
* }
*/
export const LOCAL_PARTICIPANT_RAISE_HAND = 'LOCAL_PARTICIPANT_RAISE_HAND';
/**
* Clear the raise hand queue.
* {
* type: RAISE_HAND_CLEAR
* }
*/
export const RAISE_HAND_CLEAR = 'RAISE_HAND_CLEAR';
/**
* Updates participant in raise hand queue.
* {
* type: RAISE_HAND_UPDATED,
* participant: {
* id: string,
* raiseHand: boolean
* }
* }
*/
export const RAISE_HAND_UPDATED = 'RAISE_HAND_UPDATED';
/**
* The type of Redux action which notifies that the local participant has changed the audio levels.
* {
* type: LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED
* level: number
* }
*/
export const LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED = 'LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED'
/**
* The type of Redux action which overwrites the name of a participant.
* {
* type: OVERWRITE_PARTICIPANT_NAME,
* id: string,
* name: string
* }
*/
export const OVERWRITE_PARTICIPANT_NAME = 'OVERWRITE_PARTICIPANT_NAME';
/**
* The type of Redux action which overwrites the names of multiple participants.
* {
* type: OVERWRITE_PARTICIPANTS_NAMES,
* participantsList: Array<Object>,
* }
*/
export const OVERWRITE_PARTICIPANTS_NAMES = 'OVERWRITE_PARTICIPANTS_NAMES';
/**
* Updates participants local recording status.
* {
* type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
* recording: boolean,
* onlySelf: boolean
* }
*/
export const SET_LOCAL_PARTICIPANT_RECORDING_STATUS = 'SET_LOCAL_PARTICIPANT_RECORDING_STATUS';

View File

@@ -0,0 +1,689 @@
import { IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { IJitsiConference } from '../conference/reducer';
import { set } from '../redux/functions';
import {
DOMINANT_SPEAKER_CHANGED,
GRANT_MODERATOR,
KICK_PARTICIPANT,
LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT,
OVERWRITE_PARTICIPANTS_NAMES,
OVERWRITE_PARTICIPANT_NAME,
PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_KICKED,
PARTICIPANT_LEFT,
PARTICIPANT_MUTED_US,
PARTICIPANT_ROLE_CHANGED,
PARTICIPANT_SOURCES_UPDATED,
PARTICIPANT_UPDATED,
PIN_PARTICIPANT,
RAISE_HAND_CLEAR,
RAISE_HAND_UPDATED,
SCREENSHARE_PARTICIPANT_NAME_CHANGED,
SET_LOADABLE_AVATAR_URL,
SET_LOCAL_PARTICIPANT_RECORDING_STATUS
} from './actionTypes';
import {
DISCO_REMOTE_CONTROL_FEATURE
} from './constants';
import {
getLocalParticipant,
getNormalizedDisplayName,
getParticipantById,
getParticipantDisplayName,
getVirtualScreenshareParticipantOwnerId
} from './functions';
import logger from './logger';
import { FakeParticipant, IJitsiParticipant, IParticipant } from './types';
/**
* Create an action for when dominant speaker changes.
*
* @param {string} dominantSpeaker - Participant ID of the dominant speaker.
* @param {Array<string>} previousSpeakers - Participant IDs of the previous speakers.
* @param {boolean} silence - Whether the dominant speaker is silent or not.
* @param {JitsiConference} conference - The {@code JitsiConference} associated
* with the participant identified by the specified {@code id}. Only the local
* participant is allowed to not specify an associated {@code JitsiConference}
* instance.
* @returns {{
* type: DOMINANT_SPEAKER_CHANGED,
* participant: {
* conference: JitsiConference,
* id: string,
* previousSpeakers: Array<string>,
* silence: boolean
* }
* }}
*/
export function dominantSpeakerChanged(
dominantSpeaker: string, previousSpeakers: string[], silence: boolean, conference: IJitsiConference) {
return {
type: DOMINANT_SPEAKER_CHANGED,
participant: {
conference,
id: dominantSpeaker,
previousSpeakers,
silence
}
};
}
/**
* Create an action for granting moderator to a participant.
*
* @param {string} id - Participant's ID.
* @returns {{
* type: GRANT_MODERATOR,
* id: string
* }}
*/
export function grantModerator(id: string) {
return {
type: GRANT_MODERATOR,
id
};
}
/**
* Create an action for removing a participant from the conference.
*
* @param {string} id - Participant's ID.
* @returns {{
* type: KICK_PARTICIPANT,
* id: string
* }}
*/
export function kickParticipant(id: string) {
return {
type: KICK_PARTICIPANT,
id
};
}
/**
* Action to signal that the ID of local participant has changed. It happens
* when the local participant joins a new conference or leaves an existing
* conference.
*
* @param {string} id - New ID for local participant.
* @returns {Function}
*/
export function localParticipantIdChanged(id: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const participant = getLocalParticipant(getState);
if (participant) {
return dispatch({
type: PARTICIPANT_ID_CHANGED,
// XXX A participant is identified by an id-conference pair.
// Only the local participant is with an undefined conference.
conference: undefined,
newValue: id,
oldValue: participant.id
});
}
};
}
/**
* Action to signal that a local participant has joined.
*
* @param {IParticipant} participant={} - Information about participant.
* @returns {{
* type: PARTICIPANT_JOINED,
* participant: IParticipant
* }}
*/
export function localParticipantJoined(participant: IParticipant = { id: '' }) {
return participantJoined(set(participant, 'local', true));
}
/**
* Action to remove a local participant.
*
* @returns {Function}
*/
export function localParticipantLeft() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const participant = getLocalParticipant(getState);
if (participant) {
return (
dispatch(
participantLeft(
participant.id,
// XXX Only the local participant is allowed to leave
// without stating the JitsiConference instance because
// the local participant is uniquely identified by the
// very fact that there is only one local participant
// (and the fact that the local participant "joins" at
// the beginning of the app and "leaves" at the end of
// the app).
undefined)));
}
};
}
/**
* Action to signal the role of the local participant has changed. It can happen
* when the participant has joined a conference, even before a non-default local
* id has been set, or after a moderator leaves.
*
* @param {string} role - The new role of the local participant.
* @returns {Function}
*/
export function localParticipantRoleChanged(role: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const participant = getLocalParticipant(getState);
if (participant) {
return dispatch(participantRoleChanged(participant.id, role));
}
};
}
/**
* Create an action for muting another participant in the conference.
*
* @param {string} id - Participant's ID.
* @param {MEDIA_TYPE} mediaType - The media to mute.
* @returns {{
* type: MUTE_REMOTE_PARTICIPANT,
* id: string,
* mediaType: MEDIA_TYPE
* }}
*/
export function muteRemoteParticipant(id: string, mediaType: string) {
return {
type: MUTE_REMOTE_PARTICIPANT,
id,
mediaType
};
}
/**
* Action to signal that a participant has joined.
*
* @param {IParticipant} participant - Information about participant.
* @returns {{
* type: PARTICIPANT_JOINED,
* participant: IParticipant
* }}
*/
export function participantJoined(participant: IParticipant) {
// Only the local participant is not identified with an id-conference pair.
if (participant.local) {
return {
type: PARTICIPANT_JOINED,
participant
};
}
// In other words, a remote participant is identified with an id-conference
// pair.
const { conference } = participant;
if (!conference) {
throw Error(
'A remote participant must be associated with a JitsiConference!');
}
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
// A remote participant is only expected to join in a joined or joining
// conference. The following check is really necessary because a
// JitsiConference may have moved into leaving but may still manage to
// sneak a PARTICIPANT_JOINED in if its leave is delayed for any purpose
// (which is not outrageous given that leaving involves network
// requests.)
const stateFeaturesBaseConference
= getState()['features/base/conference'];
if (conference === stateFeaturesBaseConference.conference
|| conference === stateFeaturesBaseConference.joining) {
return dispatch({
type: PARTICIPANT_JOINED,
participant
});
}
};
}
/**
* Updates the sources of a remote participant.
*
* @param {IJitsiParticipant} jitsiParticipant - The IJitsiParticipant instance.
* @returns {{
* type: PARTICIPANT_SOURCES_UPDATED,
* participant: IParticipant
* }}
*/
export function participantSourcesUpdated(jitsiParticipant: IJitsiParticipant) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const id = jitsiParticipant.getId();
const participant = getParticipantById(getState(), id);
if (participant?.local) {
return;
}
const sources = jitsiParticipant.getSources();
if (!sources?.size) {
return;
}
return dispatch({
type: PARTICIPANT_SOURCES_UPDATED,
participant: {
id,
sources
}
});
};
}
/**
* Updates the features of a remote participant.
*
* @param {JitsiParticipant} jitsiParticipant - The ID of the participant.
* @returns {{
* type: PARTICIPANT_UPDATED,
* participant: IParticipant
* }}
*/
export function updateRemoteParticipantFeatures(jitsiParticipant: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!jitsiParticipant) {
return;
}
const id = jitsiParticipant.getId();
jitsiParticipant.getFeatures()
.then((features: Map<string, string>) => {
const supportsRemoteControl = features.has(DISCO_REMOTE_CONTROL_FEATURE);
const participant = getParticipantById(getState(), id);
if (!participant || participant.local) {
return;
}
if (participant?.supportsRemoteControl !== supportsRemoteControl) {
return dispatch({
type: PARTICIPANT_UPDATED,
participant: {
id,
supportsRemoteControl
}
});
}
})
.catch((error: any) => {
logger.error(`Failed to get participant features for ${id}!`, error);
});
};
}
/**
* Action to signal that a participant has left.
*
* @param {string} id - Participant's ID.
* @param {JitsiConference} conference - The {@code JitsiConference} associated
* with the participant identified by the specified {@code id}. Only the local
* participant is allowed to not specify an associated {@code JitsiConference}
* instance.
* @param {Object} participantLeftProps - Other participant properties.
* @typedef {Object} participantLeftProps
* @param {FakeParticipant|undefined} participantLeftProps.fakeParticipant - The type of fake participant.
* @param {boolean} participantLeftProps.isReplaced - Whether the participant is to be replaced in the meeting.
*
* @returns {{
* type: PARTICIPANT_LEFT,
* participant: {
* conference: JitsiConference,
* id: string
* }
* }}
*/
export function participantLeft(id: string, conference?: IJitsiConference, participantLeftProps: {
fakeParticipant?: string; isReplaced?: boolean;
} = {}) {
return {
type: PARTICIPANT_LEFT,
participant: {
conference,
fakeParticipant: participantLeftProps.fakeParticipant,
id,
isReplaced: participantLeftProps.isReplaced
}
};
}
/**
* Action to signal that a participant's presence status has changed.
*
* @param {string} id - Participant's ID.
* @param {string} presence - Participant's new presence status.
* @returns {{
* type: PARTICIPANT_UPDATED,
* participant: {
* id: string,
* presence: string
* }
* }}
*/
export function participantPresenceChanged(id: string, presence: string) {
return participantUpdated({
id,
presence
});
}
/**
* Action to signal that a participant's role has changed.
*
* @param {string} id - Participant's ID.
* @param {PARTICIPANT_ROLE} role - Participant's new role.
* @returns {Promise}
*/
export function participantRoleChanged(id: string, role: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const oldParticipantRole = getParticipantById(getState(), id)?.role;
dispatch(participantUpdated({
id,
role
}));
if (oldParticipantRole !== role) {
dispatch({
type: PARTICIPANT_ROLE_CHANGED,
participant: {
id,
role
}
});
}
};
}
/**
* Action to signal that a participant's display name has changed.
*
* @param {string} id - Screenshare participant's ID.
* @param {name} name - The new display name of the screenshare participant's owner.
* @returns {{
* type: SCREENSHARE_PARTICIPANT_NAME_CHANGED,
* id: string,
* name: string
* }}
*/
export function screenshareParticipantDisplayNameChanged(id: string, name: string) {
return {
type: SCREENSHARE_PARTICIPANT_NAME_CHANGED,
id,
name
};
}
/**
* Action to signal that some of participant properties has been changed.
*
* @param {IParticipant} participant={} - Information about participant. To
* identify the participant the object should contain either property id with
* value the id of the participant or property local with value true (if the
* local participant hasn't joined the conference yet).
* @returns {{
* type: PARTICIPANT_UPDATED,
* participant: IParticipant
* }}
*/
export function participantUpdated(participant: IParticipant = { id: '' }) {
const participantToUpdate = {
...participant
};
if (participant.name) {
participantToUpdate.name = getNormalizedDisplayName(participant.name);
}
return {
type: PARTICIPANT_UPDATED,
participant: participantToUpdate
};
}
/**
* Action to signal that a participant has muted us.
*
* @param {JitsiParticipant} participant - Information about participant.
* @param {JitsiLocalTrack} track - Information about the track that has been muted.
* @returns {Promise}
*/
export function participantMutedUs(participant: any, track: any) {
return {
type: PARTICIPANT_MUTED_US,
participant,
track
};
}
/**
* Action to create a virtual screenshare participant.
*
* @param {(string)} sourceName - The source name of the JitsiTrack instance.
* @param {(boolean)} local - Whether it's a local or remote participant.
* @param {JitsiConference} conference - The conference instance for which the participant is to be created.
* @returns {Function}
*/
export function createVirtualScreenshareParticipant(sourceName: string, local: boolean, conference?: IJitsiConference) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const ownerId = getVirtualScreenshareParticipantOwnerId(sourceName);
const ownerName = getParticipantDisplayName(state, ownerId);
dispatch(participantJoined({
conference,
fakeParticipant: local ? FakeParticipant.LocalScreenShare : FakeParticipant.RemoteScreenShare,
id: sourceName,
name: ownerName
}));
};
}
/**
* Action to signal that a participant had been kicked.
*
* @param {JitsiParticipant} kicker - Information about participant performing the kick.
* @param {JitsiParticipant} kicked - Information about participant that was kicked.
* @returns {Promise}
*/
export function participantKicked(kicker: any, kicked: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const localParticipant = getLocalParticipant(state);
const kickedId = kicked.getId();
const kickerId = kicker?.getId();
dispatch({
type: PARTICIPANT_KICKED,
kicked: kickedId,
kicker: kickerId
});
if (kicked.isReplaced?.() || !kickerId || kickerId === localParticipant?.id) {
return;
}
dispatch(showNotification({
titleArguments: {
kicked:
getParticipantDisplayName(state, kickedId),
kicker:
getParticipantDisplayName(state, kickerId)
},
titleKey: 'notify.kickParticipant'
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
};
}
/**
* Create an action which pins a conference participant.
*
* @param {string|null} id - The ID of the conference participant to pin or null
* if none of the conference's participants are to be pinned.
* @returns {{
* type: PIN_PARTICIPANT,
* participant: {
* id: string
* }
* }}
*/
export function pinParticipant(id?: string | null) {
return {
type: PIN_PARTICIPANT,
participant: {
id
}
};
}
/**
* Creates an action which notifies the app that the loadable URL of the avatar of a participant got updated.
*
* @param {string} participantId - The ID of the participant.
* @param {string} url - The new URL.
* @param {boolean} useCORS - Indicates whether we need to use CORS for this URL.
* @returns {{
* type: SET_LOADABLE_AVATAR_URL,
* participant: {
* id: string,
* loadableAvatarUrl: string,
* loadableAvatarUrlUseCORS: boolean
* }
* }}
*/
export function setLoadableAvatarUrl(participantId: string, url: string, useCORS: boolean) {
return {
type: SET_LOADABLE_AVATAR_URL,
participant: {
id: participantId,
loadableAvatarUrl: url,
loadableAvatarUrlUseCORS: useCORS
}
};
}
/**
* Raise hand for the local participant.
*
* @param {boolean} enabled - Raise or lower hand.
* @returns {{
* type: LOCAL_PARTICIPANT_RAISE_HAND,
* raisedHandTimestamp: number
* }}
*/
export function raiseHand(enabled: boolean) {
return {
type: LOCAL_PARTICIPANT_RAISE_HAND,
raisedHandTimestamp: enabled ? Date.now() : 0
};
}
/**
* Clear the raise hand queue.
*
* @returns {{
* type: RAISE_HAND_CLEAR
* }}
*/
export function raiseHandClear() {
return {
type: RAISE_HAND_CLEAR
};
}
/**
* Update raise hand queue of participants.
*
* @param {Object} participant - Participant that updated raised hand.
* @returns {{
* type: RAISE_HAND_UPDATED,
* participant: Object
* }}
*/
export function raiseHandUpdateQueue(participant: IParticipant) {
return {
type: RAISE_HAND_UPDATED,
participant
};
}
/**
* Notifies if the local participant audio level has changed.
*
* @param {number} level - The audio level.
* @returns {{
* type: LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
* level: number
* }}
*/
export function localParticipantAudioLevelChanged(level: number) {
return {
type: LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
level
};
}
/**
* Overwrites the name of the participant with the given id.
*
* @param {string} id - Participant id;.
* @param {string} name - New participant name.
* @returns {Object}
*/
export function overwriteParticipantName(id: string, name: string) {
return {
type: OVERWRITE_PARTICIPANT_NAME,
id,
name
};
}
/**
* Overwrites the names of the given participants.
*
* @param {Array<Object>} participantList - The list of participants to overwrite.
* @returns {Object}
*/
export function overwriteParticipantsNames(participantList: IParticipant[]) {
return {
type: OVERWRITE_PARTICIPANTS_NAMES,
participantList
};
}
/**
* Local video recording status for the local participant.
*
* @param {boolean} recording - If local recording is ongoing.
* @param {boolean} onlySelf - If recording only local streams.
* @returns {{
* type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
* recording: boolean
* }}
*/
export function updateLocalRecordingStatus(recording: boolean, onlySelf?: boolean) {
return {
type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
recording,
onlySelf
};
}

View File

@@ -0,0 +1,300 @@
import React, { Component } from 'react';
import { GestureResponderEvent, Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import {
isTrackStreamingStatusActive,
isTrackStreamingStatusInactive
} from '../../../connection-indicator/functions';
import SharedVideo from '../../../shared-video/components/native/SharedVideo';
import { isSharedVideoEnabled } from '../../../shared-video/functions';
import { IStateful } from '../../app/types';
import Avatar from '../../avatar/components/Avatar';
import { translate } from '../../i18n/functions';
import VideoTrack from '../../media/components/native/VideoTrack';
import { shouldRenderVideoTrack } from '../../media/functions';
import Container from '../../react/components/native/Container';
import { toState } from '../../redux/functions';
import { StyleType } from '../../styles/functions.any';
import TestHint from '../../testing/components/TestHint';
import { getVideoTrackByParticipant } from '../../tracks/functions';
import { ITrack } from '../../tracks/types';
import { getParticipantById, getParticipantDisplayName, isSharedVideoParticipant } from '../functions';
import styles from './styles';
/**
* The type of the React {@link Component} props of {@link ParticipantView}.
*/
interface IProps {
/**
* Whether the connection is inactive or not.
*
* @private
*/
_isConnectionInactive: boolean;
/**
* Whether the participant is a shared video participant.
*/
_isSharedVideoParticipant: boolean;
/**
* The name of the participant which this component represents.
*
* @private
*/
_participantName: string;
/**
* True if the video should be rendered, false otherwise.
*/
_renderVideo: boolean;
/**
* Whether the shared video is enabled or not.
*/
_sharedVideoEnabled: boolean;
/**
* The video Track of the participant with {@link #participantId}.
*/
_videoTrack?: ITrack;
/**
* The avatar size.
*/
avatarSize: number;
/**
* Whether video should be disabled for his view.
*/
disableVideo?: boolean;
/**
* Callback to invoke when the {@code ParticipantView} is clicked/pressed.
*/
onPress: (e?: GestureResponderEvent) => void;
/**
* The ID of the participant (to be) depicted by {@link ParticipantView}.
*
* @public
*/
participantId: string;
/**
* The style, if any, to apply to {@link ParticipantView} in addition to its
* default style.
*/
style: StyleType;
/**
* The function to translate human-readable text.
*/
t: Function;
/**
* The test hint id which can be used to locate the {@code ParticipantView}
* on the jitsi-meet-torture side. If not provided, the
* {@code participantId} with the following format will be used:
* {@code `org.jitsi.meet.Participant#${participantId}`}.
*/
testHintId?: string;
/**
* Indicates if the connectivity info label should be shown, if appropriate.
* It will be shown in case the connection is interrupted.
*/
useConnectivityInfoLabel: boolean;
/**
* The z-order of the {@link Video} of {@link ParticipantView} in the
* stacking space of all {@code Video}s. For more details, refer to the
* {@code zOrder} property of the {@code Video} class for React Native.
*/
zOrder: number;
/**
* Indicates whether zooming (pinch to zoom and/or drag) is enabled.
*/
zoomEnabled: boolean;
}
/**
* Implements a React Component which depicts a specific participant's avatar
* and video.
*
* @augments Component
*/
class ParticipantView extends Component<IProps> {
/**
* Renders the inactive connection status label.
*
* @private
* @returns {ReactElement}
*/
_renderInactiveConnectionInfo() {
const {
avatarSize,
_participantName: displayName,
t
} = this.props;
// XXX Consider splitting this component into 2: one for the large view
// and one for the thumbnail. Some of these don't apply to both.
const containerStyle = {
...styles.connectionInfoContainer,
width: avatarSize * 1.5
};
return (
<View
pointerEvents = 'box-none'
style = { containerStyle as ViewStyle }>
<Text style = { styles.connectionInfoText as TextStyle }>
{ t('connection.LOW_BANDWIDTH', { displayName }) }
</Text>
</View>
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_isConnectionInactive,
_isSharedVideoParticipant,
_renderVideo: renderVideo,
_sharedVideoEnabled,
_videoTrack: videoTrack,
disableVideo,
onPress
} = this.props;
const testHintId
= this.props.testHintId
? this.props.testHintId
: `org.jitsi.meet.Participant#${this.props.participantId}`;
const renderSharedVideo = _isSharedVideoParticipant && !disableVideo && _sharedVideoEnabled;
return (
<Container
onClick = { renderVideo || renderSharedVideo ? undefined : onPress }
style = {{
...styles.participantView,
...this.props.style
}}
touchFeedback = { false }>
<TestHint
id = { testHintId }
onPress = { renderSharedVideo ? undefined : onPress }
value = '' />
{ renderSharedVideo && <SharedVideo /> }
{ renderVideo
&& <VideoTrack
onPress = { onPress }
videoTrack = { videoTrack }
waitForVideoStarted = { false }
zOrder = { this.props.zOrder }
zoomEnabled = { this.props.zoomEnabled } /> }
{ !renderSharedVideo && !renderVideo
&& <View style = { styles.avatarContainer as ViewStyle }>
<Avatar
participantId = { this.props.participantId }
size = { this.props.avatarSize } />
</View> }
{ _isConnectionInactive && this.props.useConnectivityInfoLabel
&& this._renderInactiveConnectionInfo() }
</Container>
);
}
}
/**
* Maps (parts of) the redux state to the associated {@link ParticipantView}'s
* props.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The React {@code Component} props passed to the
* associated (instance of) {@code ParticipantView}.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { disableVideo, participantId } = ownProps;
const participant = getParticipantById(state, participantId);
const videoTrack = getVideoTrackByParticipant(state, participant);
return {
_isConnectionInactive: isTrackStreamingStatusInactive(videoTrack),
_isSharedVideoParticipant: isSharedVideoParticipant(participant),
_participantName: getParticipantDisplayName(state, participantId),
_renderVideo: shouldRenderParticipantVideo(state, participantId) && !disableVideo,
_sharedVideoEnabled: isSharedVideoEnabled(state),
_videoTrack: videoTrack
};
}
/**
* Returns true if the video of the participant should be rendered.
*
* @param {Object|Function} stateful - Object or function that can be resolved
* to the Redux state.
* @param {string} id - The ID of the participant.
* @returns {boolean}
*/
function shouldRenderParticipantVideo(stateful: IStateful, id: string) {
const state = toState(stateful);
const participant = getParticipantById(state, id);
if (!participant) {
return false;
}
/* First check if we have an unmuted video track. */
const videoTrack = getVideoTrackByParticipant(state, participant);
if (!videoTrack) {
return false;
}
if (!shouldRenderVideoTrack(videoTrack, /* waitForVideoStarted */ false)) {
return false;
}
/* Then check if the participant connection or track streaming status is active. */
if (!videoTrack.local && !isTrackStreamingStatusActive(videoTrack)) {
return false;
}
/* Then check if audio-only mode is not active. */
const audioOnly = state['features/base/audio-only'].enabled;
if (!audioOnly) {
return true;
}
/* Last, check if the participant is sharing their screen and they are on stage. */
const remoteScreenShares = state['features/video-layout'].remoteScreenShares || [];
const largeVideoParticipantId = state['features/large-video'].participantId;
const participantIsInLargeVideoWithScreen
= participant.id === largeVideoParticipantId && remoteScreenShares.includes(participant.id);
return participantIsInLargeVideoWithScreen;
}
export default translate(connect(_mapStateToProps)(ParticipantView));

View File

@@ -0,0 +1,46 @@
import { BoxModel } from '../../styles/components/styles/BoxModel';
import { ColorPalette } from '../../styles/components/styles/ColorPalette';
/**
* The styles of the feature base/participants.
*/
export default {
/**
* Container for the avatar in the view.
*/
avatarContainer: {
alignItems: 'center',
justifyContent: 'center'
},
/**
* Style for the text rendered when there is a connectivity problem.
*/
connectionInfoText: {
color: ColorPalette.white,
fontSize: 12,
marginVertical: BoxModel.margin,
marginHorizontal: BoxModel.margin,
textAlign: 'center'
},
/**
* Style for the container of the text rendered when there is a
* connectivity problem.
*/
connectionInfoContainer: {
alignSelf: 'center',
backgroundColor: ColorPalette.darkGrey,
borderRadius: 20,
marginTop: BoxModel.margin
},
/**
* {@code ParticipantView} Style.
*/
participantView: {
alignItems: 'stretch',
flex: 1,
justifyContent: 'center'
}
};

View File

@@ -0,0 +1,82 @@
import { IconPhoneRinging, IconWhiteboard } from '../icons/svg';
/**
* The relative path to the default/stock avatar (image) file used on both
* Web/React and mobile/React Native (for the purposes of consistency).
*
* XXX (1) Web/React utilizes relativity on the Jitsi Meet deployment.
* (2) Mobile/React Native utilizes relativity on the local file system at build
* time. Unfortunately, the packager of React Native cannot deal with the
* {@code const} early enough for {@code require} to succeed at runtime.
* Anyway, be sure to synchronize the relative path on Web and mobile for the
* purposes of consistency.
*
* @type {string}
*/
export const DEFAULT_AVATAR_RELATIVE_PATH = 'images/avatar.png';
/**
* The value for the "var" attribute of feature tag in disco-info packets.
*/
export const DISCO_REMOTE_CONTROL_FEATURE = 'http://jitsi.org/meet/remotecontrol';
/**
* Icon URL for jigasi participants.
*
* @type {string}
*/
export const JIGASI_PARTICIPANT_ICON = IconPhoneRinging;
/**
* The local participant might not have real ID until she joins a conference,
* so use 'local' as her default ID.
*
* @type {string}
*/
export const LOCAL_PARTICIPANT_DEFAULT_ID = 'local';
/**
* Max length of the display names.
*
* @type {string}
*/
export const MAX_DISPLAY_NAME_LENGTH = 50;
/**
* The identifier of the sound to be played when new remote participant joins
* the room.
*
* @type {string}
*/
export const PARTICIPANT_JOINED_SOUND_ID = 'PARTICIPANT_JOINED_SOUND';
/**
* The identifier of the sound to be played when remote participant leaves
* the room.
*
* @type {string}
*/
export const PARTICIPANT_LEFT_SOUND_ID = 'PARTICIPANT_LEFT_SOUND';
/**
* The set of possible XMPP MUC roles for conference participants.
*
* @enum {string}
*/
export const PARTICIPANT_ROLE = {
MODERATOR: 'moderator',
NONE: 'none',
PARTICIPANT: 'participant'
};
/**
* The audio level at which the hand will be lowered if raised.
*
* @type {string}
*/
export const LOWER_HAND_AUDIO_LEVEL = 0.2;
/**
* Icon URL for the whiteboard participant.
*/
export const WHITEBOARD_PARTICIPANT_ICON = IconWhiteboard;

View File

@@ -0,0 +1,859 @@
// @ts-expect-error
import { getGravatarURL } from '@jitsi/js-utils/avatar';
import { IReduxState, IStore } from '../../app/types';
import { isVisitorChatParticipant } from '../../chat/functions';
import { isStageFilmstripAvailable } from '../../filmstrip/functions';
import { isAddPeopleEnabled, isDialOutEnabled } from '../../invite/functions';
import { toggleShareDialog } from '../../share-room/actions';
import { iAmVisitor } from '../../visitors/functions';
import { IVisitorChatParticipant } from '../../visitors/types';
import { IStateful } from '../app/types';
import { GRAVATAR_BASE_URL } from '../avatar/constants';
import { isCORSAvatarURL } from '../avatar/functions';
import { getCurrentConference } from '../conference/functions';
import { ADD_PEOPLE_ENABLED } from '../flags/constants';
import { getFeatureFlag } from '../flags/functions';
import i18next from '../i18n/i18next';
import { MEDIA_TYPE, MediaType, VIDEO_TYPE } from '../media/constants';
import { toState } from '../redux/functions';
import { getScreenShareTrack, isLocalTrackMuted } from '../tracks/functions.any';
import {
JIGASI_PARTICIPANT_ICON,
MAX_DISPLAY_NAME_LENGTH,
PARTICIPANT_ROLE,
WHITEBOARD_PARTICIPANT_ICON
} from './constants';
import { preloadImage } from './preloadImage';
import { FakeParticipant, IJitsiParticipant, IParticipant, ISourceInfo } from './types';
/**
* Temp structures for avatar urls to be checked/preloaded.
*/
const AVATAR_QUEUE: Object[] = [];
const AVATAR_CHECKED_URLS = new Map();
/* eslint-disable arrow-body-style */
const AVATAR_CHECKER_FUNCTIONS = [
(participant: IParticipant) => {
return participant?.isJigasi ? JIGASI_PARTICIPANT_ICON : null;
},
(participant: IParticipant) => {
return isWhiteboardParticipant(participant) ? WHITEBOARD_PARTICIPANT_ICON : null;
},
(participant: IParticipant) => {
return participant?.avatarURL ? participant.avatarURL : null;
},
(participant: IParticipant, store: IStore) => {
const config = store.getState()['features/base/config'];
const isGravatarDisabled = config.gravatar?.disabled;
if (participant?.email && !isGravatarDisabled) {
const gravatarBaseURL = config.gravatar?.baseUrl
|| config.gravatarBaseURL
|| GRAVATAR_BASE_URL;
return getGravatarURL(participant.email, gravatarBaseURL);
}
return null;
}
];
/* eslint-enable arrow-body-style */
/**
* Returns the list of active speakers that should be moved to the top of the sorted list of participants so that the
* dominant speaker is visible always on the vertical filmstrip in stage layout.
*
* @param {Function | Object} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @returns {Array<string>}
*/
export function getActiveSpeakersToBeDisplayed(stateful: IStateful) {
const state = toState(stateful);
const {
dominantSpeaker,
fakeParticipants,
sortedRemoteVirtualScreenshareParticipants,
speakersList
} = state['features/base/participants'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
let activeSpeakers = new Map(speakersList);
// Do not re-sort the active speakers if dominant speaker is currently visible.
if (dominantSpeaker && visibleRemoteParticipants.has(dominantSpeaker)) {
return activeSpeakers;
}
let availableSlotsForActiveSpeakers = visibleRemoteParticipants.size;
if (activeSpeakers.has(dominantSpeaker ?? '')) {
activeSpeakers.delete(dominantSpeaker ?? '');
}
// Add dominant speaker to the beginning of the list (not including self) since the active speaker list is always
// alphabetically sorted.
if (dominantSpeaker && dominantSpeaker !== getLocalParticipant(state)?.id) {
const updatedSpeakers = Array.from(activeSpeakers);
updatedSpeakers.splice(0, 0, [ dominantSpeaker, getParticipantById(state, dominantSpeaker)?.name ?? '' ]);
activeSpeakers = new Map(updatedSpeakers);
}
// Remove screenshares from the count.
if (sortedRemoteVirtualScreenshareParticipants) {
availableSlotsForActiveSpeakers -= sortedRemoteVirtualScreenshareParticipants.size * 2;
for (const screenshare of Array.from(sortedRemoteVirtualScreenshareParticipants.keys())) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare as string);
activeSpeakers.delete(ownerId);
}
}
// Remove fake participants from the count.
if (fakeParticipants) {
availableSlotsForActiveSpeakers -= fakeParticipants.size;
}
const truncatedSpeakersList = Array.from(activeSpeakers).slice(0, availableSlotsForActiveSpeakers);
truncatedSpeakersList.sort((a: any, b: any) => a[1].localeCompare(b[1]));
return new Map(truncatedSpeakersList);
}
/**
* Resolves the first loadable avatar URL for a participant.
*
* @param {Object} participant - The participant to resolve avatars for.
* @param {Store} store - Redux store.
* @returns {Promise}
*/
export function getFirstLoadableAvatarUrl(participant: IParticipant, store: IStore) {
const deferred: any = Promise.withResolvers();
const fullPromise = deferred.promise
.then(() => _getFirstLoadableAvatarUrl(participant, store))
.then((result: any) => {
if (AVATAR_QUEUE.length) {
const next: any = AVATAR_QUEUE.shift();
next.resolve();
}
return result;
});
if (AVATAR_QUEUE.length) {
AVATAR_QUEUE.push(deferred);
} else {
deferred.resolve();
}
return fullPromise;
}
/**
* Returns local participant from 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 {(IParticipant|undefined)}
*/
export function getLocalParticipant(stateful: IStateful) {
const state = toState(stateful)['features/base/participants'];
return state.local;
}
/**
* Returns local screen share participant from 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 {(IParticipant|undefined)}
*/
export function getLocalScreenShareParticipant(stateful: IStateful) {
const state = toState(stateful)['features/base/participants'];
return state.localScreenShare;
}
/**
* Returns screenshare 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.
* @param {string} id - The owner ID of the screenshare participant to retrieve.
* @returns {(IParticipant|undefined)}
*/
export function getVirtualScreenshareParticipantByOwnerId(stateful: IStateful, id: string) {
const state = toState(stateful);
const track = getScreenShareTrack(state['features/base/tracks'], id);
return getParticipantById(stateful, track?.jitsiTrack.getSourceName());
}
/**
* Normalizes a display name so then no invalid values (padding, length...etc)
* can be set.
*
* @param {string} name - The display name to set.
* @returns {string}
*/
export function getNormalizedDisplayName(name: string) {
if (!name?.trim()) {
return undefined;
}
return name.trim().substring(0, MAX_DISPLAY_NAME_LENGTH);
}
/**
* Returns participant by ID from 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.
* @param {string} id - The ID of the participant to retrieve.
* @private
* @returns {(IParticipant|undefined)}
*/
export function getParticipantById(stateful: IStateful, id: string): IParticipant | undefined {
const state = toState(stateful)['features/base/participants'];
const { local, localScreenShare, remote } = state;
return remote.get(id)
|| (local?.id === id ? local : undefined)
|| (localScreenShare?.id === id ? localScreenShare : 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 {IParticipant|undefined}
*/
export function getParticipantByIdOrUndefined(stateful: IStateful, 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)} 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: IStateful) {
const state = toState(stateful);
const {
local,
remote,
fakeParticipants,
sortedRemoteVirtualScreenshareParticipants
} = state['features/base/participants'];
return remote.size - fakeParticipants.size - sortedRemoteVirtualScreenshareParticipants.size + (local ? 1 : 0);
}
/**
* Returns participant ID of the owner of a virtual screenshare participant.
*
* @param {string} id - The ID of the virtual screenshare participant.
* @private
* @returns {(string|undefined)}
*/
export function getVirtualScreenshareParticipantOwnerId(id: string) {
return id.split('-')[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, IParticipant>} - The Map with fake participants.
*/
export function getFakeParticipants(stateful: IStateful) {
return toState(stateful)['features/base/participants'].fakeParticipants;
}
/**
* Returns whether the fake participant is a local screenshare.
*
* @param {IParticipant|undefined} participant - The participant entity.
* @returns {boolean} - True if it's a local screenshare participant.
*/
export function isLocalScreenshareParticipant(participant?: IParticipant): boolean {
return participant?.fakeParticipant === FakeParticipant.LocalScreenShare;
}
/**
* Returns whether the fake participant is a remote screenshare.
*
* @param {IParticipant|undefined} participant - The participant entity.
* @returns {boolean} - True if it's a remote screenshare participant.
*/
export function isRemoteScreenshareParticipant(participant?: IParticipant): boolean {
return participant?.fakeParticipant === FakeParticipant.RemoteScreenShare;
}
/**
* Returns whether the fake participant is of local or virtual screenshare type.
*
* @param {IReduxState} state - The (whole) redux state, or redux's.
* @param {string|undefined} participantId - The participant id.
* @returns {boolean} - True if it's one of the two.
*/
export function isScreenShareParticipantById(state: IReduxState, participantId?: string): boolean {
const participant = getParticipantByIdOrUndefined(state, participantId);
return isScreenShareParticipant(participant);
}
/**
* Returns whether the fake participant is of local or virtual screenshare type.
*
* @param {IParticipant|undefined} participant - The participant entity.
* @returns {boolean} - True if it's one of the two.
*/
export function isScreenShareParticipant(participant?: IParticipant): boolean {
return isLocalScreenshareParticipant(participant) || isRemoteScreenshareParticipant(participant);
}
/**
* Returns whether the (fake) participant is a shared video.
*
* @param {IParticipant|undefined} participant - The participant entity.
* @returns {boolean} - True if it's a shared video participant.
*/
export function isSharedVideoParticipant(participant?: IParticipant): boolean {
return participant?.fakeParticipant === FakeParticipant.SharedVideo;
}
/**
* Returns whether the fake participant is a whiteboard.
*
* @param {IParticipant|undefined} participant - The participant entity.
* @returns {boolean} - True if it's a whiteboard participant.
*/
export function isWhiteboardParticipant(participant?: IParticipant): boolean {
return participant?.fakeParticipant === FakeParticipant.Whiteboard;
}
/**
* 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 getRemoteParticipantCountWithFake(stateful: IStateful) {
const state = toState(stateful);
const participantsState = state['features/base/participants'];
return participantsState.remote.size;
}
/**
* Returns the muted state of the given media source for a given participant.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's.
* @param {IParticipant} participant - The participant entity.
* @param {MediaType} mediaType - The media type.
* @returns {boolean} - True its muted, false otherwise.
*/
export function getMutedStateByParticipantAndMediaType(
stateful: IStateful,
participant: IParticipant,
mediaType: MediaType): boolean {
const type = mediaType === MEDIA_TYPE.SCREENSHARE ? 'video' : mediaType;
if (participant.local) {
const state = toState(stateful);
const tracks = state['features/base/tracks'];
return isLocalTrackMuted(tracks, mediaType);
}
const sources = participant.sources?.get(type);
if (!sources) {
return true;
}
if (mediaType === MEDIA_TYPE.AUDIO) {
return Array.from(sources.values())[0].muted;
}
const videoType = mediaType === MEDIA_TYPE.VIDEO ? VIDEO_TYPE.CAMERA : VIDEO_TYPE.DESKTOP;
const source = Array.from(sources.values()).find(src => src.videoType === videoType);
return source?.muted ?? true;
}
/**
* Returns a count of the known participants in the passed in redux state,
* including 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 {number}
*/
export function getParticipantCountWithFake(stateful: IStateful) {
const state = toState(stateful);
const { local, localScreenShare, remote } = state['features/base/participants'];
return remote.size + (local ? 1 : 0) + (localScreenShare ? 1 : 0);
}
/**
* Returns a count of the known participants in the passed in redux state,
* including fake participants. Subtract 1 when the local participant is a visitor as we do not show a local thumbnail.
* The number used to display the participant count in the UI.
*
* @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 getParticipantCountForDisplay(stateful: IStateful) {
const _iAmVisitor = iAmVisitor(stateful);
return getParticipantCount(stateful) - (_iAmVisitor ? 1 : 0);
}
/**
* Returns participant's display name.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @param {string} id - The ID of the participant's display name to retrieve.
* @returns {string}
*/
export function getParticipantDisplayName(stateful: IStateful, id: string): string {
const state = toState(stateful);
const participant = getParticipantById(state, id);
const {
defaultLocalDisplayName,
defaultRemoteDisplayName
} = state['features/base/config'];
if (participant) {
if (isScreenShareParticipant(participant)) {
return getScreenshareParticipantDisplayName(state, id);
}
if (participant.name) {
return participant.name;
}
if (participant.local) {
return defaultLocalDisplayName ?? '';
}
}
return defaultRemoteDisplayName ?? '';
}
/**
* Returns the source names of the screenshare sources in the conference based on the presence shared by the remote
* endpoints. This should be only used for creating/removing virtual screenshare participant tiles when ssrc-rewriting
* is enabled. Once the tile is created, the source-name gets added to the receiver constraints based on which the
* JVB will add the source to the video sources map and signal it to the local endpoint. Only then, a remote track is
* created/remapped and the tracks in redux will be updated. Once the track is updated in redux, the client will
* will continue to use the other track based getter functions for other operations related to screenshare.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @returns {string[]}
*/
export function getRemoteScreensharesBasedOnPresence(stateful: IStateful): string[] {
const conference = getCurrentConference(stateful);
return conference?.getParticipants()?.reduce((screenshares: string[], participant: IJitsiParticipant) => {
const sources: Map<string, Map<string, ISourceInfo>> = participant.getSources();
const videoSources = sources.get(MEDIA_TYPE.VIDEO);
const screenshareSources = Array.from(videoSources ?? new Map())
.filter(source => source[1].videoType === VIDEO_TYPE.DESKTOP && !source[1].muted)
.map(source => source[0]);
// eslint-disable-next-line no-param-reassign
screenshares = [ ...screenshares, ...screenshareSources ];
return screenshares;
}, []);
}
/**
* Returns screenshare participant's display name.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @param {string} id - The ID of the screenshare participant's display name to retrieve.
* @returns {string}
*/
export function getScreenshareParticipantDisplayName(stateful: IStateful, id: string) {
const ownerDisplayName = getParticipantDisplayName(stateful, getVirtualScreenshareParticipantOwnerId(id));
return i18next.t('screenshareDisplayName', { name: ownerDisplayName });
}
/**
* Returns a list of IDs of the participants that are currently screensharing.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @returns {Array<string>}
*/
export function getScreenshareParticipantIds(stateful: IStateful): Array<string> {
return toState(stateful)['features/base/tracks']
.filter(track => track.videoType === VIDEO_TYPE.DESKTOP && !track.muted)
.map(t => t.participantId);
}
/**
* Returns a list of source names associated with a given remote participant and for the given media type.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @param {string} id - The id of the participant whose source names are to be retrieved.
* @param {string} mediaType - The type of source, audio or video.
* @returns {Array<string>}
*/
export function getSourceNamesByMediaTypeAndParticipant(
stateful: IStateful,
id: string,
mediaType: string): Array<string> {
const participant: IParticipant | undefined = getParticipantById(stateful, id);
if (!participant) {
return [];
}
const sources = participant.sources;
if (!sources) {
return [];
}
return Array.from(sources.get(mediaType) ?? new Map())
.filter(source => source[1].videoType !== VIDEO_TYPE.DESKTOP || !source[1].muted)
.map(s => s[0]);
}
/**
* Returns a list of source names associated with a given remote participant and for the given video type (only for
* video sources).
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @param {string} id - The id of the participant whose source names are to be retrieved.
* @param {string} videoType - The type of video, camera or desktop.
* @returns {Array<string>}
*/
export function getSourceNamesByVideoTypeAndParticipant(
stateful: IStateful,
id: string,
videoType: string): Array<string> {
const participant: IParticipant | undefined = getParticipantById(stateful, id);
if (!participant) {
return [];
}
const sources = participant.sources;
if (!sources) {
return [];
}
return Array.from(sources.get(MEDIA_TYPE.VIDEO) ?? new Map())
.filter(source => source[1].videoType === videoType && (videoType === VIDEO_TYPE.CAMERA || !source[1].muted))
.map(s => s[0]);
}
/**
* Returns the presence status of a participant associated with the passed id.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {string} id - The id of the participant.
* @returns {string} - The presence status.
*/
export function getParticipantPresenceStatus(stateful: IStateful, id: string) {
if (!id) {
return undefined;
}
const participantById = getParticipantById(stateful, id);
if (!participantById) {
return undefined;
}
return participantById.presence;
}
/**
* 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 {Map<string, Object>}
*/
export function getRemoteParticipants(stateful: IStateful): Map<string, IParticipant> {
return toState(stateful)['features/base/participants'].remote;
}
/**
* Selectors for the getting the remote participants in the order that they are displayed in the filmstrip.
*
@param {(Function|Object)} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state features/filmstrip.
* @returns {Array<string>}
*/
export function getRemoteParticipantsSorted(stateful: IStateful) {
return toState(stateful)['features/filmstrip'].remoteParticipants;
}
/**
* Returns the participant which has its pinned state set to truthy.
*
* @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 {(IParticipant|undefined)}
*/
export function getPinnedParticipant(stateful: IStateful) {
const state = toState(stateful);
const { pinnedParticipant } = state['features/base/participants'];
const stageFilmstrip = isStageFilmstripAvailable(state);
if (stageFilmstrip) {
const { activeParticipants } = state['features/filmstrip'];
const id = activeParticipants.find(p => p.pinned)?.participantId;
return id ? getParticipantById(stateful, id) : undefined;
}
if (!pinnedParticipant) {
return undefined;
}
return getParticipantById(stateful, pinnedParticipant);
}
/**
* Returns true if the participant is a moderator.
*
* @param {string} participant - Participant object.
* @returns {boolean}
*/
export function isParticipantModerator(participant?: IParticipant) {
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 {IParticipant} - The participant from the redux store.
*/
export function getDominantSpeakerParticipant(stateful: IStateful) {
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.
*
* @param {Object|Function} stateful -Object or function that can be resolved
* to the Redux state.
* @returns {boolean}
*/
export function isEveryoneModerator(stateful: IStateful) {
const state = toState(stateful)['features/base/participants'];
return state.numberOfNonModeratorParticipants === 0;
}
/**
* Checks a value and returns true if it's a preloaded icon object.
*
* @param {?string | ?Object} icon - The icon to check.
* @returns {boolean}
*/
export function isIconUrl(icon?: string | Object) {
return Boolean(icon) && (typeof icon === 'object' || typeof icon === 'function');
}
/**
* Returns true if the current local participant is a moderator in the
* conference.
*
* @param {Object|Function} stateful - Object or function that can be resolved
* to the Redux state.
* @returns {boolean}
*/
export function isLocalParticipantModerator(stateful: IStateful) {
const state = toState(stateful)['features/base/participants'];
const { local } = state;
if (!local) {
return false;
}
return isParticipantModerator(local);
}
/**
* Resolves the first loadable avatar URL for a participant.
*
* @param {Object} participant - The participant to resolve avatars for.
* @param {Store} store - Redux store.
* @returns {?string}
*/
async function _getFirstLoadableAvatarUrl(participant: IParticipant, store: IStore) {
for (let i = 0; i < AVATAR_CHECKER_FUNCTIONS.length; i++) {
const url = AVATAR_CHECKER_FUNCTIONS[i](participant, store);
if (url !== null) {
if (AVATAR_CHECKED_URLS.has(url)) {
const { isLoadable, isUsingCORS } = AVATAR_CHECKED_URLS.get(url) || {};
if (isLoadable) {
return {
isUsingCORS,
src: url
};
}
} else {
try {
const { corsAvatarURLs } = store.getState()['features/base/config'];
const useCORS = isIconUrl(url) ? false : isCORSAvatarURL(url, corsAvatarURLs);
const { isUsingCORS, src } = await preloadImage(url, useCORS);
AVATAR_CHECKED_URLS.set(src, {
isLoadable: true,
isUsingCORS
});
return {
isUsingCORS,
src
};
} catch (e) {
AVATAR_CHECKED_URLS.set(url, {
isLoadable: false,
isUsingCORS: false
});
}
}
}
}
return undefined;
}
/**
* Get the participants queue with raised hands.
*
* @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 {Array<Object>}
*/
export function getRaiseHandsQueue(stateful: IStateful): Array<{ id: string; raisedHandTimestamp: number; }> {
const { raisedHandsQueue } = toState(stateful)['features/base/participants'];
return raisedHandsQueue;
}
/**
* Returns whether the given participant has his hand raised or not.
*
* @param {Object} participant - The participant.
* @returns {boolean} - Whether participant has raise hand or not.
*/
export function hasRaisedHand(participant?: IParticipant): boolean {
return Boolean(participant?.raisedHandTimestamp);
}
/**
* Add people feature enabling/disabling.
*
* @param {Object|Function} stateful - Object or function that can be resolved
* to the Redux state.
* @returns {boolean}
*/
export const addPeopleFeatureControl = (stateful: IStateful) => {
const state = toState(stateful);
return getFeatureFlag(state, ADD_PEOPLE_ENABLED, true)
&& (isAddPeopleEnabled(state) || isDialOutEnabled(state));
};
/**
* Controls share dialog visibility.
*
* @param {boolean} addPeopleFeatureEnabled - Checks if add people functionality is enabled.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Function}
*/
export const setShareDialogVisiblity = (addPeopleFeatureEnabled: boolean, dispatch: IStore['dispatch']) => {
if (addPeopleFeatureEnabled) {
dispatch(toggleShareDialog(false));
} else {
dispatch(toggleShareDialog(true));
}
};
/**
* Checks if private chat is enabled for the given participant.
*
* @param {IParticipant|IVisitorChatParticipant|undefined} participant - The participant to check.
* @param {IReduxState} state - The Redux state.
* @returns {boolean} - True if private chat is enabled, false otherwise.
*/
export function isPrivateChatEnabled(participant: IParticipant | IVisitorChatParticipant | undefined, state: IReduxState) {
const { remoteVideoMenu = {} } = state['features/base/config'];
const { disablePrivateChat } = remoteVideoMenu;
if ((!isVisitorChatParticipant(participant) && participant?.local) || disablePrivateChat === 'all') {
return false;
}
if (disablePrivateChat === 'disable-visitor-chat') {
// Block if the participant we're trying to message is a visitor
// OR if the local user is a visitor
if (isVisitorChatParticipant(participant) || iAmVisitor(state)) {
return false;
}
return true; // should allow private chat for other participants
}
if (disablePrivateChat === 'allow-moderator-chat') {
return isLocalParticipantModerator(state) || isParticipantModerator(participant);
}
return !disablePrivateChat;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../logging/functions';
export default getLogger('features/base/participants');

View File

@@ -0,0 +1,969 @@
import i18n from 'i18next';
import { batch } from 'react-redux';
import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import {
approveParticipant,
approveParticipantAudio,
approveParticipantDesktop,
approveParticipantVideo
} from '../../av-moderation/actions';
import {
AUDIO_RAISED_HAND_NOTIFICATION_ID,
DESKTOP_RAISED_HAND_NOTIFICATION_ID,
MEDIA_TYPE,
VIDEO_RAISED_HAND_NOTIFICATION_ID
} from '../../av-moderation/constants';
import { isForceMuted } from '../../av-moderation/functions';
import { UPDATE_BREAKOUT_ROOMS } from '../../breakout-rooms/actionTypes';
import { getBreakoutRooms } from '../../breakout-rooms/functions';
import { toggleE2EE } from '../../e2ee/actions';
import { MAX_MODE } from '../../e2ee/constants';
import { hideNotification, showNotification } from '../../notifications/actions';
import {
LOCAL_RECORDING_NOTIFICATION_ID,
NOTIFICATION_TIMEOUT_TYPE,
RAISE_HAND_NOTIFICATION_ID
} from '../../notifications/constants';
import { open as openParticipantsPane } from '../../participants-pane/actions';
import { CALLING, INVITED } from '../../presence-status/constants';
import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../../recording/constants';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app/actionTypes';
import { CONFERENCE_JOINED, CONFERENCE_WILL_JOIN } from '../conference/actionTypes';
import { forEachConference, getCurrentConference } from '../conference/functions';
import { IJitsiConference } from '../conference/reducer';
import { SET_CONFIG } from '../config/actionTypes';
import { getDisableRemoveRaisedHandOnFocus } from '../config/functions.any';
import { JitsiConferenceEvents } from '../lib-jitsi-meet';
import { VIDEO_TYPE } from '../media/constants';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { playSound, registerSound, unregisterSound } from '../sounds/actions';
import { isImageDataURL } from '../util/uri';
import {
DOMINANT_SPEAKER_CHANGED,
GRANT_MODERATOR,
KICK_PARTICIPANT,
LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT,
OVERWRITE_PARTICIPANTS_NAMES,
OVERWRITE_PARTICIPANT_NAME,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_MUTED_US,
PARTICIPANT_UPDATED,
RAISE_HAND_UPDATED,
SET_LOCAL_PARTICIPANT_RECORDING_STATUS
} from './actionTypes';
import {
localParticipantIdChanged,
localParticipantJoined,
localParticipantLeft,
overwriteParticipantName,
participantLeft,
participantUpdated,
raiseHand,
raiseHandUpdateQueue,
setLoadableAvatarUrl
} from './actions';
import {
LOCAL_PARTICIPANT_DEFAULT_ID,
LOWER_HAND_AUDIO_LEVEL,
PARTICIPANT_JOINED_SOUND_ID,
PARTICIPANT_LEFT_SOUND_ID
} from './constants';
import {
getDominantSpeakerParticipant,
getFirstLoadableAvatarUrl,
getLocalParticipant,
getParticipantById,
getParticipantCount,
getParticipantDisplayName,
getRaiseHandsQueue,
getRemoteParticipants,
hasRaisedHand,
isLocalParticipantModerator,
isScreenShareParticipant,
isWhiteboardParticipant
} from './functions';
import logger from './logger';
import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds';
import { IJitsiParticipant } from './types';
import './subscriber';
/**
* Middleware that captures CONFERENCE_JOINED and CONFERENCE_LEFT actions and
* updates respectively ID of local participant.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
_registerSounds(store);
return _localParticipantJoined(store, next, action);
case APP_WILL_UNMOUNT:
_unregisterSounds(store);
return _localParticipantLeft(store, next, action);
case CONFERENCE_WILL_JOIN:
store.dispatch(localParticipantIdChanged(action.conference.myUserId()));
break;
case DOMINANT_SPEAKER_CHANGED: {
// Lower hand through xmpp when local participant becomes dominant speaker.
const { id } = action.participant;
const state = store.getState();
const participant = getLocalParticipant(state);
const dominantSpeaker = getDominantSpeakerParticipant(state);
const isLocal = participant && participant.id === id;
if (isLocal && dominantSpeaker?.id !== id
&& hasRaisedHand(participant)
&& !getDisableRemoveRaisedHandOnFocus(state)) {
store.dispatch(raiseHand(false));
}
break;
}
case LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED: {
const state = store.getState();
const participant = getDominantSpeakerParticipant(state);
if (
participant?.local
&& hasRaisedHand(participant)
&& action.level > LOWER_HAND_AUDIO_LEVEL
&& !getDisableRemoveRaisedHandOnFocus(state)
) {
store.dispatch(raiseHand(false));
}
break;
}
case GRANT_MODERATOR: {
const { conference } = store.getState()['features/base/conference'];
conference?.grantOwner(action.id);
break;
}
case KICK_PARTICIPANT: {
const { conference } = store.getState()['features/base/conference'];
conference?.kickParticipant(action.id);
break;
}
case LOCAL_PARTICIPANT_RAISE_HAND: {
const { raisedHandTimestamp } = action;
const localId = getLocalParticipant(store.getState())?.id;
store.dispatch(participantUpdated({
// XXX Only the local participant is allowed to update without
// stating the JitsiConference instance (i.e. participant property
// `conference` for a remote participant) because the local
// participant is uniquely identified by the very fact that there is
// only one local participant.
id: localId ?? '',
local: true,
raisedHandTimestamp
}));
store.dispatch(raiseHandUpdateQueue({
id: localId ?? '',
raisedHandTimestamp
}));
if (typeof APP !== 'undefined') {
APP.API.notifyRaiseHandUpdated(localId, raisedHandTimestamp);
}
break;
}
case SET_CONFIG: {
const result = next(action);
const state = store.getState();
const { deploymentInfo } = state['features/base/config'];
// if there userRegion set let's use it for the local participant
if (deploymentInfo?.userRegion) {
const localId = getLocalParticipant(state)?.id;
if (localId) {
store.dispatch(participantUpdated({
id: localId,
local: true,
region: deploymentInfo.userRegion
}));
}
}
return result;
}
case CONFERENCE_JOINED: {
const result = next(action);
const state = store.getState();
const { startSilent } = state['features/base/config'];
if (startSilent) {
const localId = getLocalParticipant(store.getState())?.id;
if (localId) {
store.dispatch(participantUpdated({
id: localId,
local: true,
isSilent: startSilent
}));
}
}
return result;
}
case SET_LOCAL_PARTICIPANT_RECORDING_STATUS: {
const state = store.getState();
const { recording, onlySelf } = action;
const localId = getLocalParticipant(state)?.id;
const { localRecording } = state['features/base/config'];
if (localRecording?.notifyAllParticipants && !onlySelf && localId) {
store.dispatch(participantUpdated({
// XXX Only the local participant is allowed to update without
// stating the JitsiConference instance (i.e. participant property
// `conference` for a remote participant) because the local
// participant is uniquely identified by the very fact that there is
// only one local participant.
id: localId,
local: true,
localRecording: recording
}));
}
break;
}
case MUTE_REMOTE_PARTICIPANT: {
const { conference } = store.getState()['features/base/conference'];
conference?.muteParticipant(action.id, action.mediaType);
break;
}
case RAISE_HAND_UPDATED: {
const { participant } = action;
let queue = getRaiseHandsQueue(store.getState());
if (participant.raisedHandTimestamp) {
queue = [ ...queue, { id: participant.id,
raisedHandTimestamp: participant.raisedHandTimestamp } ];
// sort the queue before adding to store.
queue = queue.sort(({ raisedHandTimestamp: a }, { raisedHandTimestamp: b }) => a - b);
} else {
// no need to sort on remove value.
queue = queue.filter(({ id }) => id !== participant.id);
}
action.queue = queue;
break;
}
case PARTICIPANT_JOINED: {
// Do not play sounds when a screenshare or whiteboard participant tile is created for screenshare.
(!isScreenShareParticipant(action.participant)
&& !isWhiteboardParticipant(action.participant)
) && _maybePlaySounds(store, action);
return _participantJoinedOrUpdated(store, next, action);
}
case PARTICIPANT_LEFT: {
// Do not play sounds when a tile for screenshare or whiteboard is removed.
(!isScreenShareParticipant(action.participant)
&& !isWhiteboardParticipant(action.participant)
) && _maybePlaySounds(store, action);
break;
}
case PARTICIPANT_MUTED_US: {
const { dispatch, getState } = store;
const { participant, track } = action;
let titleKey;
if (track.isAudioTrack()) {
titleKey = 'notify.mutedRemotelyTitle';
} else if (track.isVideoTrack()) {
if (track.getVideoType() === VIDEO_TYPE.DESKTOP) {
titleKey = 'notify.desktopMutedRemotelyTitle';
} else {
titleKey = 'notify.videoMutedRemotelyTitle';
}
}
dispatch(showNotification({
titleKey,
titleArguments: {
participantDisplayName: getParticipantDisplayName(getState, participant.getId())
}
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
break;
}
case PARTICIPANT_UPDATED:
return _participantJoinedOrUpdated(store, next, action);
case OVERWRITE_PARTICIPANTS_NAMES: {
const { participantList } = action;
if (!Array.isArray(participantList)) {
logger.error('Overwrite names failed. Argument is not an array.');
return;
}
batch(() => {
participantList.forEach(p => {
store.dispatch(overwriteParticipantName(p.id, p.name));
});
});
break;
}
case OVERWRITE_PARTICIPANT_NAME: {
const { dispatch, getState } = store;
const state = getState();
const { id, name } = action;
let breakoutRoom = false, identifier = id;
if (id.indexOf('@') !== -1) {
identifier = id.slice(id.indexOf('/') + 1);
breakoutRoom = true;
action.id = identifier;
}
if (breakoutRoom) {
const rooms = getBreakoutRooms(state);
const roomCounter = state['features/breakout-rooms'].roomCounter;
const newRooms: any = {};
Object.entries(rooms).forEach(([ key, r ]) => {
const participants = r?.participants || {};
const jid = Object.keys(participants).find(p =>
p.slice(p.indexOf('/') + 1) === identifier);
if (jid) {
newRooms[key] = {
...r,
participants: {
...participants,
[jid]: {
...participants[jid],
displayName: name
}
}
};
} else {
newRooms[key] = r;
}
});
dispatch({
type: UPDATE_BREAKOUT_ROOMS,
rooms,
roomCounter,
updatedNames: true
});
} else {
dispatch(participantUpdated({
id: identifier,
name
}));
}
break;
}
}
return next(action);
});
/**
* Syncs the redux state features/base/participants up with the redux state
* features/base/conference by ensuring that the former does not contain remote
* participants no longer relevant to the latter. Introduced to address an issue
* with multiplying thumbnails in the filmstrip.
*/
StateListenerRegistry.register(
/* selector */ state => getCurrentConference(state),
/* listener */ (conference, { dispatch, getState }) => {
batch(() => {
for (const [ id, p ] of getRemoteParticipants(getState())) {
(!conference || p.conference !== conference)
&& dispatch(participantLeft(id, p.conference, {
isReplaced: p.isReplaced
}));
}
});
});
/**
* Reset the ID of the local participant to
* {@link LOCAL_PARTICIPANT_DEFAULT_ID}. Such a reset is deemed possible only if
* the local participant and, respectively, her ID is not involved in a
* conference which is still of interest to the user and, consequently, the app.
* For example, a conference which is in the process of leaving is no longer of
* interest the user, is unrecoverable from the perspective of the user and,
* consequently, the app.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/conference'],
/* listener */ ({ leaving }, { dispatch, getState }) => {
const state = getState();
const localParticipant = getLocalParticipant(state);
let id: string;
if (!localParticipant
|| (id = localParticipant.id)
=== LOCAL_PARTICIPANT_DEFAULT_ID) {
// The ID of the local participant has been reset already.
return;
}
// The ID of the local may be reset only if it is not in use.
const dispatchLocalParticipantIdChanged
= forEachConference(
state,
conference =>
conference === leaving || conference.myUserId() !== id);
dispatchLocalParticipantIdChanged
&& dispatch(
localParticipantIdChanged(LOCAL_PARTICIPANT_DEFAULT_ID));
});
/**
* Registers listeners for participant change events.
*/
StateListenerRegistry.register(
state => state['features/base/conference'].conference,
(conference, store) => {
if (conference) {
const propertyHandlers: {
[key: string]: Function;
} = {
'e2ee.enabled': (participant: IJitsiParticipant, value: string) =>
_e2eeUpdated(store, conference, participant.getId(), value),
'features_e2ee': (participant: IJitsiParticipant, value: boolean) =>
getParticipantById(store.getState(), participant.getId())?.e2eeSupported !== value
&& store.dispatch(participantUpdated({
conference,
id: participant.getId(),
e2eeSupported: value
})),
'features_jigasi': (participant: IJitsiParticipant, value: boolean) =>
store.dispatch(participantUpdated({
conference,
id: participant.getId(),
isJigasi: value
})),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
'features_screen-sharing': (participant: IJitsiParticipant, value: string) =>
store.dispatch(participantUpdated({
conference,
id: participant.getId(),
features: { 'screen-sharing': true }
})),
'localRecording': (participant: IJitsiParticipant, value: string) =>
_localRecordingUpdated(store, conference, participant.getId(), Boolean(value)),
'raisedHand': (participant: IJitsiParticipant, value: string) =>
_raiseHandUpdated(store, conference, participant.getId(), value),
'region': (participant: IJitsiParticipant, value: string) =>
store.dispatch(participantUpdated({
conference,
id: participant.getId(),
region: value
})),
'remoteControlSessionStatus': (participant: IJitsiParticipant, value: boolean) =>
store.dispatch(participantUpdated({
conference,
id: participant.getId(),
remoteControlSessionStatus: value
}))
};
// update properties for the participants that are already in the conference
conference.getParticipants().forEach((participant: any) => {
Object.keys(propertyHandlers).forEach(propertyName => {
const value = participant.getProperty(propertyName);
if (value !== undefined) {
propertyHandlers[propertyName as keyof typeof propertyHandlers](participant, value);
}
});
});
// We joined a conference
conference.on(
JitsiConferenceEvents.PARTICIPANT_PROPERTY_CHANGED,
(participant: IJitsiParticipant, propertyName: string, oldValue: string, newValue: string) => {
if (propertyHandlers.hasOwnProperty(propertyName)) {
propertyHandlers[propertyName](participant, newValue);
}
});
} else {
const localParticipantId = getLocalParticipant(store.getState)?.id;
// We left the conference, the local participant must be updated.
_e2eeUpdated(store, conference, localParticipantId ?? '', false);
_raiseHandUpdated(store, conference, localParticipantId ?? '', 0);
}
}
);
/**
* Handles a E2EE enabled status update.
*
* @param {Store} store - The redux store.
* @param {Object} conference - The conference for which we got an update.
* @param {string} participantId - The ID of the participant from which we got an update.
* @param {boolean} newValue - The new value of the E2EE enabled status.
* @returns {void}
*/
function _e2eeUpdated({ getState, dispatch }: IStore, conference: IJitsiConference,
participantId: string, newValue: string | boolean) {
const e2eeEnabled = newValue === 'true';
const state = getState();
const { e2ee = {} } = state['features/base/config'];
if (e2eeEnabled === getParticipantById(state, participantId)?.e2eeEnabled) {
return;
}
dispatch(participantUpdated({
conference,
id: participantId,
e2eeEnabled
}));
if (e2ee.externallyManagedKey) {
return;
}
const { maxMode } = getState()['features/e2ee'] || {};
if (maxMode !== MAX_MODE.THRESHOLD_EXCEEDED || !e2eeEnabled) {
dispatch(toggleE2EE(e2eeEnabled));
}
}
/**
* Initializes the local participant and signals that it joined.
*
* @private
* @param {Store} store - The redux store.
* @param {Dispatch} next - The redux dispatch function to dispatch the
* specified action to the specified store.
* @param {Action} action - The redux action which is being dispatched
* in the specified store.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _localParticipantJoined({ getState, dispatch }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const settings = getState()['features/base/settings'];
dispatch(localParticipantJoined({
avatarURL: settings.avatarURL,
email: settings.email,
name: settings.displayName,
id: ''
}));
return result;
}
/**
* Signals that the local participant has left.
*
* @param {Store} store - The redux store.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} into the specified {@code store}.
* @param {Action} action - The redux action which is being dispatched in the
* specified {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _localParticipantLeft({ dispatch }: IStore, next: Function, action: AnyAction) {
const result = next(action);
dispatch(localParticipantLeft());
return result;
}
/**
* Plays sounds when participants join/leave conference.
*
* @param {Store} store - The redux store.
* @param {Action} action - The redux action. Should be either
* {@link PARTICIPANT_JOINED} or {@link PARTICIPANT_LEFT}.
* @private
* @returns {void}
*/
function _maybePlaySounds({ getState, dispatch }: IStore, action: AnyAction) {
const state = getState();
const { startAudioMuted } = state['features/base/config'];
const { soundsParticipantJoined: joinSound, soundsParticipantLeft: leftSound } = state['features/base/settings'];
// We're not playing sounds for local participant
// nor when the user is joining past the "startAudioMuted" limit.
// The intention there was to not play user joined notification in big
// conferences where 100th person is joining.
if (!action.participant.local
&& (!startAudioMuted
|| getParticipantCount(state) < startAudioMuted)) {
const { isReplacing, isReplaced } = action.participant;
if (action.type === PARTICIPANT_JOINED) {
if (!joinSound) {
return;
}
const { presence } = action.participant;
// The sounds for the poltergeist are handled by features/invite.
if (presence !== INVITED && presence !== CALLING && !isReplacing) {
dispatch(playSound(PARTICIPANT_JOINED_SOUND_ID));
}
} else if (action.type === PARTICIPANT_LEFT && !isReplaced && leftSound) {
dispatch(playSound(PARTICIPANT_LEFT_SOUND_ID));
}
}
}
/**
* Notifies the feature base/participants that the action
* {@code PARTICIPANT_JOINED} or {@code PARTICIPANT_UPDATED} is being dispatched
* within a specific redux store.
*
* @param {Store} store - The redux store in which the specified {@code action}
* is being dispatched.
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
* specified {@code action} in the specified {@code store}.
* @param {Action} action - The redux action {@code PARTICIPANT_JOINED} or
* {@code PARTICIPANT_UPDATED} which is being dispatched in the specified
* {@code store}.
* @private
* @returns {Object} The value returned by {@code next(action)}.
*/
function _participantJoinedOrUpdated(store: IStore, next: Function, action: AnyAction) {
const { dispatch, getState } = store;
const { overwrittenNameList } = store.getState()['features/base/participants'];
const { participant: {
avatarURL,
email,
id,
local,
localRecording,
name,
raisedHandTimestamp
} } = action;
// Send an external update of the local participant's raised hand state
// if a new raised hand state is defined in the action.
if (typeof raisedHandTimestamp !== 'undefined') {
if (local) {
const { conference } = getState()['features/base/conference'];
const rHand = parseInt(raisedHandTimestamp, 10);
// Send raisedHand signalling only if there is a change
if (conference && rHand !== getLocalParticipant(getState())?.raisedHandTimestamp) {
conference.setLocalParticipantProperty('raisedHand', rHand);
}
}
}
if (overwrittenNameList[id]) {
action.participant.name = overwrittenNameList[id];
}
// Send an external update of the local participant's local recording state
// if a new local recording state is defined in the action.
if (typeof localRecording !== 'undefined') {
if (local) {
const conference = getCurrentConference(getState);
// Send localRecording signalling only if there is a change
if (conference
&& localRecording !== getLocalParticipant(getState())?.localRecording) {
conference.setLocalParticipantProperty('localRecording', localRecording);
}
}
}
// Allow the redux update to go through and compare the old avatar
// to the new avatar and emit out change events if necessary.
const result = next(action);
// Only run this if the config is populated, otherwise we preload external resources
// even if disableThirdPartyRequests is set to true in config
if (getState()['features/base/config']?.hosts) {
const { disableThirdPartyRequests } = getState()['features/base/config'];
const participantId = !id && local ? getLocalParticipant(getState())?.id : id;
if (avatarURL || email || id || name) {
if (!disableThirdPartyRequests) {
const updatedParticipant = getParticipantById(getState(), participantId);
getFirstLoadableAvatarUrl(updatedParticipant ?? { id: '' }, store)
.then((urlData?: { isUsingCORS: boolean; src: string; }) => {
dispatch(setLoadableAvatarUrl(
participantId, urlData?.src ?? '', Boolean(urlData?.isUsingCORS)));
});
} else if (isImageDataURL(avatarURL)) {
dispatch(setLoadableAvatarUrl(participantId, avatarURL, false));
}
}
}
return result;
}
/**
* Handles a local recording status update.
*
* @param {Function} dispatch - The Redux dispatch function.
* @param {Object} conference - The conference for which we got an update.
* @param {string} participantId - The ID of the participant from which we got an update.
* @param {boolean} newValue - The new value of the local recording status.
* @returns {void}
*/
function _localRecordingUpdated({ dispatch, getState }: IStore, conference: IJitsiConference,
participantId: string, newValue: boolean) {
const state = getState();
const participant = getParticipantById(state, participantId);
if (participant?.localRecording === newValue) {
return;
}
dispatch(participantUpdated({
conference,
id: participantId,
localRecording: newValue
}));
const participantName = getParticipantDisplayName(state, participantId);
dispatch(showNotification({
titleKey: 'notify.somebody',
title: participantName,
descriptionKey: newValue ? 'notify.localRecordingStarted' : 'notify.localRecordingStopped',
uid: LOCAL_RECORDING_NOTIFICATION_ID
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
dispatch(playSound(newValue ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
}
/**
* Handles a raise hand status update.
*
* @param {Function} dispatch - The Redux dispatch function.
* @param {Object} conference - The conference for which we got an update.
* @param {string} participantId - The ID of the participant from which we got an update.
* @param {boolean} newValue - The new value of the raise hand status.
* @returns {void}
*/
function _raiseHandUpdated({ dispatch, getState }: IStore, conference: IJitsiConference,
participantId: string, newValue: string | number) {
let raisedHandTimestamp;
switch (newValue) {
case undefined:
case 'false':
raisedHandTimestamp = 0;
break;
case 'true':
raisedHandTimestamp = Date.now();
break;
default:
raisedHandTimestamp = parseInt(`${newValue}`, 10);
}
const state = getState();
dispatch(participantUpdated({
conference,
id: participantId,
raisedHandTimestamp
}));
dispatch(raiseHandUpdateQueue({
id: participantId,
raisedHandTimestamp
}));
if (typeof APP !== 'undefined') {
APP.API.notifyRaiseHandUpdated(participantId, raisedHandTimestamp);
}
if (!raisedHandTimestamp) {
return;
}
// Display notifications about raised hands.
const isModerator = isLocalParticipantModerator(state);
const participant = getParticipantById(state, participantId);
const participantName = getParticipantDisplayName(state, participantId);
let shouldDisplayAllowAudio = false;
let shouldDisplayAllowVideo = false;
let shouldDisplayAllowDesktop = false;
if (isModerator) {
shouldDisplayAllowAudio = isForceMuted(participant, MEDIA_TYPE.AUDIO, state);
shouldDisplayAllowVideo = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
shouldDisplayAllowDesktop = isForceMuted(participant, MEDIA_TYPE.DESKTOP, state);
}
if (shouldDisplayAllowAudio || shouldDisplayAllowVideo || shouldDisplayAllowDesktop) {
const action: {
customActionHandler: Array<() => void>;
customActionNameKey: string[];
} = {
customActionHandler: [],
customActionNameKey: [],
};
// Always add a "allow all" at the end of the list.
action.customActionNameKey.push('notify.allowAll');
action.customActionHandler.push(() => {
dispatch(approveParticipant(participantId));
dispatch(hideNotification(AUDIO_RAISED_HAND_NOTIFICATION_ID));
dispatch(hideNotification(DESKTOP_RAISED_HAND_NOTIFICATION_ID));
dispatch(hideNotification(VIDEO_RAISED_HAND_NOTIFICATION_ID));
});
if (shouldDisplayAllowAudio) {
const customActionNameKey = action.customActionNameKey.slice();
const customActionHandler = action.customActionHandler.slice();
customActionNameKey.unshift('notify.allowAudio');
customActionHandler.unshift(() => {
dispatch(approveParticipantAudio(participantId));
dispatch(hideNotification(AUDIO_RAISED_HAND_NOTIFICATION_ID));
});
dispatch(showNotification({
title: participantName,
descriptionKey: 'notify.raisedHand',
uid: AUDIO_RAISED_HAND_NOTIFICATION_ID,
customActionNameKey,
customActionHandler,
}, NOTIFICATION_TIMEOUT_TYPE.EXTRA_LONG));
}
if (shouldDisplayAllowVideo) {
const customActionNameKey = action.customActionNameKey.slice();
const customActionHandler = action.customActionHandler.slice();
customActionNameKey.unshift('notify.allowVideo');
customActionHandler.unshift(() => {
dispatch(approveParticipantVideo(participantId));
dispatch(hideNotification(VIDEO_RAISED_HAND_NOTIFICATION_ID));
});
dispatch(showNotification({
title: participantName,
descriptionKey: 'notify.raisedHand',
uid: VIDEO_RAISED_HAND_NOTIFICATION_ID,
customActionNameKey,
customActionHandler,
}, NOTIFICATION_TIMEOUT_TYPE.EXTRA_LONG));
}
if (shouldDisplayAllowDesktop) {
const customActionNameKey = action.customActionNameKey.slice();
const customActionHandler = action.customActionHandler.slice();
customActionNameKey.unshift('notify.allowDesktop');
customActionHandler.unshift(() => {
dispatch(approveParticipantDesktop(participantId));
dispatch(hideNotification(DESKTOP_RAISED_HAND_NOTIFICATION_ID));
});
dispatch(showNotification({
title: participantName,
descriptionKey: 'notify.raisedHand',
uid: DESKTOP_RAISED_HAND_NOTIFICATION_ID,
customActionNameKey,
customActionHandler
}, NOTIFICATION_TIMEOUT_TYPE.EXTRA_LONG));
}
} else {
let notificationTitle;
const { raisedHandsQueue } = state['features/base/participants'];
if (raisedHandsQueue.length > 1) {
notificationTitle = i18n.t('notify.raisedHands', {
participantName,
raisedHands: raisedHandsQueue.length - 1
});
} else {
notificationTitle = participantName;
}
dispatch(showNotification({
titleKey: 'notify.somebody',
title: notificationTitle,
descriptionKey: 'notify.raisedHand',
concatText: true,
uid: RAISE_HAND_NOTIFICATION_ID,
customActionNameKey: [ 'notify.viewParticipants' ],
customActionHandler: [ () => dispatch(openParticipantsPane()) ]
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
}
dispatch(playSound(RAISE_HAND_SOUND_ID));
}
/**
* Registers sounds related with the participants feature.
*
* @param {Store} store - The redux store.
* @private
* @returns {void}
*/
function _registerSounds({ dispatch }: IStore) {
dispatch(
registerSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_FILE));
dispatch(registerSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_FILE));
}
/**
* Unregisters sounds related with the participants feature.
*
* @param {Store} store - The redux store.
* @private
* @returns {void}
*/
function _unregisterSounds({ dispatch }: IStore) {
dispatch(unregisterSound(PARTICIPANT_JOINED_SOUND_ID));
dispatch(unregisterSound(PARTICIPANT_LEFT_SOUND_ID));
}

View File

@@ -0,0 +1,26 @@
import { Image } from 'react-native';
import { isIconUrl } from './functions';
/**
* Tries to preload an image.
*
* @param {string | Object} src - Source of the avatar.
* @param {boolean} _isUsingCORS - Used on web.
* @returns {Promise}
*/
export function preloadImage(src: string | Object, _isUsingCORS: boolean): Promise<any> {
if (isIconUrl(src)) {
return Promise.resolve(src);
}
return new Promise((resolve, reject) => {
// @ts-ignore
Image.prefetch(src).then(
() => resolve({
src,
isUsingCORS: false
}),
reject);
});
}

View File

@@ -0,0 +1,46 @@
import { isIconUrl } from './functions';
/**
* Tries to preload an image.
*
* @param {string | Object} src - Source of the avatar.
* @param {boolean} useCORS - Whether to use CORS or not.
* @param {boolean} tryOnce - If true we try to load the image only using the specified CORS mode. Otherwise both modes
* (CORS and no CORS) will be used to load the image if the first attempt fails.
* @returns {Promise}
*/
export function preloadImage(
src: string,
useCORS = false,
tryOnce = false
): Promise<{ isUsingCORS?: boolean; src: string | Object; }> {
if (isIconUrl(src)) {
return Promise.resolve({ src });
}
return new Promise((resolve, reject) => {
const image = document.createElement('img');
if (useCORS) {
image.setAttribute('crossOrigin', '');
}
image.onload = () => resolve({
src,
isUsingCORS: useCORS
});
image.onerror = error => {
if (tryOnce) {
reject(error);
} else {
preloadImage(src, !useCORS, true)
.then(resolve)
.catch(reject);
}
};
image.referrerPolicy = 'no-referrer';
image.src = src;
});
}

View File

@@ -0,0 +1,694 @@
import { AnyAction } from 'redux';
import { UPDATE_CONFERENCE_METADATA } from '../conference/actionTypes';
import { MEDIA_TYPE } from '../media/constants';
import ReducerRegistry from '../redux/ReducerRegistry';
import { set } from '../redux/functions';
import {
DOMINANT_SPEAKER_CHANGED,
NOTIFIED_TO_SPEAK,
OVERWRITE_PARTICIPANT_NAME,
PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_SOURCES_UPDATED,
PARTICIPANT_UPDATED,
PIN_PARTICIPANT,
RAISE_HAND_CLEAR,
RAISE_HAND_UPDATED,
SCREENSHARE_PARTICIPANT_NAME_CHANGED,
SET_LOADABLE_AVATAR_URL
} from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
import {
isLocalScreenshareParticipant,
isParticipantModerator,
isRemoteScreenshareParticipant,
isScreenShareParticipant
} from './functions';
import { FakeParticipant, ILocalParticipant, IParticipant, ISourceInfo } from './types';
/**
* Participant object.
*
* @typedef {Object} Participant
* @property {string} id - Participant ID.
* @property {string} name - Participant name.
* @property {string} avatar - Path to participant avatar if any.
* @property {string} role - Participant role.
* @property {boolean} local - If true, participant is local.
* @property {boolean} pinned - If true, participant is currently a
* "PINNED_ENDPOINT".
* @property {boolean} dominantSpeaker - If this participant is the dominant
* speaker in the (associated) conference, {@code true}; otherwise,
* {@code false}.
* @property {string} email - Participant email.
*/
/**
* The participant properties which cannot be updated through
* {@link PARTICIPANT_UPDATED}. They either identify the participant or can only
* be modified through property-dedicated actions.
*
* @type {string[]}
*/
const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
// The following properties identify the participant:
'conference',
'id',
'local',
// The following properties can only be modified through property-dedicated
// actions:
'dominantSpeaker',
'pinned'
];
const DEFAULT_STATE = {
dominantSpeaker: undefined,
fakeParticipants: new Map(),
local: undefined,
localScreenShare: undefined,
numberOfNonModeratorParticipants: 0,
numberOfParticipantsDisabledE2EE: 0,
numberOfParticipantsNotSupportingE2EE: 0,
overwrittenNameList: {},
pinnedParticipant: undefined,
raisedHandsQueue: [],
remote: new Map(),
remoteVideoSources: new Set<string>(),
sortedRemoteVirtualScreenshareParticipants: new Map(),
sortedRemoteParticipants: new Map(),
speakersList: new Map()
};
export interface IParticipantsState {
dominantSpeaker?: string;
fakeParticipants: Map<string, IParticipant>;
local?: ILocalParticipant;
localScreenShare?: IParticipant;
numberOfNonModeratorParticipants: number;
numberOfParticipantsDisabledE2EE: number;
numberOfParticipantsNotSupportingE2EE: number;
overwrittenNameList: { [id: string]: string; };
pinnedParticipant?: string;
raisedHandsQueue: Array<{ hasBeenNotified?: boolean; id: string; raisedHandTimestamp: number; }>;
remote: Map<string, IParticipant>;
remoteVideoSources: Set<string>;
sortedRemoteParticipants: Map<string, string>;
sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
speakersList: Map<string, string>;
}
/**
* Listen for actions which add, remove, or update the set of participants in
* the conference.
*
* @param {IParticipant[]} state - List of participants to be modified.
* @param {Object} action - Action object.
* @param {string} action.type - Type of action.
* @param {IParticipant} action.participant - Information about participant to be
* added/removed/modified.
* @returns {IParticipant[]}
*/
ReducerRegistry.register<IParticipantsState>('features/base/participants',
(state = DEFAULT_STATE, action): IParticipantsState => {
switch (action.type) {
case NOTIFIED_TO_SPEAK: {
return {
...state,
raisedHandsQueue: state.raisedHandsQueue.map((item, index) => {
if (index === 0) {
return {
...item,
hasBeenNotified: true
};
}
return item;
})
};
}
case PARTICIPANT_ID_CHANGED: {
const { local } = state;
if (local) {
if (action.newValue === 'local' && state.raisedHandsQueue.find(pid => pid.id === local.id)) {
state.raisedHandsQueue = state.raisedHandsQueue.filter(pid => pid.id !== local.id);
}
state.local = {
...local,
id: action.newValue
};
return {
...state
};
}
return state;
}
case DOMINANT_SPEAKER_CHANGED: {
const { participant } = action;
const { id, previousSpeakers = [] } = participant;
const { dominantSpeaker, local } = state;
const newSpeakers = [ id, ...previousSpeakers ];
const sortedSpeakersList: Array<Array<string>> = [];
for (const speaker of newSpeakers) {
if (speaker !== local?.id) {
const remoteParticipant = state.remote.get(speaker);
remoteParticipant
&& sortedSpeakersList.push(
[ speaker, _getDisplayName(state, remoteParticipant?.name) ]
);
}
}
// Keep the remote speaker list sorted alphabetically.
sortedSpeakersList.sort((a, b) => a[1].localeCompare(b[1]));
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
_updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
}
if (_updateParticipantProperty(state, id, 'dominantSpeaker', true)) {
return {
...state,
dominantSpeaker: id, // @ts-ignore
speakersList: new Map(sortedSpeakersList)
};
}
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 (id && _updateParticipantProperty(state, id, 'pinned', true)) {
return {
...state,
pinnedParticipant: id
};
}
delete state.pinnedParticipant;
return {
...state
};
}
case SET_LOADABLE_AVATAR_URL:
case PARTICIPANT_UPDATED: {
const { participant } = action;
let { id } = participant;
const { local } = participant;
if (!id && local) {
id = LOCAL_PARTICIPANT_DEFAULT_ID;
}
let newParticipant: IParticipant | null = null;
const oldParticipant = local || state.local?.id === id ? state.local : state.remote.get(id);
if (state.remote.has(id)) {
newParticipant = _participant(oldParticipant, action);
state.remote.set(id, newParticipant);
} else if (id === state.local?.id) {
newParticipant = state.local = _participant(state.local, action);
}
if (oldParticipant && newParticipant && !newParticipant.fakeParticipant) {
const isModerator = isParticipantModerator(newParticipant);
if (isParticipantModerator(oldParticipant) !== isModerator) {
state.numberOfNonModeratorParticipants += isModerator ? -1 : 1;
}
const e2eeEnabled = Boolean(newParticipant.e2eeEnabled);
const e2eeSupported = Boolean(newParticipant.e2eeSupported);
if (Boolean(oldParticipant.e2eeEnabled) !== e2eeEnabled) {
state.numberOfParticipantsDisabledE2EE += e2eeEnabled ? -1 : 1;
}
if (!local && Boolean(oldParticipant.e2eeSupported) !== e2eeSupported) {
state.numberOfParticipantsNotSupportingE2EE += e2eeSupported ? -1 : 1;
}
}
return {
...state
};
}
case SCREENSHARE_PARTICIPANT_NAME_CHANGED: {
const { id, name } = action;
if (state.sortedRemoteVirtualScreenshareParticipants.has(id)) {
state.sortedRemoteVirtualScreenshareParticipants.delete(id);
const sortedRemoteVirtualScreenshareParticipants = [ ...state.sortedRemoteVirtualScreenshareParticipants ];
sortedRemoteVirtualScreenshareParticipants.push([ id, name ]);
sortedRemoteVirtualScreenshareParticipants.sort((a, b) => a[1].localeCompare(b[1]));
state.sortedRemoteVirtualScreenshareParticipants = new Map(sortedRemoteVirtualScreenshareParticipants);
}
return { ...state };
}
case PARTICIPANT_JOINED: {
const participant = _participantJoined(action);
const {
fakeParticipant,
id,
name,
pinned,
sources
} = participant;
const { pinnedParticipant, dominantSpeaker } = state;
if (pinned) {
if (pinnedParticipant) {
_updateParticipantProperty(state, pinnedParticipant, 'pinned', false);
}
state.pinnedParticipant = id;
}
if (participant.dominantSpeaker) {
if (dominantSpeaker) {
_updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
}
state.dominantSpeaker = id;
}
if (!fakeParticipant) {
const isModerator = isParticipantModerator(participant);
if (!isModerator) {
state.numberOfNonModeratorParticipants += 1;
}
const { e2eeEnabled, e2eeSupported } = participant as IParticipant;
if (!e2eeEnabled) {
state.numberOfParticipantsDisabledE2EE += 1;
}
if (!participant.local && !e2eeSupported) {
state.numberOfParticipantsNotSupportingE2EE += 1;
}
}
if (participant.local) {
return {
...state,
local: participant
};
}
if (isLocalScreenshareParticipant(participant)) {
return {
...state,
localScreenShare: participant
};
}
state.remote.set(id, participant);
if (sources?.size) {
const videoSources: Map<string, ISourceInfo> | undefined = sources.get(MEDIA_TYPE.VIDEO);
if (videoSources?.size) {
const newRemoteVideoSources = new Set(state.remoteVideoSources);
for (const source of videoSources.keys()) {
newRemoteVideoSources.add(source);
}
state.remoteVideoSources = newRemoteVideoSources;
}
}
// Insert the new participant.
const displayName = _getDisplayName(state, name);
const sortedRemoteParticipants = Array.from(state.sortedRemoteParticipants);
sortedRemoteParticipants.push([ id, displayName ]);
sortedRemoteParticipants.sort((a, b) => a[1].localeCompare(b[1]));
// The sort order of participants is preserved since Map remembers the original insertion order of the keys.
state.sortedRemoteParticipants = new Map(sortedRemoteParticipants);
if (isRemoteScreenshareParticipant(participant)) {
const sortedRemoteVirtualScreenshareParticipants = [ ...state.sortedRemoteVirtualScreenshareParticipants ];
sortedRemoteVirtualScreenshareParticipants.push([ id, name ?? '' ]);
sortedRemoteVirtualScreenshareParticipants.sort((a, b) => a[1].localeCompare(b[1]));
state.sortedRemoteVirtualScreenshareParticipants = new Map(sortedRemoteVirtualScreenshareParticipants);
}
// Exclude the screenshare participant from the fake participant count to avoid duplicates.
if (fakeParticipant && !isScreenShareParticipant(participant)) {
state.fakeParticipants.set(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
// identified by the very fact that there is only one local participant
// (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,
sortedRemoteVirtualScreenshareParticipants,
remote,
local,
localScreenShare,
dominantSpeaker,
pinnedParticipant
} = state;
let oldParticipant = remote.get(id);
let isLocalScreenShare = false;
if (oldParticipant && oldParticipant.conference === conference) {
remote.delete(id);
} else if (local?.id === id) {
oldParticipant = state.local;
delete state.local;
} else if (localScreenShare?.id === id) {
isLocalScreenShare = true;
oldParticipant = state.local;
delete state.localScreenShare;
} else {
// no participant found
return state;
}
if (oldParticipant?.sources?.size) {
const videoSources: Map<string, ISourceInfo> | undefined = oldParticipant.sources.get(MEDIA_TYPE.VIDEO);
if (videoSources?.size) {
const newRemoteVideoSources = new Set(state.remoteVideoSources);
for (const source of videoSources.keys()) {
newRemoteVideoSources.delete(source);
}
state.remoteVideoSources = newRemoteVideoSources;
}
} else if (oldParticipant?.fakeParticipant === FakeParticipant.RemoteScreenShare) {
const newRemoteVideoSources = new Set(state.remoteVideoSources);
if (newRemoteVideoSources.delete(id)) {
state.remoteVideoSources = newRemoteVideoSources;
}
}
state.sortedRemoteParticipants.delete(id);
state.raisedHandsQueue = state.raisedHandsQueue.filter(pid => pid.id !== id);
if (dominantSpeaker === id) {
state.dominantSpeaker = undefined;
}
// Remove the participant from the list of speakers.
state.speakersList.has(id) && state.speakersList.delete(id);
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;
}
if (fakeParticipants.has(id)) {
fakeParticipants.delete(id);
}
if (sortedRemoteVirtualScreenshareParticipants.has(id)) {
sortedRemoteVirtualScreenshareParticipants.delete(id);
state.sortedRemoteVirtualScreenshareParticipants = new Map(sortedRemoteVirtualScreenshareParticipants);
}
if (oldParticipant && !oldParticipant.fakeParticipant && !isLocalScreenShare) {
const { e2eeEnabled, e2eeSupported } = oldParticipant;
if (!isParticipantModerator(oldParticipant)) {
state.numberOfNonModeratorParticipants -= 1;
}
if (!e2eeEnabled) {
state.numberOfParticipantsDisabledE2EE -= 1;
}
if (!oldParticipant.local && !e2eeSupported) {
state.numberOfParticipantsNotSupportingE2EE -= 1;
}
}
return { ...state };
}
case PARTICIPANT_SOURCES_UPDATED: {
const { id, sources } = action.participant;
const participant = state.remote.get(id);
if (participant) {
participant.sources = sources;
const videoSources: Map<string, ISourceInfo> = sources.get(MEDIA_TYPE.VIDEO);
if (videoSources?.size) {
const newRemoteVideoSources = new Set(state.remoteVideoSources);
for (const source of videoSources.keys()) {
newRemoteVideoSources.add(source);
}
state.remoteVideoSources = newRemoteVideoSources;
}
}
return { ...state };
}
case RAISE_HAND_CLEAR: {
return {
...state,
raisedHandsQueue: []
};
}
case RAISE_HAND_UPDATED: {
return {
...state,
raisedHandsQueue: action.queue
};
}
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
if (metadata?.visitors?.promoted) {
let participantProcessed = false;
Object.entries(metadata?.visitors?.promoted).forEach(([ key, _ ]) => {
const p = state.remote.get(key);
if (p && !p.isPromoted) {
state.remote.set(key, {
...p,
isPromoted: true
});
participantProcessed = true;
}
});
if (participantProcessed) {
return { ...state };
}
}
break;
}
case OVERWRITE_PARTICIPANT_NAME: {
const { id, name } = action;
return {
...state,
overwrittenNameList: {
...state.overwrittenNameList,
[id]: name
}
};
}
}
return state;
});
/**
* Returns the participant's display name, default string if display name is not set on the participant.
*
* @param {Object} state - The local participant redux state.
* @param {string} name - The display name of the participant.
* @returns {string}
*/
function _getDisplayName(state: Object, name?: string): string {
// @ts-ignore
const config = state['features/base/config'];
return name ?? (config?.defaultRemoteDisplayName || 'Fellow Jitster');
}
/**
* Reducer function for a single participant.
*
* @param {IParticipant|undefined} state - Participant to be modified.
* @param {Object} action - Action object.
* @param {string} action.type - Type of action.
* @param {IParticipant} action.participant - Information about participant to be
* added/modified.
* @param {JitsiConference} action.conference - Conference instance.
* @private
* @returns {IParticipant}
*/
function _participant(state: IParticipant | ILocalParticipant = { id: '' },
action: AnyAction): IParticipant | ILocalParticipant {
switch (action.type) {
case SET_LOADABLE_AVATAR_URL:
case PARTICIPANT_UPDATED: {
const { participant } = action; // eslint-disable-line no-shadow
const newState = { ...state };
for (const key in participant) {
if (participant.hasOwnProperty(key)
&& PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE.indexOf(key)
=== -1) {
// @ts-ignore
newState[key] = participant[key];
}
}
return newState;
}
}
return state;
}
/**
* Reduces a specific redux action of type {@link PARTICIPANT_JOINED} in the
* feature base/participants.
*
* @param {Action} action - The redux action of type {@code PARTICIPANT_JOINED}
* to reduce.
* @private
* @returns {Object} The new participant derived from the payload of the
* specified {@code action} to be added into the redux state of the feature
* base/participants after the reduction of the specified
* {@code action}.
*/
function _participantJoined({ participant }: { participant: IParticipant; }) {
const {
avatarURL,
botType,
dominantSpeaker,
email,
fakeParticipant,
isPromoted,
isReplacing,
loadableAvatarUrl,
local,
name,
pinned,
presence,
role,
sources
} = participant;
let { conference, id } = participant;
if (local) {
// conference
//
// XXX The local participant is not identified in association with a
// JitsiConference because it is identified by the very fact that it is
// the local participant.
conference = undefined;
// id
id || (id = LOCAL_PARTICIPANT_DEFAULT_ID);
}
return {
avatarURL,
botType,
conference,
dominantSpeaker: dominantSpeaker || false,
email,
fakeParticipant,
id,
isPromoted,
isReplacing,
loadableAvatarUrl,
local: local || false,
name,
pinned: pinned || false,
presence,
role: role || PARTICIPANT_ROLE.NONE,
sources
};
}
/**
* 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: IParticipantsState, id: string, property: string, value: boolean) {
const { remote, local, localScreenShare } = state;
if (remote.has(id)) {
remote.set(id, set(remote.get(id) ?? {
id: '',
name: ''
}, property as keyof IParticipant, value));
return true;
} else if (local?.id === id || local?.id === 'local') {
// The local participant's ID can chance from something to "local" when
// not in a conference.
state.local = set(local, property as keyof ILocalParticipant, value);
return true;
} else if (localScreenShare?.id === id) {
state.localScreenShare = set(localScreenShare, property as keyof IParticipant, value);
return true;
}
return false;
}

View File

@@ -0,0 +1,11 @@
/**
* The name of the bundled sound file which will be played when new participant
* joins the conference.
*/
export const PARTICIPANT_JOINED_FILE = 'joined.mp3';
/**
* The name of the bundled sound file which will be played when any participant
* leaves the conference.
*/
export const PARTICIPANT_LEFT_FILE = 'left.mp3';

View File

@@ -0,0 +1,168 @@
import { difference } from 'lodash-es';
import { batch } from 'react-redux';
import { IStore } from '../../app/types';
import { hideNotification, showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, RAISE_HAND_NOTIFICATION_ID } from '../../notifications/constants';
import { getCurrentConference } from '../conference/functions';
import {
getDisableNextSpeakerNotification,
getSsrcRewritingFeatureFlag,
hasBeenNotified,
isNextToSpeak } from '../config/functions.any';
import { VIDEO_TYPE } from '../media/constants';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { NOTIFIED_TO_SPEAK } from './actionTypes';
import { createVirtualScreenshareParticipant, participantLeft } from './actions';
import {
getParticipantById,
getRemoteScreensharesBasedOnPresence,
getVirtualScreenshareParticipantOwnerId
} from './functions';
import { FakeParticipant } from './types';
StateListenerRegistry.register(
/* selector */ state => state['features/base/tracks'],
/* listener */(tracks, store) => _updateScreenshareParticipants(store)
);
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].remoteVideoSources,
/* listener */(remoteVideoSources, store) => getSsrcRewritingFeatureFlag(store.getState())
&& _updateScreenshareParticipantsBasedOnPresence(store)
);
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].raisedHandsQueue,
/* listener */ (raisedHandsQueue, store) => {
if (raisedHandsQueue.length
&& isNextToSpeak(store.getState())
&& !hasBeenNotified(store.getState())
&& !getDisableNextSpeakerNotification(store.getState())
&& !store.getState()['features/visitors'].iAmVisitor) { // visitors raise hand to be promoted
_notifyNextSpeakerInRaisedHandQueue(store);
}
if (!raisedHandsQueue[0]) {
store.dispatch(hideNotification(RAISE_HAND_NOTIFICATION_ID));
}
}
);
/**
* Compares the old and new screenshare lists provided and creates/removes the virtual screenshare participant
* tiles accordingly.
*
* @param {Array<string>} oldScreenshareSourceNames - List of old screenshare source names.
* @param {Array<string>} newScreenshareSourceNames - Current list of screenshare source names.
* @param {Object} store - The redux store.
* @returns {void}
*/
function _createOrRemoveVirtualParticipants(
oldScreenshareSourceNames: string[],
newScreenshareSourceNames: string[],
store: IStore): void {
const { dispatch, getState } = store;
const conference = getCurrentConference(getState());
const removedScreenshareSourceNames = difference(oldScreenshareSourceNames, newScreenshareSourceNames);
const addedScreenshareSourceNames = difference(newScreenshareSourceNames, oldScreenshareSourceNames);
if (removedScreenshareSourceNames.length) {
removedScreenshareSourceNames.forEach(id => dispatch(participantLeft(id, conference, {
fakeParticipant: FakeParticipant.RemoteScreenShare
})));
}
if (addedScreenshareSourceNames.length) {
addedScreenshareSourceNames.forEach(id => dispatch(
createVirtualScreenshareParticipant(id, false, conference)));
}
}
/**
* Handles creating and removing virtual screenshare participants.
*
* @param {*} store - The redux store.
* @returns {void}
*/
function _updateScreenshareParticipants(store: IStore): void {
const { dispatch, getState } = store;
const state = getState();
const conference = getCurrentConference(state);
const tracks = state['features/base/tracks'];
const { sortedRemoteVirtualScreenshareParticipants, localScreenShare } = state['features/base/participants'];
const previousScreenshareSourceNames = [ ...sortedRemoteVirtualScreenshareParticipants.keys() ];
let newLocalSceenshareSourceName;
const currentScreenshareSourceNames = tracks.reduce((acc: string[], track) => {
if (track.videoType === VIDEO_TYPE.DESKTOP && !track.jitsiTrack.isMuted()) {
const sourceName: string = track.jitsiTrack.getSourceName();
// Ignore orphan tracks in ssrc-rewriting mode.
if (!sourceName && getSsrcRewritingFeatureFlag(state)) {
return acc;
}
if (track.local) {
newLocalSceenshareSourceName = sourceName;
} else if (getParticipantById(state, getVirtualScreenshareParticipantOwnerId(sourceName))) {
acc.push(sourceName);
}
}
return acc;
}, []);
if (!localScreenShare && newLocalSceenshareSourceName) {
dispatch(createVirtualScreenshareParticipant(newLocalSceenshareSourceName, true, conference));
}
if (localScreenShare && !newLocalSceenshareSourceName) {
dispatch(participantLeft(localScreenShare.id, conference, {
fakeParticipant: FakeParticipant.LocalScreenShare
}));
}
if (getSsrcRewritingFeatureFlag(state)) {
return;
}
_createOrRemoveVirtualParticipants(previousScreenshareSourceNames, currentScreenshareSourceNames, store);
}
/**
* Handles the creation and removal of remote virtual screenshare participants when ssrc-rewriting is enabled.
*
* @param {Object} store - The redux store.
* @returns {void}
*/
function _updateScreenshareParticipantsBasedOnPresence(store: IStore): void {
const { getState } = store;
const state = getState();
const { sortedRemoteVirtualScreenshareParticipants } = state['features/base/participants'];
const previousScreenshareSourceNames = [ ...sortedRemoteVirtualScreenshareParticipants.keys() ];
const currentScreenshareSourceNames = getRemoteScreensharesBasedOnPresence(state);
_createOrRemoveVirtualParticipants(previousScreenshareSourceNames, currentScreenshareSourceNames, store);
}
/**
* Handles notifying the next speaker in the raised hand queue.
*
* @param {*} store - The redux store.
* @returns {void}
*/
function _notifyNextSpeakerInRaisedHandQueue(store: IStore): void {
const { dispatch } = store;
batch(() => {
dispatch(showNotification({
titleKey: 'notify.nextToSpeak',
maxLines: 2
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
dispatch({
type: NOTIFIED_TO_SPEAK
});
});
}

View File

@@ -0,0 +1,92 @@
import { IJitsiConference } from '../conference/reducer';
export enum FakeParticipant {
LocalScreenShare = 'LocalScreenShare',
RemoteScreenShare = 'RemoteScreenShare',
SharedVideo = 'SharedVideo',
Whiteboard = 'Whiteboard'
}
export interface IParticipant {
avatarURL?: string;
botType?: string;
conference?: IJitsiConference;
displayName?: string;
dominantSpeaker?: boolean;
e2eeEnabled?: boolean;
e2eeSupported?: boolean;
e2eeVerificationAvailable?: boolean;
e2eeVerified?: boolean;
email?: string;
fakeParticipant?: FakeParticipant;
features?: IParticipantFeatures;
getId?: Function;
id: string;
isJigasi?: boolean;
isPromoted?: boolean;
isReplaced?: boolean;
isReplacing?: number;
isSilent?: boolean;
jwtId?: string;
loadableAvatarUrl?: string;
loadableAvatarUrlUseCORS?: boolean;
local?: boolean;
localRecording?: boolean;
name?: string;
pinned?: boolean;
presence?: string;
raisedHandTimestamp?: number;
region?: string;
remoteControlSessionStatus?: boolean;
role?: string;
sources?: Map<string, Map<string, ISourceInfo>>;
supportsRemoteControl?: boolean;
}
export interface ILocalParticipant extends IParticipant {
audioOutputDeviceId?: string;
cameraDeviceId?: string;
jwtId?: string;
micDeviceId?: string;
startWithAudioMuted?: boolean;
startWithVideoMuted?: boolean;
userSelectedMicDeviceId?: string;
userSelectedMicDeviceLabel?: string;
}
export interface IParticipantFeatures {
'branding'?: boolean | string;
'calendar'?: boolean | string;
'create-polls'?: boolean | string;
'file-upload'?: boolean | string;
'flip'?: boolean | string;
'inbound-call'?: boolean | string;
'list-visitors'?: boolean | string;
'livestreaming'?: boolean | string;
'lobby'?: boolean | string;
'moderation'?: boolean | string;
'outbound-call'?: boolean | string;
'recording'?: boolean | string;
'room'?: boolean | string;
'screen-sharing'?: boolean | string;
'send-groupchat'?: boolean | string;
'sip-inbound-call'?: boolean | string;
'sip-outbound-call'?: boolean | string;
'transcription'?: boolean | string;
}
export interface ISourceInfo {
muted: boolean;
videoType: string;
}
export interface IJitsiParticipant {
getDisplayName: () => string;
getId: () => string;
getJid: () => string;
getRole: () => string;
getSources: () => Map<string, Map<string, ISourceInfo>>;
isHidden: () => boolean;
}
export type ParticipantFeaturesKey = keyof IParticipantFeatures;