Compare commits

...

9 Commits

Author SHA1 Message Date
damencho
bd3ab8d8cd squash: Implements handling of live conference and queue service. 2024-06-25 09:24:34 +03:00
damencho
7f02d7ff57 squash: Drops comments which no longer apply. 2024-06-21 16:07:49 +03:00
damencho
0d76a77754 squash: Moves go live button to use hooks. 2024-06-20 11:12:24 +03:00
damencho
e052564e6e squash: Adds go-live icon. 2024-06-17 10:24:06 +03:00
damencho
ebcc5def97 squash: Allows pre-setting jitsiMetadata on room-creation. 2024-06-17 10:24:06 +03:00
damencho
0c7cf389c9 squash: Default value will be coming from backend. 2024-06-17 10:24:06 +03:00
damencho
0d2e3a1f45 squash: Fixes lint errors. 2024-06-17 10:24:06 +03:00
damencho
43f4606042 feat: Adds a go live button. 2024-06-17 10:24:01 +03:00
damencho
71b2f5f85e squash: Adds a config flag for enabling go live. 2024-06-17 10:21:39 +03:00
37 changed files with 752 additions and 22 deletions

View File

@@ -805,6 +805,7 @@ var config = {
// 'feedback',
// 'filmstrip',
// 'fullscreen',
// 'golive',
// 'hangup',
// 'help',
// 'highlight',

View File

@@ -1216,6 +1216,7 @@
"feedback": "Leave feedback",
"fullScreen": "Toggle full screen",
"giphy": "Toggle GIPHY menu",
"golive": "Go live inviting all waiting visitors",
"grantModerator": "Grant Moderator Rights",
"hangup": "Leave the meeting",
"heading": "Toolbar",
@@ -1304,6 +1305,7 @@
"exitTileView": "Exit tile view",
"feedback": "Leave feedback",
"giphy": "Toggle GIPHY menu",
"golive": "Go Live",
"hangup": "Leave the meeting",
"help": "Help",
"hideWhiteboard": "Hide whiteboard",
@@ -1495,7 +1497,9 @@
"noVisitorLobby": "You cannot join while there is a lobby enabled for the meeting.",
"notAllowedPromotion": "A participant needs to allow your request first.",
"title": "You are a visitor in the meeting"
}
},
"waiting": "*waiting",
"waitingMessage": "You'll join the meeting as soon as it is live!"
},
"volumeSlider": "Volume slider",
"welcomepage": {

View File

@@ -41,6 +41,7 @@
"@react-navigation/material-top-tabs": "6.6.3",
"@react-navigation/native": "6.1.7",
"@react-navigation/stack": "6.3.17",
"@stomp/stompjs": "7.0.0",
"@svgr/webpack": "6.3.1",
"@tensorflow/tfjs-backend-wasm": "3.13.0",
"@tensorflow/tfjs-core": "3.13.0",

View File

@@ -54,6 +54,7 @@ export interface IConferenceMetadata {
recording?: {
isTranscribingEnabled: boolean;
};
visitorsLive?: boolean;
whiteboard?: {
collabDetails: {
roomId: string;

View File

@@ -478,6 +478,7 @@ export interface IConfig {
peopleSearchQueryTypes?: string[];
peopleSearchUrl?: string;
preferBosh?: boolean;
preferVisitor?: boolean;
preferredTranscribeLanguage?: string;
prejoinConfig?: {
enabled?: boolean;

View File

@@ -197,6 +197,7 @@ export default [
'participantsPane',
'pcStatsInterval',
'preferBosh',
'preferVisitor',
'prejoinConfig',
'prejoinPageEnabled',
'recordingService',

View File

@@ -80,6 +80,7 @@ export interface IConfigState extends IConfig {
audio?: boolean;
video?: boolean;
};
queueService: string;
};
}

View File

@@ -78,6 +78,12 @@ export const FILMSTRIP_ENABLED = 'filmstrip.enabled';
*/
export const FULLSCREEN_ENABLED = 'fullscreen.enabled';
/**
* Flag indicating if the Go live button should be enabled.
* Default: enabled (true).
*/
export const GOLIVE_BUTTON_ENABLED = 'golive.enabled';
/**
* Flag indicating if the Help button should be enabled.
* Default: enabled (true).

View File

@@ -43,6 +43,7 @@ import { default as IconFavoriteSolid } from './favorite-solid.svg';
import { default as IconFavorite } from './favorite.svg';
import { default as IconFeedback } from './feedback.svg';
import { default as IconGear } from './gear.svg';
import { default as IconGoLive } from './go-live.svg';
import { default as IconGoogle } from './google.svg';
import { default as IconHangup } from './hangup.svg';
import { default as IconDeviceHeadphone } from './headset.svg';
@@ -157,6 +158,7 @@ export const DEFAULT_ICON: Record<string, any> = {
IconFavoriteSolid,
IconFeedback,
IconGear,
IconGoLive,
IconGoogle,
IconHangup,
IconHelp,

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.16732 10.0002C3.16732 6.22622 6.22671 3.16683 10.0007 3.16683C13.7746 3.16683 16.834 6.22622 16.834 10.0002C16.834 11.7507 16.1766 13.3462 15.0942 14.5556C14.8486 14.8299 14.872 15.2514 15.1463 15.4969C15.4207 15.7425 15.8421 15.7191 16.0877 15.4448C17.3804 14.0004 18.1673 12.0914 18.1673 10.0002C18.1673 5.48984 14.511 1.8335 10.0007 1.8335C5.49033 1.8335 1.83398 5.48984 1.83398 10.0002C1.83398 12.0914 2.62086 14.0004 3.91363 15.4448C4.15918 15.7191 4.58064 15.7425 4.85499 15.4969C5.12934 15.2514 5.15269 14.8299 4.90714 14.5556C3.82471 13.3462 3.16732 11.7507 3.16732 10.0002ZM10.0007 5.66683C7.60742 5.66683 5.66732 7.60693 5.66732 10.0002C5.66732 11.1104 6.084 12.1218 6.77056 12.8889C7.01611 13.1632 6.99277 13.5847 6.71842 13.8303C6.44407 14.0758 6.0226 14.0525 5.77705 13.7781C4.88015 12.776 4.33398 11.4511 4.33398 10.0002C4.33398 6.87055 6.87104 4.3335 10.0007 4.3335C13.1303 4.3335 15.6673 6.87055 15.6673 10.0002C15.6673 11.4511 15.1212 12.776 14.2243 13.7781C13.9787 14.0525 13.5572 14.0758 13.2829 13.8303C13.0085 13.5847 12.9852 13.1632 13.2307 12.8889C13.9173 12.1218 14.334 11.1104 14.334 10.0002C14.334 7.60693 12.3939 5.66683 10.0007 5.66683ZM9.41732 10.0002C9.41732 9.678 9.67849 9.41683 10.0007 9.41683C10.3228 9.41683 10.584 9.678 10.584 10.0002C10.584 10.3223 10.3228 10.5835 10.0007 10.5835C9.67849 10.5835 9.41732 10.3223 9.41732 10.0002ZM10.0007 8.0835C8.94211 8.0835 8.08398 8.94162 8.08398 10.0002C8.08398 11.0587 8.94211 11.9168 10.0007 11.9168C11.0592 11.9168 11.9173 11.0587 11.9173 10.0002C11.9173 8.94162 11.0592 8.0835 10.0007 8.0835ZM8.37957 16.7918L10.0007 14.686L11.6217 16.7918H8.37957ZM9.34031 13.3579C9.6739 12.9246 10.3274 12.9246 10.661 13.3579L13.298 16.7835C13.7198 17.3315 13.3292 18.1251 12.6376 18.1251H7.36368C6.67214 18.1251 6.28151 17.3315 6.70334 16.7835L9.34031 13.3579Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -49,6 +49,7 @@ const {
IconFavoriteSolid,
IconFeedback,
IconGear,
IconGoLive,
IconGoogle,
IconHangup,
IconHelp,
@@ -168,6 +169,7 @@ export {
IconFavoriteSolid,
IconFeedback,
IconGear,
IconGoLive,
IconGoogle,
IconHangup,
IconHelp,

View File

@@ -30,6 +30,8 @@ import JitsiPortal from '../../../toolbox/components/web/JitsiPortal';
import Toolbox from '../../../toolbox/components/web/Toolbox';
import { LAYOUT_CLASSNAMES } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.any';
import VisitorsQueue from '../../../visitors/components/web/VisitorsQueue';
import { showVisitorsQueue } from '../../../visitors/functions.any';
import { init } from '../../actions.web';
import { maybeShowSuboptimalExperienceNotification } from '../../functions.web';
import {
@@ -100,6 +102,11 @@ interface IProps extends AbstractProps, WithTranslation {
*/
_showPrejoin: boolean;
/**
* If visitors queue page is visible or not.
*/
_showVisitorsQueue: boolean;
dispatch: IStore['dispatch'];
}
@@ -206,6 +213,7 @@ class Conference extends AbstractConference<IProps, any> {
_overflowDrawer,
_showLobby,
_showPrejoin,
_showVisitorsQueue,
t
} = this.props;
@@ -257,8 +265,9 @@ class Conference extends AbstractConference<IProps, any> {
<CalleeInfoContainer />
{ _showPrejoin && <Prejoin />}
{ _showLobby && <LobbyScreen />}
{ (_showPrejoin && !_showVisitorsQueue) && <Prejoin />}
{ (_showLobby && !_showVisitorsQueue) && <LobbyScreen />}
{ _showVisitorsQueue && <VisitorsQueue />}
</div>
<ParticipantsPane />
<ReactionAnimations />
@@ -402,7 +411,8 @@ function _mapStateToProps(state: IReduxState) {
_overflowDrawer: overflowDrawer,
_roomName: getConferenceNameForTitle(state),
_showLobby: getIsLobbyVisible(state),
_showPrejoin: isPrejoinPageVisible(state)
_showPrejoin: isPrejoinPageVisible(state),
_showVisitorsQueue: showVisitorsQueue(state)
};
}

View File

@@ -14,6 +14,8 @@ import styles from './styles';
const VisitorsList = () => {
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const isLive = useSelector(
(state: IReduxState) => state['features/base/conference'].metadata?.visitorsLive);
const dispatch = useDispatch();
@@ -30,7 +32,9 @@ const VisitorsList = () => {
let title = t('participantsPane.headings.visitors', { count: visitorsCount });
if (requests.length > 0) {
if (isLive === false) {
title = `${title} ${t('visitors.waiting')}`;
} else if (requests.length > 0) {
title += t('participantsPane.headings.visitorRequests', { count: requests.length });
}

View File

@@ -67,8 +67,18 @@ const useStyles = makeStyles()(theme => {
export default function VisitorsList() {
const requests = useSelector(getPromotionRequests);
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const isLive = useSelector(
(state: IReduxState) => state['features/base/conference'].metadata?.visitorsLive);
const { t } = useTranslation();
let visititorsHeading = t('participantsPane.headings.visitors', { count: visitorsCount });
if (isLive === false) {
let visitorsWaiting = t('visitors.waiting');
visititorsHeading = `${visititorsHeading} ${visitorsWaiting}`;
}
const { classes, cx } = useStyles();
const dispatch = useDispatch();
@@ -84,7 +94,7 @@ export default function VisitorsList() {
<>
<div className = { classes.headingContainer }>
<div className = { cx(classes.heading, classes.headingW) }>
{ t('participantsPane.headings.visitors', { count: visitorsCount })}
{ visititorsHeading }
{ requests.length > 0
&& t('participantsPane.headings.visitorRequests', { count: requests.length }) }
</div>

View File

@@ -27,6 +27,7 @@ import styles from '../../../video-menu/components/native/styles';
import WhiteboardButton from '../../../whiteboard/components/native/WhiteboardButton';
import { getMovableButtons } from '../../functions.native';
import GoLiveButton from './../../../visitors/components/GoLiveButton';
import AudioOnlyButton from './AudioOnlyButton';
import CustomOptionButton from './CustomOptionButton';
import LinkToSalesforceButton from './LinkToSalesforceButton';
@@ -164,6 +165,7 @@ class OverflowMenu extends PureComponent<IProps, IState> {
<SecurityDialogButton { ...buttonProps } />
<RecordButton { ...buttonProps } />
<LiveStreamButton { ...buttonProps } />
<GoLiveButton { ...buttonProps } />
<LinkToSalesforceButton { ...buttonProps } />
<WhiteboardButton { ...buttonProps } />
{/* @ts-ignore */}

View File

@@ -102,6 +102,7 @@ export const TOOLBAR_BUTTONS: ToolbarButton[] = [
'feedback',
'filmstrip',
'fullscreen',
'golive',
'hangup',
'help',
'highlight',

View File

@@ -56,6 +56,7 @@ import { useTileViewButton } from '../video-layout/hooks';
import VideoQualityButton from '../video-quality/components/VideoQualityButton.web';
import VideoQualityDialog from '../video-quality/components/VideoQualityDialog.web';
import { useVirtualBackgroundButton } from '../virtual-background/hooks';
import { useGoLiveButtonButton } from '../visitors/hooks.web';
import { useWhiteboardButton } from '../whiteboard/hooks';
import { setFullScreen } from './actions.web';
@@ -283,6 +284,7 @@ export function useToolboxButtons(
const participants = useParticipantPaneButton();
const tileview = useTileViewButton();
const cc = useClosedCaptionButton();
const golive = useGoLiveButtonButton();
const recording = useRecordingButton();
const liveStreaming = useLiveStreamingButton();
const linktosalesforce = useLinkToSalesforceButton();
@@ -313,6 +315,7 @@ export function useToolboxButtons(
fullscreen: _fullscreen,
security,
closedcaptions: cc,
golive,
recording,
livestreaming: liveStreaming,
linktosalesforce,

View File

@@ -18,6 +18,7 @@ export type ToolbarButton = 'camera' |
'feedback' |
'filmstrip' |
'fullscreen' |
'golive' |
'hangup' |
'help' |
'highlight' |

View File

@@ -39,6 +39,16 @@ export const VISITOR_PROMOTION_REQUEST = 'VISITOR_PROMOTION_REQUEST';
*/
export const CLEAR_VISITOR_PROMOTION_REQUEST = 'CLEAR_VISITOR_PROMOTION_REQUEST';
/**
* The type of (redux) action which sets in visitor's queue.
*
* {
* type: SET_IN_VISITORS_QUEUE,
* value: boolean
* }
*/
export const SET_IN_VISITORS_QUEUE = 'SET_IN_VISITORS_QUEUE';
/**
* The type of (redux) action which sets visitor demote actor.
*

View File

@@ -8,11 +8,13 @@ import { getLocalParticipant } from '../base/participants/functions';
import {
CLEAR_VISITOR_PROMOTION_REQUEST,
I_AM_VISITOR_MODE,
SET_IN_VISITORS_QUEUE,
SET_VISITORS_SUPPORTED,
SET_VISITOR_DEMOTE_ACTOR,
UPDATE_VISITORS_COUNT,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import { VISITORS_LIVE_ID } from './constants';
import { IPromotionRequest } from './types';
/**
@@ -150,6 +152,21 @@ export function setIAmVisitor(enabled: boolean) {
};
}
/**
* Sets in visitor's queue.
*
* @param {boolean} value - The new value.
* @returns {{
* type: SET_IN_VISITORS_QUEUE,
* }}
*/
export function setInVisitorsQueue(value: boolean) {
return {
type: SET_IN_VISITORS_QUEUE,
value
};
}
/**
* Sets visitor demote actor.
*
@@ -194,3 +211,17 @@ export function updateVisitorsCount(count: number) {
count
};
}
/**
* Closes the overflow menu if opened.
*
* @private
* @returns {void}
*/
export function goLive() {
return (_: IStore['dispatch'], getState: IStore['getState']) => {
const { conference } = getState()['features/base/conference'];
conference?.getMetadataHandler().setMetadata(VISITORS_LIVE_ID, true);
};
}

View File

@@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { translate } from '../../base/i18n/functions';
import { IconGoLive } from '../../base/icons/svg';
import AbstractButton, { IProps } from '../../base/toolbox/components/AbstractButton';
import { goLive } from '../actions';
/**
* Implements an {@link AbstractButton} to set the meeting to live and to invite all visitors.
*/
class GoLiveButton extends AbstractButton<IProps> {
accessibilityLabel = 'toolbar.accessibilityLabel.golive';
icon = IconGoLive;
label = 'toolbar.golive';
tooltip = 'toolbar.golive';
/**
* Handles clicking / pressing the button, and opens a new window with the user documentation.
*
* @private
* @returns {void}
*/
_handleClick() {
const { dispatch } = this.props;
sendAnalytics(createToolbarEvent('golive.pressed'));
dispatch(goLive());
}
}
export default translate(connect()(GoLiveButton));

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconUsers } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import { getVisitorsShortText, iAmVisitor } from '../../functions';
import { getVisitorsShortText, iAmVisitor, isVisitorsLive } from '../../functions';
const styles = {
raisedHandsCountLabel: {
@@ -27,13 +28,20 @@ const VisitorsCountLabel = () => {
const visitorsMode = useSelector((state: IReduxState) => iAmVisitor(state));
const visitorsCount = useSelector((state: IReduxState) =>
state['features/visitors'].count || 0);
const { t } = useTranslation();
const isLive = useSelector(isVisitorsLive);
let visitorsWaiting = '';
if (isLive === false) {
visitorsWaiting = t('visitors.waiting');
}
return !visitorsMode && visitorsCount > 0 ? (
<Label
icon = { IconUsers }
iconColor = { BaseTheme.palette.uiBackground }
style = { styles.raisedHandsCountLabel }
text = { `${getVisitorsShortText(visitorsCount)}` }
text = { `${getVisitorsShortText(visitorsCount)} ${visitorsWaiting}` }
textStyle = { styles.raisedHandsCountLabelText } />
) : null;
};

View File

@@ -0,0 +1,8 @@
/**
* The component that renders visitors queue UI.
*
* @returns {ReactElement}
*/
export default function VisitorsQueue() {
return undefined;
}

View File

@@ -7,7 +7,7 @@ import { IReduxState } from '../../../app/types';
import { IconUsers } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { getVisitorsShortText, iAmVisitor } from '../../functions';
import { getVisitorsShortText, iAmVisitor, isVisitorsLive } from '../../functions';
const useStyles = makeStyles()(theme => {
return {
@@ -24,16 +24,25 @@ const VisitorsCountLabel = () => {
const visitorsCount = useSelector((state: IReduxState) =>
state['features/visitors'].count || 0);
const { t } = useTranslation();
const isLive = useSelector(isVisitorsLive);
let visitorsWaiting = '';
let labelTooltip = t('visitors.labelTooltip', { count: visitorsCount });
if (isLive === false) {
visitorsWaiting = t('visitors.waiting');
labelTooltip = `${labelTooltip} ${visitorsWaiting}`;
visitorsWaiting = '*';
}
return !visitorsMode && visitorsCount > 0 ? (<Tooltip
content = { t('visitors.labelTooltip', { count: visitorsCount }) }
content = { labelTooltip }
position = { 'bottom' }>
<Label
className = { styles.label }
icon = { IconUsers }
iconColor = { theme.palette.icon04 }
id = 'visitorsCountLabel'
text = { `${getVisitorsShortText(visitorsCount)}` } />
text = { `${getVisitorsShortText(visitorsCount)} ${visitorsWaiting}` } />
</Tooltip>) : null;
};

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { withPixelLineHeight } from '../../../base/styles/functions';
import LoadingIndicator from '../../../base/ui/components/web/Spinner';
const useStyles = makeStyles()(theme => {
return {
container: {
height: '100%',
position: 'absolute',
inset: '0 0 0 0',
display: 'flex',
backgroundColor: theme.palette.ui01,
zIndex: 252,
'@media (max-width: 720px)': {
flexDirection: 'column-reverse'
}
},
content: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flexShrink: 0,
boxSizing: 'border-box',
position: 'relative',
width: '100%',
height: '100%',
zIndex: 252,
'@media (max-width: 720px)': {
height: 'auto',
margin: '0 auto'
},
// mobile phone landscape
'@media (max-width: 420px)': {
padding: '16px 16px 0 16px',
width: '100%'
},
'@media (max-width: 400px)': {
padding: '16px'
}
},
contentControls: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: 'auto',
width: '100%'
},
roomName: {
...withPixelLineHeight(theme.typography.heading5),
color: theme.palette.text01,
marginBottom: theme.spacing(4),
overflow: 'hidden',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%'
},
spinner: {
margin: '8px'
}
};
});
/**
* The component that renders visitors queue UI.
*
* @returns {ReactElement}
*/
export default function VisitorsQueue() {
const { classes } = useStyles();
const { t } = useTranslation();
return (<div className = { classes.container }>
<div className = { classes.content }>
<div className = { classes.contentControls }>
<span className = { classes.roomName }>
{ t('visitors.waitingMessage') }
</span>
<div className = { classes.spinner }>
<LoadingIndicator size = 'large' />
</div>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,4 @@
/**
* The metadata field ID for visitors live event.
*/
export const VISITORS_LIVE_ID = 'visitorsLive';

View File

@@ -45,3 +45,36 @@ export function iAmVisitor(stateful: IStateful) {
export function getVisitorsCount(stateful: IStateful) {
return toState(stateful)['features/visitors'].count ?? 0;
}
/**
* Whether visitor mode is supported.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @returns {boolean} Whether visitor moder is supported.
*/
export function isVisitorsSupported(stateful: IStateful) {
return toState(stateful)['features/visitors'].supported;
}
/**
* Whether visitor mode is live.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @returns {boolean} Whether visitor moder is live.
*/
export function isVisitorsLive(stateful: IStateful) {
return toState(stateful)['features/base/conference'].metadata?.visitorsLive;
}
/**
* Whether to show visitor queue screen.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @returns {boolean} Whether current participant is visitor and is in queue.
*/
export function showVisitorsQueue(stateful: IStateful) {
return toState(stateful)['features/visitors'].inVisitorsQueue;
}

View File

@@ -0,0 +1,15 @@
import { IReduxState } from '../app/types';
import { GOLIVE_BUTTON_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
export * from './functions.any';
/**
* Returns the config whether Go Live button is enabled.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function isGoLiveButtonEnabled(state: IReduxState): string {
return getFeatureFlag(state, GOLIVE_BUTTON_ENABLED, true);
}

View File

@@ -0,0 +1,14 @@
import { IReduxState } from '../app/types';
import { isButtonEnabled } from '../toolbox/functions';
export * from './functions.any';
/**
* Returns the config whether Go Live button is enabled.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function isGoLiveButtonEnabled(state: IReduxState): boolean {
return isButtonEnabled('golive', state);
}

View File

@@ -0,0 +1,28 @@
import { useSelector } from 'react-redux';
import { isLocalParticipantModerator } from '../base/participants/functions';
import GoLiveButton from './components/GoLiveButton';
import { isGoLiveButtonEnabled, isVisitorsLive, isVisitorsSupported } from './functions';
const golive = {
key: 'golive',
Content: GoLiveButton,
group: 2
};
/**
* A hook that returns the CC button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useGoLiveButtonButton() {
const isModerator = useSelector(isLocalParticipantModerator);
const supported = useSelector(isVisitorsSupported);
const enabled = useSelector(isGoLiveButtonEnabled);
const isLive = useSelector(isVisitorsLive);
if (isModerator && supported && enabled && !isLive) {
return golive;
}
}

View File

@@ -2,17 +2,28 @@ import i18n from 'i18next';
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { IStateful } from '../base/app/types';
import {
CONFERENCE_JOINED,
CONFERENCE_JOIN_IN_PROGRESS,
ENDPOINT_MESSAGE_RECEIVED
ENDPOINT_MESSAGE_RECEIVED,
UPDATE_CONFERENCE_METADATA
} from '../base/conference/actionTypes';
import { SET_CONFIG } from '../base/config/actionTypes';
import { CONNECTION_FAILED } from '../base/connection/actionTypes';
import { connect, setPreferVisitor } from '../base/connection/actions';
import { disconnect } from '../base/connection/actions.any';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { JitsiConferenceEvents, JitsiConnectionErrors } from '../base/lib-jitsi-meet';
import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes';
import { raiseHand } from '../base/participants/actions';
import { getLocalParticipant, getParticipantById } from '../base/participants/functions';
import {
getLocalParticipant,
getParticipantById,
isLocalParticipantModerator,
isParticipantModerator
} from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { toState } from '../base/redux/functions';
import { BUTTON_TYPES } from '../base/ui/constants.any';
import { hideNotification, showNotification } from '../notifications/actions';
import {
@@ -22,18 +33,21 @@ import {
} from '../notifications/constants';
import { INotificationProps } from '../notifications/types';
import { open as openParticipantsPane } from '../participants-pane/actions';
import { joinConference } from '../prejoin/actions';
import {
approveRequest,
clearPromotionRequest,
denyRequest,
promotionRequestReceived,
promotionRequestReceived, setInVisitorsQueue,
setVisitorDemoteActor,
setVisitorsSupported,
updateVisitorsCount
} from './actions';
import { VISITORS_LIVE_ID } from './constants';
import { getPromotionRequests } from './functions';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.type) {
@@ -130,17 +144,130 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
if (data?.action === 'promotion-response' && data.approved) {
const request = getPromotionRequests(getState())
.find(r => r.from === data.id);
.find((r: any) => r.from === data.id);
request && dispatch(clearPromotionRequest(request));
}
break;
}
case CONNECTION_FAILED: {
const { error } = action;
if (error?.name !== JitsiConnectionErrors.NOT_READY_ERROR) {
break;
}
const { hosts, preferVisitor, visitors: visitorsConfig } = getState()['features/base/config'];
const { locationURL } = getState()['features/base/connection'];
if (!visitorsConfig?.queueService || !locationURL || !preferVisitor) {
break;
}
// let's subscribe for visitor waiting queue
const { room } = getState()['features/base/conference'];
const conferenceJid = `${room}@${hosts?.muc}`;
WebsocketClient.getInstance()
.connect(`wss://${visitorsConfig?.queueService}/visitor/websocket`,
`/secured/conference/visitor/topic.${conferenceJid}`,
msg => {
if ('status' in msg && msg.status === 'live') {
WebsocketClient.getInstance().disconnect();
dispatch(setInVisitorsQueue(false));
let delay = 0;
// now let's connect to meeting
if ('randomDelayMs' in msg) {
delay = msg.randomDelayMs;
}
setTimeout(() => {
dispatch(joinConference());
}, delay);
}
},
getState()['features/base/jwt'].jwt,
() => {
dispatch(setInVisitorsQueue(true));
});
break;
}
case PARTICIPANT_UPDATED: {
const { participant } = action;
const { local } = participant;
if (local && isParticipantModerator(participant)) {
const { metadata } = getState()['features/base/conference'];
if (metadata?.visitorsLive === false) {
// when go live is available and false, we should subscribe
// to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
}
break;
}
case SET_CONFIG: {
const result = next(action);
const { preferVisitor } = action.config;
if (preferVisitor !== undefined) {
setPreferVisitor(preferVisitor);
}
return result;
}
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
if (isLocalParticipantModerator(getState)) {
if (metadata?.[VISITORS_LIVE_ID] === false) {
// if metadata go live changes to goLive false and local is moderator
// we should subscribe to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
} else if (metadata?.[VISITORS_LIVE_ID]) {
WebsocketClient.getInstance().disconnect();
}
}
break;
}
}
return next(action);
});
/**
* Subscribe for moderator stats.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @param {Dispatch} dispatch - The Redux dispatch function.
* @returns {void}
*/
function _subscribeQueueStats(stateful: IStateful, dispatch: IStore['dispatch']) {
const { hosts } = toState(stateful)['features/base/config'];
const { room } = toState(stateful)['features/base/conference'];
const conferenceJid = `${room}@${hosts?.muc}`;
const { visitors: visitorsConfig } = toState(stateful)['features/base/config'];
WebsocketClient.getInstance()
.connect(`wss://${visitorsConfig?.queueService}/visitor/websocket`,
`/secured/conference/state/topic.${conferenceJid}`,
msg => {
if ('visitorsWaiting' in msg) {
dispatch(updateVisitorsCount(msg.visitorsWaiting));
}
},
toState(stateful)['features/base/jwt'].jwt);
}
/**
* Function to handle the promotion notification.
*

View File

@@ -4,6 +4,7 @@ import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CLEAR_VISITOR_PROMOTION_REQUEST,
I_AM_VISITOR_MODE,
SET_IN_VISITORS_QUEUE,
SET_VISITORS_SUPPORTED,
SET_VISITOR_DEMOTE_ACTOR,
UPDATE_VISITORS_COUNT,
@@ -14,6 +15,7 @@ import { IPromotionRequest } from './types';
const DEFAULT_STATE = {
count: -1,
iAmVisitor: false,
inVisitorsQueue: false,
showNotification: false,
supported: false,
promotionRequests: []
@@ -23,6 +25,7 @@ export interface IVisitorsState {
count?: number;
demoteActorDisplayName?: string;
iAmVisitor: boolean;
inVisitorsQueue: boolean;
promotionRequests: IPromotionRequest[];
supported: boolean;
}
@@ -55,6 +58,12 @@ ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_S
iAmVisitor: action.enabled
};
}
case SET_IN_VISITORS_QUEUE: {
return {
...state,
inVisitorsQueue: action.value
};
}
case SET_VISITOR_DEMOTE_ACTOR: {
return {
...state,

View File

@@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Client } from '@stomp/stompjs';
import logger from './logger';
interface QueueServiceResponse {
conference: string;
}
export interface StateResponse extends QueueServiceResponse {
randomDelayMs: number;
status: string;
}
export interface VisitorResponse extends QueueServiceResponse {
visitorsWaiting: number;
}
/**
* Websocket client impl, used for visitors queue.
* Uses STOMP for authenticating (https://stomp.github.io/).
*/
export class WebsocketClient {
private stompClient: Client | undefined;
private static instance: WebsocketClient;
private retriesCount = 0;
/**
* WebsocketClient getInstance.
*
* @static
* @returns {WebsocketClient} - WebsocketClient instance.
*/
static getInstance(): WebsocketClient {
if (!this.instance) {
this.instance = new WebsocketClient();
}
return this.instance;
}
/**
* Connect to endpoint.
*
* @param {string} queueServiceURL - The service URL to use.
* @param {string} endpoint - The endpoint to subscribe to.
* @param {Function} callback - The callback to execute when we receive a message from the endpoint.
* @param {string} token - The token, if any, to be used for authorization.
* @param {Function?} connectCallback - The callback to execute when successfully connected.
*
* @returns {void}
*/
connect(queueServiceURL: string, // eslint-disable-line max-params
endpoint: string,
callback: (response: StateResponse | VisitorResponse) => void,
token: string | undefined,
connectCallback?: () => void): void {
this.stompClient = new Client({
brokerURL: queueServiceURL
});
const errorConnecting = (error: any) => {
if (this.retriesCount > 3) {
this.stompClient?.deactivate();
this.stompClient = undefined;
return;
}
this.retriesCount++;
logger.error(`Error connecting to ${queueServiceURL} ${error || ''}`);
};
this.stompClient.onWebSocketError = errorConnecting;
this.stompClient.onStompError = frame => {
errorConnecting(frame.headers.message);
};
if (token) {
this.stompClient.connectHeaders = {
Authorization: `Bearer ${token}`
};
}
this.stompClient.onConnect = () => {
if (!this.stompClient) {
return;
}
this.retriesCount = 0;
logger.info(`Connected to:${endpoint}`);
connectCallback?.();
this.stompClient.subscribe(endpoint, message => {
try {
callback(JSON.parse(message.body));
} catch (e) {
logger.error(`Error parsing response: ${message}`, e);
}
});
};
this.stompClient.activate();
}
/**
* Disconnects the current stomp client instance and clears it.
*
* @returns {void}
*/
disconnect(): void {
if (!this.stompClient) {
return;
}
const url = this.stompClient.brokerURL;
this.stompClient.deactivate().then(() => {
logger.info(`disconnected from: ${url}`);
this.stompClient = undefined;
});
}
}

View File

@@ -36,6 +36,7 @@ local breakout_rooms_component_host = module:get_option_string('breakout_rooms_c
module:log("info", "Starting room metadata for %s", muc_component_host);
local main_muc_module;
-- Utility functions
@@ -86,7 +87,10 @@ function room_created(event)
return ;
end
room.jitsiMetadata = {};
-- jitsiMetadata can be pre-set somewhere else
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
end
function on_message(event)
@@ -146,6 +150,9 @@ function on_message(event)
broadcastMetadata(room);
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
return true;
end
@@ -156,6 +163,8 @@ module:hook("message/host", on_message);
-- operates on already loaded main muc module
function process_main_muc_loaded(main_muc, host_module)
main_muc_module = host_module;
module:log('debug', 'Main muc loaded');
module:log("info", "Hook to muc events on %s", muc_component_host);

View File

@@ -1,9 +1,4 @@
--- activate under main vhost
--- In /etc/hosts add:
--- vm1-ip-address visitors1.domain.com
--- vm1-ip-address conference.visitors1.domain.com
--- vm2-ip-address visitors2.domain.com
--- vm2-ip-address conference.visitors2.domain.com
--- Enable in global modules: 's2s_bidi' and 'certs_all'
--- Make sure 's2s' is not in modules_disabled
--- Open port 5269 on the provider side and on the firewall on the machine (iptables -I INPUT 4 -p tcp -m tcp --dport 5269 -j ACCEPT)

View File

@@ -1,5 +1,6 @@
module:log('info', 'Starting visitors_component at %s', module.host);
local http = require 'net.http';
local jid = require 'util.jid';
local st = require 'util.stanza';
local util = module:require 'util';
@@ -18,6 +19,8 @@ local um_is_admin = require 'core.usermanager'.is_admin;
local json = require 'cjson.safe';
local inspect = require 'inspect';
local token_util = module:require 'token/util'.new(module);
local MUC_NS = 'http://jabber.org/protocol/muc';
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
@@ -36,6 +39,13 @@ local auto_allow_promotion = module:get_option_boolean('auto_allow_visitor_promo
-- can be set to off and being controlled by another module, turning it on and off for rooms
local always_visitors_enabled = module:get_option_boolean('always_visitors_enabled', true);
local visitors_queue_service = module:get_option_string('visitors_queue_service');
local http_headers = {
["User-Agent"] = "Prosody (" .. prosody.version .. "; " .. prosody.platform .. ")",
["Content-Type"] = "application/json",
["Accept"] = "application/json"
};
local function is_admin(jid)
return um_is_admin(jid, module.host);
end
@@ -306,6 +316,39 @@ local function process_promotion_response(room, id, approved)
allow = approved }):up());
end
local function go_live(room)
-- let's inform the queue service
local function cb(content_, code_, response_, request_)
local room = room;
if code_ == 200 then
-- meeting went live ???
module:log('info', 'live')
else
module:log('warn', 'External call to visitors_queue_service/golive failed. Code %s, Content %s',
code_, content_)
end
end
local headers = http_headers or {};
headers['Authorization'] = token_util:generateAsapToken();
-- local ev = {
-- conference = internal_room_jid_match_rewrite(room.jid)
-- };
-- local ev = {
-- conference = 'lksdjfkjsdfh1@conference.vpaas-magic-cookie-eee8959313084417a035ca3409e52105.stage.8x8.vc'
-- };
local ev = {
conference = 'lksdjfkjsdfh1@conference.vpaas-magic-cookie-43865235d9064762bf500eb65286ea6a.stage.8x8.vc'
};
http.request(visitors_queue_service..'/golive', {
headers = headers,
method = 'POST',
body = json.encode(ev);
}, cb);
end
module:hook('iq/host', stanza_handler, 10);
process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_module, host)
@@ -463,6 +506,22 @@ process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_modul
return true; -- halt processing, but return true that we handled it
end);
if visitors_queue_service then
host_module:hook('muc-room-created', function (event)
local room = event.room;
if room.jitsiMetadata.visitorsLive then
go_live(room);
end
end, -2); -- metadata hook on -1
host_module:hook('jitsi-metadata-updated', function (event)
if event.key == 'visitorsLive' then
local room = event.room;
if room.jitsiMetadata.visitorsLive then
go_live(room);
end
end
end);
end
if always_visitors_enabled then
local visitorsEnabledField = {

View File

@@ -26,6 +26,24 @@ local ssl = require "ssl";
-- TODO: Figure out a less arbitrary default cache size.
local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128);
-- the cache for generated asap jwt tokens
local jwtKeyCache = require 'util.cache'.new(cacheSize);
local ASAPTTL_THRESHOLD = module:get_option_number('asap_ttl_threshold', 600);
local ASAPTTL = module:get_option_number('asap_ttl', 3600);
local ASAPIssuer = module:get_option_string('asap_issuer', 'jitsi');
local ASAPAudience = module:get_option_string('asap_audience', 'jitsi');
local ASAPKeyId = module:get_option_string('asap_key_id', 'jitsi');
local ASAPKeyPath = module:get_option_string('asap_key_path', '/etc/prosody/certs/asap.key');
local ASAPKey;
local f = io.open(ASAPKeyPath, 'r');
module:log()
if f then
ASAPKey = f:read('*all');
f:close();
end
local Util = {}
Util.__index = Util
@@ -479,4 +497,48 @@ function Util:verify_room(session, room_address)
end
end
function Util:generateAsapToken(audience)
if not ASAPKey then
module:log('warn', 'No ASAP Key read, asap key generation is disabled');
return ''
end
audience = audience or ASAPAudience
local t = os.time()
local err
local exp_key = 'asap_exp.'..audience
local token_key = 'asap_token.'..audience
local exp = jwtKeyCache:get(exp_key)
local token = jwtKeyCache:get(token_key)
--if we find a token and it isn't too far from expiry, then use it
if token ~= nil and exp ~= nil then
exp = tonumber(exp)
if (exp - t) > ASAPTTL_THRESHOLD then
return token
end
end
--expiry is the current time plus TTL
exp = t + ASAPTTL
local payload = {
iss = ASAPIssuer,
aud = audience,
nbf = t,
exp = exp,
}
-- encode
local alg = 'RS256'
token, err = jwt.encode(payload, ASAPKey, alg, { kid = ASAPKeyId })
if not err then
token = 'Bearer '..token
jwtKeyCache:set(exp_key, exp)
jwtKeyCache:set(token_key, token)
return token
else
return ''
end
end
return Util;