Compare commits

...

7 Commits

Author SHA1 Message Date
Horatiu Muresan
d16c8af57e feat(external-api): Add recording download link available event (#10229) 2021-10-22 13:00:53 +03:00
Mihaela Dumitru
67037e6089 feat(external-api): add knocking event and approve/reject command (#10210) 2021-10-22 13:00:53 +03:00
hmuresan
c50e331715 feat(rtc-stats) Whitelist rtcstatsEnabled 2021-10-15 20:07:27 +03:00
Vlad Piersec
f1eeae49dc fix(jaas): Get dynamic branding url from config file
We make the request for dynamic branding as soon as possible so
at the time of the request the config is not yet added to the store.
In order to fix this we get the jass brandingDataUrl &
dynamicBrandingUrl directly from the config.
2021-10-07 11:53:57 +03:00
hmuresan
338838cece feat(context-menu) Show participants context menu overlaid in a portal 2021-10-07 10:00:24 +03:00
robertpin
bf65788359 feat(external_api) Exposed AV Moderation to the iFrame API
Renamed event property
2021-10-07 09:43:08 +03:00
robertpin
6d4d0dd948 fix(participants-pane) Place Dominant Speaker first in participants list
Move function participants list ordering function in participant-pane, since that's the only feature that uses it.
2021-10-07 09:43:08 +03:00
21 changed files with 719 additions and 177 deletions

View File

@@ -3,48 +3,45 @@
* to allow mouse movement from the popover trigger to the popover itself
* without triggering a mouseleave event.
*/
.popover-mousemove-padding-bottom {
bottom: -15px;
height: 20px;
position: absolute;
right: 0;
width: 100%;
}
%vertical-popover-padding {
height: 100%;
position: absolute;
top: 0;
width: 40px;
width: 20px;
padding: 20px 0;
top: -20px;
}
%horizontal-popover-padding {
height: 25px;
position: absolute;
right: 0;
width: 100%;
padding: 0 35px;
left: -35px;
}
.popover-mousemove-padding-left {
@extend %vertical-popover-padding;
left: -20px;
left: -35px;
}
.popover-mousemove-padding-right {
@extend %vertical-popover-padding;
right: -20px;
right: -35px;
}
/**
* An invisible element is added to the top of the popover to ensure the mouse
* stays over the popover when the popover's height is shrunk, which would then
* normally leave the mouse outside of the popover itself and cause a mouseleave
* event.
*/
.popover-mouse-padding-top {
height: 30px;
position: absolute;
right: 0;
top: -25px;
width: 100%;
.popover-mousemove-padding-bottom {
@extend %horizontal-popover-padding;
bottom: -40px;
}
.popover-mousemove-padding-top {
@extend %horizontal-popover-padding;
top: -40px;
}
.popover {
background-color: $popoverBg;
border-radius: 3px;
margin: -16px -24px;
padding: 16px 24px;
z-index: $popoverZ;

View File

@@ -6,6 +6,17 @@ import {
createApiEvent,
sendAnalytics
} from '../../react/features/analytics';
import {
approveParticipantAudio,
approveParticipantVideo,
rejectParticipantAudio,
rejectParticipantVideo,
requestDisableAudioModeration,
requestDisableVideoModeration,
requestEnableAudioModeration,
requestEnableVideoModeration
} from '../../react/features/av-moderation/actions';
import { isEnabledFromState } from '../../react/features/av-moderation/functions';
import {
getCurrentConference,
sendTones,
@@ -25,7 +36,8 @@ import {
pinParticipant,
kickParticipant,
raiseHand,
isParticipantModerator
isParticipantModerator,
isLocalParticipantModerator
} from '../../react/features/base/participants';
import { updateSettings } from '../../react/features/base/settings';
import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks';
@@ -49,7 +61,8 @@ import {
captureLargeVideoScreenshot,
resizeLargeVideo
} from '../../react/features/large-video/actions.web';
import { toggleLobbyMode } from '../../react/features/lobby/actions';
import { toggleLobbyMode, setKnockingParticipantApproval } from '../../react/features/lobby/actions';
import { isForceMuted } from '../../react/features/participants-pane/functions';
import { RECORDING_TYPES } from '../../react/features/recording/constants';
import { getActiveSession } from '../../react/features/recording/functions';
import { isScreenAudioSupported } from '../../react/features/screen-share';
@@ -100,6 +113,23 @@ let videoAvailable = true;
*/
function initCommands() {
commands = {
'answer-knocking-participant': (id, approved) => {
APP.store.dispatch(setKnockingParticipantApproval(id, approved));
},
'approve-video': participantId => {
if (!isLocalParticipantModerator(APP.store.getState())) {
return;
}
APP.store.dispatch(approveParticipantVideo(participantId));
},
'ask-to-unmute': participantId => {
if (!isLocalParticipantModerator(APP.store.getState())) {
return;
}
APP.store.dispatch(approveParticipantAudio(participantId));
},
'display-name': displayName => {
sendAnalytics(createApiEvent('display.name.changed'));
APP.conference.changeLocalDisplayName(displayName);
@@ -150,6 +180,15 @@ function initCommands() {
'proxy-connection-event': event => {
APP.conference.onProxyConnectionEvent(event);
},
'reject-participant': (participantId, mediaType) => {
if (!isLocalParticipantModerator(APP.store.getState())) {
return;
}
const reject = mediaType === MEDIA_TYPE.VIDEO ? rejectParticipantVideo : rejectParticipantAudio;
APP.store.dispatch(reject(participantId));
},
'resize-large-video': (width, height) => {
logger.debug('Resize large video command received');
sendAnalytics(createApiEvent('largevideo.resized'));
@@ -218,6 +257,24 @@ function initCommands() {
sendAnalytics(createApiEvent('chat.toggled'));
APP.store.dispatch(toggleChat());
},
'toggle-moderation': (enabled, mediaType) => {
const state = APP.store.getState();
if (!isLocalParticipantModerator(state)) {
return;
}
const enable = mediaType === MEDIA_TYPE.VIDEO
? requestEnableVideoModeration : requestEnableAudioModeration;
const disable = mediaType === MEDIA_TYPE.VIDEO
? requestDisableVideoModeration : requestDisableAudioModeration;
if (enabled) {
APP.store.dispatch(enable());
} else {
APP.store.dispatch(disable());
}
},
'toggle-raise-hand': () => {
const localParticipant = getLocalParticipant(APP.store.getState());
@@ -541,6 +598,22 @@ function initCommands() {
case 'is-audio-muted':
callback(APP.conference.isLocalAudioMuted());
break;
case 'is-moderation-on': {
const { mediaType } = request;
const type = mediaType || MEDIA_TYPE.AUDIO;
callback(isEnabledFromState(type, APP.store.getState()));
break;
}
case 'is-participant-force-muted': {
const state = APP.store.getState();
const { participantId, mediaType } = request;
const type = mediaType || MEDIA_TYPE.AUDIO;
const participant = getParticipantById(state, participantId);
callback(isForceMuted(participant, type, state));
break;
}
case 'is-video-muted':
callback(APP.conference.isLocalVideoMuted());
break;
@@ -806,6 +879,51 @@ class API {
});
}
/**
* Notify the external application that the moderation status has changed.
*
* @param {string} mediaType - Media type for which the moderation changed.
* @param {boolean} enabled - Whether or not the new moderation status is enabled.
* @returns {void}
*/
notifyModerationChanged(mediaType: string, enabled: boolean) {
this._sendEvent({
name: 'moderation-status-changed',
mediaType,
enabled
});
}
/**
* Notify the external application that a participant was approved on moderation.
*
* @param {string} participantId - The ID of the participant that got approved.
* @param {string} mediaType - Media type for which the participant was approved.
* @returns {void}
*/
notifyParticipantApproved(participantId: string, mediaType: string) {
this._sendEvent({
name: 'moderation-participant-approved',
id: participantId,
mediaType
});
}
/**
* Notify the external application that a participant was rejected on moderation.
*
* @param {string} participantId - The ID of the participant that got rejected.
* @param {string} mediaType - Media type for which the participant was rejected.
* @returns {void}
*/
notifyParticipantRejected(participantId: string, mediaType: string) {
this._sendEvent({
name: 'moderation-participant-rejected',
id: participantId,
mediaType
});
}
/**
* Notify external application that the video quality setting has changed.
*
@@ -1340,6 +1458,33 @@ class API {
});
}
/**
* Notify external application (if API is enabled) that the current recording link is
* available.
*
* @param {string} link - The recording download link.
* @returns {void}
*/
notifyRecordingLinkAvailable(link: string) {
this._sendEvent({
name: 'recording-link-available',
link
});
}
/**
* Notify external application (if API is enabled) that a participant is knocking in the lobby.
*
* @param {Object} participant - Participant data such as id and name.
* @returns {void}
*/
notifyKnockingParticipant(participant: Object) {
this._sendEvent({
name: 'knocking-participant',
participant
});
}
/**
* Notify external application (if API is enabled) that an error occured.
*

View File

@@ -27,6 +27,9 @@ const ALWAYS_ON_TOP_FILENAMES = [
* commands expected by jitsi-meet
*/
const commands = {
answerKnockingParticipant: 'answer-knocking-participant',
approveVideo: 'approve-video',
askToUnmute: 'ask-to-unmute',
avatarUrl: 'avatar-url',
cancelPrivateChat: 'cancel-private-chat',
displayName: 'display-name',
@@ -40,6 +43,7 @@ const commands = {
overwriteConfig: 'overwrite-config',
password: 'password',
pinParticipant: 'pin-participant',
rejectParticipant: 'reject-participant',
resizeLargeVideo: 'resize-large-video',
sendChatMessage: 'send-chat-message',
sendEndpointTextMessage: 'send-endpoint-text-message',
@@ -60,6 +64,7 @@ const commands = {
toggleCameraMirror: 'toggle-camera-mirror',
toggleChat: 'toggle-chat',
toggleFilmStrip: 'toggle-film-strip',
toggleModeration: 'toggle-moderation',
toggleRaiseHand: 'toggle-raise-hand',
toggleShareAudio: 'toggle-share-audio',
toggleShareScreen: 'toggle-share-screen',
@@ -90,8 +95,12 @@ const events = {
'feedback-prompt-displayed': 'feedbackPromptDisplayed',
'filmstrip-display-changed': 'filmstripDisplayChanged',
'incoming-message': 'incomingMessage',
'knocking-participant': 'knockingParticipant',
'log': 'log',
'mic-error': 'micError',
'moderation-participant-approved': 'moderationParticipantApproved',
'moderation-participant-rejected': 'moderationParticipantRejected',
'moderation-status-changed': 'moderationStatusChanged',
'mouse-enter': 'mouseEnter',
'mouse-leave': 'mouseLeave',
'mouse-move': 'mouseMove',
@@ -103,6 +112,7 @@ const events = {
'password-required': 'passwordRequired',
'proxy-connection-event': 'proxyConnectionEvent',
'raise-hand-updated': 'raiseHandUpdated',
'recording-link-available': 'recordingLinkAvailable',
'recording-status-changed': 'recordingStatusChanged',
'video-ready-to-close': 'readyToClose',
'video-conference-joined': 'videoConferenceJoined',
@@ -896,6 +906,36 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
});
}
/**
* Returns the moderation on status on the given mediaType.
*
* @param {string} mediaType - The media type for which to check moderation.
* @returns {Promise} - Resolves with the moderation on status and rejects on
* failure.
*/
isModerationOn(mediaType) {
return this._transport.sendRequest({
name: 'is-moderation-on',
mediaType
});
}
/**
* Returns force muted status of the given participant id for the given media type.
*
* @param {string} participantId - The id of the participant to check.
* @param {string} mediaType - The media type for which to check.
* @returns {Promise} - Resolves with the force muted status and rejects on
* failure.
*/
isParticipantForceMuted(participantId, mediaType) {
return this._transport.sendRequest({
name: 'is-participant-force-muted',
participantId,
mediaType
});
}
/**
* Returns screen sharing status.
*

View File

@@ -23,28 +23,53 @@ import {
import { isEnabledFromState } from './functions';
/**
* Action used by moderator to approve audio and video for a participant.
* Action used by moderator to approve audio for a participant.
*
* @param {staring} id - The id of the participant to be approved.
* @returns {void}
*/
export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => {
export const approveParticipantAudio = (id: string) => (dispatch: Function, getState: Function) => {
const state = getState();
const { conference } = getConferenceState(state);
const participant = getParticipantById(state, id);
const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
const isAudioModerationOn = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
const isVideoModerationOn = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
if (isAudioModerationOn || !isVideoModerationOn) {
conference.avModerationApprove(MEDIA_TYPE.AUDIO, id);
}
};
/**
* Action used by moderator to approve video for a participant.
*
* @param {staring} id - The id of the participant to be approved.
* @returns {void}
*/
export const approveParticipantVideo = (id: string) => (dispatch: Function, getState: Function) => {
const state = getState();
const { conference } = getConferenceState(state);
const participant = getParticipantById(state, id);
const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
const isVideoModerationOn = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
if (isVideoModerationOn && isVideoForceMuted) {
conference.avModerationApprove(MEDIA_TYPE.VIDEO, id);
}
};
/**
* Action used by moderator to approve audio and video for a participant.
*
* @param {staring} id - The id of the participant to be approved.
* @returns {void}
*/
export const approveParticipant = (id: string) => (dispatch: Function) => {
dispatch(approveParticipantAudio(id));
dispatch(approveParticipantVideo(id));
};
/**
* Action used by moderator to reject audio for a participant.
*

View File

@@ -22,7 +22,13 @@ import {
import { muteLocal } from '../video-menu/actions.any';
import {
DISABLE_MODERATION,
ENABLE_MODERATION,
LOCAL_PARTICIPANT_APPROVED,
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
LOCAL_PARTICIPANT_REJECTED,
PARTICIPANT_APPROVED,
PARTICIPANT_REJECTED,
REQUEST_DISABLE_AUDIO_MODERATION,
REQUEST_DISABLE_VIDEO_MODERATION,
REQUEST_ENABLE_AUDIO_MODERATION,
@@ -51,6 +57,8 @@ import {
} from './functions';
import { ASKED_TO_UNMUTE_FILE } from './sounds';
declare var APP: Object;
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const { type } = action;
const { conference } = getConferenceState(getState());
@@ -148,6 +156,46 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break;
}
case ENABLE_MODERATION: {
if (typeof APP !== 'undefined') {
APP.API.notifyModerationChanged(action.mediaType, true);
}
break;
}
case DISABLE_MODERATION: {
if (typeof APP !== 'undefined') {
APP.API.notifyModerationChanged(action.mediaType, false);
}
break;
}
case LOCAL_PARTICIPANT_APPROVED: {
if (typeof APP !== 'undefined') {
const local = getLocalParticipant(getState());
APP.API.notifyParticipantApproved(local.id, action.mediaType);
}
break;
}
case PARTICIPANT_APPROVED: {
if (typeof APP !== 'undefined') {
APP.API.notifyParticipantApproved(action.id, action.mediaType);
}
break;
}
case LOCAL_PARTICIPANT_REJECTED: {
if (typeof APP !== 'undefined') {
const local = getLocalParticipant(getState());
APP.API.notifyParticipantRejected(local.id, action.mediaType);
}
break;
}
case PARTICIPANT_REJECTED: {
if (typeof APP !== 'undefined') {
APP.API.notifyParticipantRejected(action.id, action.mediaType);
}
break;
}
}
return next(action);

View File

@@ -14,6 +14,7 @@ export default [
'_peerConnStatusRtcMuteTimeout',
'abTesting',
'analytics.disabled',
'analytics.rtcstatsEnabled',
'audioLevelsInterval',
'audioQuality',
'apiLogLevels',

View File

@@ -455,47 +455,6 @@ async function _getFirstLoadableAvatarUrl(participant, store) {
return undefined;
}
/**
* Selector for retrieving ids of participants in the order that they are displayed in the filmstrip (with the
* exception of participants with raised hand). The participants are reordered as follows.
* 1. Local participant.
* 2. Participants with raised hand.
* 3. Participants with screenshare sorted alphabetically by their display name.
* 4. Shared video participants.
* 5. Recent speakers sorted alphabetically by their display name.
* 6. Rest of the participants sorted alphabetically by their display name.
*
* @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<string>}
*/
export function getSortedParticipantIds(stateful: Object | Function): Array<string> {
const { id } = getLocalParticipant(stateful);
const remoteParticipants = getRemoteParticipantsSorted(stateful);
const reorderedParticipants = new Set(remoteParticipants);
const raisedHandParticipants = getRaiseHandsQueue(stateful);
const remoteRaisedHandParticipants = new Set(raisedHandParticipants || []);
for (const participant of remoteRaisedHandParticipants.keys()) {
// Avoid duplicates.
if (reorderedParticipants.has(participant)) {
reorderedParticipants.delete(participant);
} else {
remoteRaisedHandParticipants.delete(participant);
}
}
// Remove self.
remoteRaisedHandParticipants.has(id) && remoteRaisedHandParticipants.delete(id);
// Move self and participants with raised hand to the top of the list.
return [
id,
...Array.from(remoteRaisedHandParticipants.keys()),
...Array.from(reorderedParticipants.keys())
];
}
/**
* Get the participants queue with raised hands.
*

View File

@@ -1,35 +1,10 @@
/* @flow */
import InlineDialog from '@atlaskit/inline-dialog';
import React, { Component } from 'react';
import { Drawer, DrawerPortal } from '../../../toolbox/components/web';
/**
* A map of dialog positions, relative to trigger, to css classes used to
* manipulate elements for handling mouse events.
*
* @private
* @type {object}
*/
const DIALOG_TO_PADDING_POSITION = {
'left': 'popover-mousemove-padding-right',
'right': 'popover-mousemove-padding-left',
'top': 'popover-mousemove-padding-bottom'
};
/**
* Takes the position expected by {@code InlineDialog} and maps it to a CSS
* class that can be used styling the elements used for preventing mouseleave
* events when moving from the trigger to the dialog.
*
* @param {string} position - From which position the dialog will display.
* @private
* @returns {string}
*/
function _mapPositionToPaddingClass(position = 'left') {
return DIALOG_TO_PADDING_POSITION[position.split('-')[0]];
}
import { Drawer, DrawerPortal, DialogPortal } from '../../../toolbox/components/web';
import { isMobileBrowser } from '../../environment/utils';
import { getContextMenuStyle } from '../functions.web';
/**
* The type of the React {@code Component} props of {@link Popover}.
@@ -90,6 +65,11 @@ type Props = {
*/
type State = {
/**
* The style to apply to the context menu in order to position it correctly.
*/
contextMenuStyle: Object,
/**
* Whether or not the {@code InlineDialog} should be displayed.
*/
@@ -118,6 +98,7 @@ class Popover extends Component<Props, State> {
*/
_containerRef: Object;
_contextMenuRef: HTMLElement;
/**
* Initializes a new {@code Popover} instance.
@@ -129,7 +110,8 @@ class Popover extends Component<Props, State> {
super(props);
this.state = {
showDialog: false
showDialog: false,
contextMenuStyle: null
};
// Bind event handlers so they are only bound once for every instance.
@@ -140,6 +122,9 @@ class Popover extends Component<Props, State> {
this._onEscKey = this._onEscKey.bind(this);
this._onThumbClick = this._onThumbClick.bind(this);
this._onTouchStart = this._onTouchStart.bind(this);
this._setContextMenuRef = this._setContextMenuRef.bind(this);
this._setContextMenuStyle = this._setContextMenuStyle.bind(this);
this._getCustomDialogStyle = this._getCustomDialogStyle.bind(this);
}
/**
@@ -179,7 +164,7 @@ class Popover extends Component<Props, State> {
* @returns {ReactElement}
*/
render() {
const { children, className, content, id, overflowDrawer, position } = this.props;
const { children, className, content, id, overflowDrawer } = this.props;
if (overflowDrawer) {
return (
@@ -208,16 +193,47 @@ class Popover extends Component<Props, State> {
onMouseEnter = { this._onShowDialog }
onMouseLeave = { this._onHideDialog }
ref = { this._containerRef }>
<InlineDialog
content = { this._renderContent() }
isOpen = { this.state.showDialog }
placement = { position }>
{ children }
</InlineDialog>
{ this.state.showDialog && (
<DialogPortal
getRef = { this._setContextMenuRef }
setSize = { this._setContextMenuStyle }
style = { this.state.contextMenuStyle }>
{this._renderContent()}
</DialogPortal>
)}
{ children }
</div>
);
}
_setContextMenuStyle: (size: Object) => void;
/**
* Sets the context menu dialog style for positioning it on screen.
*
* @param {DOMRectReadOnly} size -The size info of the current context menu.
*
* @returns {void}
*/
_setContextMenuStyle(size) {
const style = this._getCustomDialogStyle(size);
this.setState({ contextMenuStyle: style });
}
_setContextMenuRef: (elem: HTMLElement) => void;
/**
* Sets the context menu's ref.
*
* @param {HTMLElement} elem -The html element of the context menu.
*
* @returns {void}
*/
_setContextMenuRef(elem) {
this._contextMenuRef = elem;
}
_onTouchStart: (event: TouchEvent) => void;
/**
@@ -230,9 +246,9 @@ class Popover extends Component<Props, State> {
_onTouchStart(event) {
if (this.state.showDialog
&& !this.props.overflowDrawer
&& this._containerRef
&& this._containerRef.current
&& !this._containerRef.current.contains(event.target)) {
&& this._contextMenuRef
&& this._contextMenuRef.contains
&& !this._contextMenuRef.contains(event.target)) {
this._onHideDialog();
}
}
@@ -246,7 +262,10 @@ class Popover extends Component<Props, State> {
* @returns {void}
*/
_onHideDialog() {
this.setState({ showDialog: false });
this.setState({
showDialog: false,
contextMenuStyle: null
});
if (this.props.onPopoverClose) {
this.props.onPopoverClose();
@@ -327,6 +346,24 @@ class Popover extends Component<Props, State> {
}
}
_getCustomDialogStyle: (DOMRectReadOnly) => void;
/**
* Gets style for positioning the context menu on screen in regards to the trigger's
* position.
*
* @param {DOMRectReadOnly} size -The current context menu's size info.
*
* @returns {Object} - The new style of the context menu.
*/
_getCustomDialogStyle(size) {
if (this._containerRef && this._containerRef.current) {
const bounds = this._containerRef.current.getBoundingClientRect();
return getContextMenuStyle(bounds, size, this.props.position);
}
}
/**
* Renders the React Element to be displayed in the {@code InlineDialog}.
* Also adds padding to support moving the mouse from the trigger to the
@@ -336,15 +373,20 @@ class Popover extends Component<Props, State> {
* @returns {ReactElement}
*/
_renderContent() {
const { content, position } = this.props;
const { content } = this.props;
return (
<div
className = 'popover'
className = 'popover popupmenu'
onKeyDown = { this._onEscKey }>
{ content }
<div className = 'popover-mouse-padding-top' />
<div className = { _mapPositionToPaddingClass(position) } />
{!isMobileBrowser() && (
<>
<div className = 'popover-mousemove-padding-top' />
<div className = 'popover-mousemove-padding-right' />
<div className = 'popover-mousemove-padding-left' />
<div className = 'popover-mousemove-padding-bottom' />
</>)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
// @flow
const LEFT_RIGHT_OFFSET = 25;
const TOP_BOTTOM_OFFSET = 20;
const getLeftAlignedStyle = bounds => {
return {
position: 'fixed',
right: `${window.innerWidth - bounds.x + LEFT_RIGHT_OFFSET}px`
};
};
const getRightAlignedStyle = bounds => {
return {
position: 'fixed',
left: `${bounds.x + bounds.width + LEFT_RIGHT_OFFSET}px`
};
};
const getTopAlignedStyle = bounds => {
return {
position: 'fixed',
bottom: `${window.innerHeight - bounds.y + TOP_BOTTOM_OFFSET}px`
};
};
const getBottomAlignedStyle = bounds => {
return {
position: 'fixed',
top: `${bounds.y + bounds.height + TOP_BOTTOM_OFFSET}px`
};
};
const getLeftRightStartAlign = (bounds, size) => {
return {
top: `${Math.min(bounds.y + 15, window.innerHeight - size.height - 20)}px`
};
};
const getLeftRightMidAlign = (bounds, size) => {
return {
bottom: `${window.innerHeight - bounds.y - bounds.height - (size.height / 2)}px`
};
};
const getLeftRightEndAlign = (bounds, size) => {
return {
bottom: `${Math.min(window.innerHeight - bounds.y - bounds.height, window.innerHeight - size.height)}px`
};
};
const getTopBotStartAlign = bounds => {
return {
right: `${window.innerWidth - bounds.x + 10}px`
};
};
const getTopBotMidAlign = (bounds, size) => {
return {
right: `${window.innerWidth - bounds.x - (size.width / 2)}px`
};
};
const getTopBotEndAlign = bounds => {
return {
left: `${bounds.x + bounds.width + 10}px`
};
};
/**
* Gets the trigger element's and the context menu's bounds/size info and
* computes the style to apply to the context menu to positioning it correctly
* in regards to the given position info.
*
* @param {DOMRect} triggerBounds -The bounds info of the trigger html element.
* @param {DOMRectReadOnly} dialogSize - The size info of the context menu.
* @param {string} position - The position of the context menu in regards to the trigger element.
*
* @returns {Object} = The style to apply to context menu for positioning it correctly.
*/
export const getContextMenuStyle = (triggerBounds: DOMRect,
dialogSize: DOMRectReadOnly,
position: string) => {
const parsed = position.split('-');
switch (parsed[0]) {
case 'top': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getTopBotStartAlign(triggerBounds)
: getTopBotEndAlign(triggerBounds);
} else {
alignmentStyle = getTopBotMidAlign(triggerBounds, dialogSize);
}
return {
...getTopAlignedStyle(triggerBounds),
...alignmentStyle
};
}
case 'bottom': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getTopBotStartAlign(triggerBounds)
: getTopBotEndAlign(triggerBounds);
} else {
alignmentStyle = getTopBotMidAlign(triggerBounds, dialogSize);
}
return {
...getBottomAlignedStyle(triggerBounds),
...alignmentStyle
};
}
case 'left': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getLeftRightStartAlign(triggerBounds, dialogSize)
: getLeftRightEndAlign(triggerBounds, dialogSize);
} else {
alignmentStyle = getLeftRightMidAlign(triggerBounds, dialogSize);
}
return {
...getLeftAlignedStyle(triggerBounds),
...alignmentStyle
};
}
case 'right': {
let alignmentStyle = {};
if (parsed[1]) {
alignmentStyle = parsed[1] === 'start'
? getLeftRightStartAlign(triggerBounds, dialogSize)
: getLeftRightEndAlign(triggerBounds, dialogSize);
} else {
alignmentStyle = getLeftRightMidAlign(triggerBounds, dialogSize);
}
return {
...getRightAlignedStyle(triggerBounds),
...alignmentStyle
};
}
default: {
return {
...getLeftAlignedStyle(triggerBounds),
...getLeftRightEndAlign(triggerBounds, dialogSize)
};
}
}
};

View File

@@ -27,7 +27,7 @@ export function fetchCustomBrandingData() {
const { customizationReady } = state['features/dynamic-branding'];
if (!customizationReady) {
const url = getDynamicBrandingUrl(state);
const url = await getDynamicBrandingUrl();
if (url) {
try {

View File

@@ -1,4 +1,5 @@
// @flow
import { loadConfig } from '../base/lib-jitsi-meet';
/**
* Extracts the fqn part from a path, where fqn represents
@@ -17,17 +18,17 @@ export function extractFqnFromPath() {
/**
* Returns the url used for fetching dynamic branding.
*
* @param {Object} state - The state of the app.
* @returns {string}
*/
export function getDynamicBrandingUrl(state: Object) {
const { dynamicBrandingUrl } = state['features/base/config'];
export async function getDynamicBrandingUrl() {
const config = await loadConfig(window.location.href);
const { dynamicBrandingUrl } = config;
if (dynamicBrandingUrl) {
return dynamicBrandingUrl;
}
const baseUrl = state['features/base/config'].brandingDataUrl;
const { brandingDataUrl: baseUrl } = config;
const fqn = extractFqnFromPath();
if (baseUrl && fqn) {

View File

@@ -25,6 +25,8 @@ import {
import { KNOCKING_PARTICIPANT_SOUND_ID } from './constants';
import { KNOCKING_PARTICIPANT_FILE } from './sounds';
declare var APP: Object;
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
@@ -69,6 +71,12 @@ StateListenerRegistry.register(
name
}));
dispatch(playSound(KNOCKING_PARTICIPANT_SOUND_ID));
if (typeof APP !== 'undefined') {
APP.API.notifyKnockingParticipant({
id,
name
});
}
});
});

View File

@@ -8,13 +8,12 @@ import { rejectParticipantAudio } from '../../../av-moderation/actions';
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
import { MEDIA_TYPE } from '../../../base/media';
import {
getParticipantCountWithFake,
getSortedParticipantIds
getParticipantCountWithFake
} from '../../../base/participants';
import { connect } from '../../../base/redux';
import { showOverflowDrawer } from '../../../toolbox/functions';
import { muteRemote } from '../../../video-menu/actions.any';
import { findStyledAncestor, shouldRenderInviteButton } from '../../functions';
import { findStyledAncestor, getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
import { useParticipantDrawer } from '../../hooks';
import { InviteButton } from './InviteButton';

View File

@@ -11,7 +11,10 @@ import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import {
getDominantSpeakerParticipant,
isLocalParticipantModerator,
isParticipantModerator
isParticipantModerator,
getLocalParticipant,
getRemoteParticipantsSorted,
getRaiseHandsQueue
} from '../base/participants/functions';
import { toState } from '../base/redux';
@@ -188,3 +191,57 @@ export const shouldRenderInviteButton = (state: Object) => {
return flagEnabled && !disableInviteFunctions;
};
/**
* Selector for retrieving ids of participants in the order that they are displayed in the filmstrip (with the
* exception of participants with raised hand). The participants are reordered as follows.
* 1. Dominant speaker.
* 2. Local participant.
* 3. Participants with raised hand.
* 4. Participants with screenshare sorted alphabetically by their display name.
* 5. Shared video participants.
* 6. Recent speakers sorted alphabetically by their display name.
* 7. Rest of the participants sorted alphabetically by their display name.
*
* @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<string>}
*/
export function getSortedParticipantIds(stateful: Object | Function): Array<string> {
const { id } = getLocalParticipant(stateful);
const remoteParticipants = getRemoteParticipantsSorted(stateful);
const reorderedParticipants = new Set(remoteParticipants);
const raisedHandParticipants = getRaiseHandsQueue(stateful);
const remoteRaisedHandParticipants = new Set(raisedHandParticipants || []);
const dominantSpeaker = getDominantSpeakerParticipant(stateful);
for (const participant of remoteRaisedHandParticipants.keys()) {
// Avoid duplicates.
if (reorderedParticipants.has(participant)) {
reorderedParticipants.delete(participant);
} else {
remoteRaisedHandParticipants.delete(participant);
}
}
// Remove self.
remoteRaisedHandParticipants.delete(id);
const dominant = [];
// Remove dominant speaker.
if (dominantSpeaker && dominantSpeaker.id !== id) {
remoteRaisedHandParticipants.delete(dominantSpeaker.id);
reorderedParticipants.delete(dominantSpeaker.id);
dominant.push(dominantSpeaker.id);
}
// Move self and participants with raised hand to the top of the list.
return [
...dominant,
id,
...Array.from(remoteRaisedHandParticipants.keys()),
...Array.from(reorderedParticipants.keys())
];
}

View File

@@ -22,6 +22,8 @@ import {
import { getRecordingLink, getResourceId, isSavingRecordingOnDropbox } from './functions';
import logger from './logger';
declare var APP: Object;
/**
* Clears the data of every recording sessions.
*
@@ -188,6 +190,10 @@ export function showStartedRecordingNotification(
try {
const link = await getRecordingLink(recordingSharingUrl, sessionId, region, tenant);
if (typeof APP === 'object') {
APP.API.notifyRecordingLinkAvailable(link);
}
// add the option to copy recording link
dialogProps.customActionNameKey = 'recording.copyLink';
dialogProps.customActionHandler = () => copyText(link);

View File

@@ -3,7 +3,6 @@
import type { Dispatch } from 'redux';
import { overwriteConfig } from '../base/config';
import { isLayoutTileView } from '../video-layout';
import {
CLEAR_TOOLBOX_TIMEOUT,
@@ -134,13 +133,10 @@ export function showToolbox(timeout: number = 0): Object {
const {
enabled,
visible,
overflowDrawer
visible
} = state['features/toolbox'];
const { contextMenuOpened } = state['features/base/responsive-ui'];
const contextMenuOpenedInTileview = isLayoutTileView(state) && contextMenuOpened && !overflowDrawer;
if (enabled && !visible && !contextMenuOpenedInTileview) {
if (enabled && !visible) {
dispatch(setToolboxVisible(true));
// If the Toolbox is always visible, there's no need for a timeout
@@ -178,23 +174,6 @@ export function setOverflowDrawer(displayAsDrawer: boolean) {
};
}
/**
* Disables and hides the toolbox on demand when in tile view.
*
* @returns {void}
*/
export function hideToolboxOnTileView() {
return (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const { overflowDrawer } = state['features/toolbox'];
if (!overflowDrawer && isLayoutTileView(state)) {
dispatch(hideToolbox(true));
}
};
}
/**
* Signals that toolbox timeout should be cleared.
*

View File

@@ -0,0 +1,99 @@
// @flow
import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
type Props = {
/**
* The component(s) to be displayed within the drawer portal.
*/
children: React$Node,
/**
* Custom class name to apply on the container div.
*/
className?: string,
/**
* Function used to get the refferrence to the container div.
*/
getRef?: Function,
/**
* Function used to get the updated size info of the container on it's resize.
*/
setSize?: Function,
/**
* Custom style to apply to the container div.
*/
style?: Object,
};
/**
* Component meant to render a drawer at the bottom of the screen,
* by creating a portal containing the component's children.
*
* @returns {ReactElement}
*/
function DialogPortal({ children, className, style, getRef, setSize }: Props) {
const [ portalTarget ] = useState(() => {
const portalDiv = document.createElement('div');
return portalDiv;
});
useEffect(() => {
if (style) {
for (const styleProp of Object.keys(style)) {
// https://github.com/facebook/flow/issues/3733
const objStyle: Object = portalTarget.style;
objStyle[styleProp] = style[styleProp];
}
}
if (className) {
portalTarget.className = className;
}
}, [ style, className ]);
useEffect(() => {
if (portalTarget && getRef) {
getRef(portalTarget);
}
}, [ portalTarget ]);
useEffect(() => {
const size = {
width: 1,
height: 1
};
const observer = new ResizeObserver(entries => {
const { contentRect } = entries[0];
if (contentRect.width !== size.width || contentRect.height !== size.height) {
setSize && setSize(contentRect);
}
});
if (document.body) {
document.body.appendChild(portalTarget);
observer.observe(portalTarget);
}
return () => {
observer.unobserve(portalTarget);
if (document.body) {
document.body.removeChild(portalTarget);
}
};
}, []);
return ReactDOM.createPortal(
children,
portalTarget
);
}
export default DialogPortal;

View File

@@ -1,7 +1,7 @@
// @flow
import React from 'react';
import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import DialogPortal from './DialogPortal';
type Props = {
@@ -18,29 +18,10 @@ type Props = {
* @returns {ReactElement}
*/
function DrawerPortal({ children }: Props) {
const [ portalTarget ] = useState(() => {
const portalDiv = document.createElement('div');
portalDiv.className = 'drawer-portal';
return portalDiv;
});
useEffect(() => {
if (document.body) {
document.body.appendChild(portalTarget);
}
return () => {
if (document.body) {
document.body.removeChild(portalTarget);
}
};
}, []);
return ReactDOM.createPortal(
children,
portalTarget
return (
<DialogPortal className = 'drawer-portal'>
{ children }
</DialogPortal>
);
}

View File

@@ -4,3 +4,4 @@ export { default as ToolbarButton } from './ToolbarButton';
export { default as Toolbox } from './Toolbox';
export { default as Drawer } from './Drawer';
export { default as DrawerPortal } from './DrawerPortal';
export { default as DialogPortal } from './DialogPortal';

View File

@@ -14,7 +14,6 @@ import { connect } from '../../../base/redux';
import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions';
import { getLocalVideoTrack } from '../../../base/tracks';
import ConnectionIndicatorContent from '../../../connection-indicator/components/web/ConnectionIndicatorContent';
import { hideToolboxOnTileView } from '../../../toolbox/actions';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { renderConnectionStatus } from '../../actions.web';
@@ -196,7 +195,6 @@ class LocalVideoMenuTriggerButton extends Component<Props> {
*/
_onPopoverOpen() {
this.props.dispatch(setParticipantContextMenuOpen(true));
this.props.dispatch(hideToolboxOnTileView());
}
_onPopoverClose: () => void;

View File

@@ -13,7 +13,6 @@ import { Popover } from '../../../base/popover';
import { connect } from '../../../base/redux';
import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions';
import { requestRemoteControl, stopController } from '../../../remote-control';
import { hideToolboxOnTileView } from '../../../toolbox/actions';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { renderConnectionStatus } from '../../actions.web';
@@ -234,7 +233,6 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
*/
_onPopoverOpen() {
this.props.dispatch(setParticipantContextMenuOpen(true));
this.props.dispatch(hideToolboxOnTileView());
}
_onPopoverClose: () => void;