feat(visitors): Handles live conference and queue service. (#14869)

* feat(visitors): Handling of live conference and queue service.

* squash: Small refactor mobile code.

* squash: Drop debug log.

* chore(deps) lib-jitsi-meet@latest

https://github.com/jitsi/lib-jitsi-meet/compare/v1836.0.0+d05325f3...v1839.0.0+ea523fc6

* squash: Adds a count function.

* squash: Drop debug print.

* squash: Skip if queueService is not enabled.

* squash: Avoids double subscribing for visitorsWaiting.

* squash: Fixes lint error.

* squash: Fixes showing dialog.
This commit is contained in:
Дамян Минков
2024-06-28 15:29:41 +03:00
committed by GitHub
parent d618175074
commit f0fc63f573
32 changed files with 852 additions and 51 deletions

View File

@@ -822,6 +822,8 @@
"viewLobby": "View lobby",
"viewVisitors": "View visitors",
"waitingParticipants": "{{waitingParticipants}} people",
"waitingVisitors": "Visitors waiting in queue: {{waitingVisitors}}",
"waitingVisitorsTitle": "The meeting is not live yet!",
"whiteboardLimitDescription": "Please save your progress, as the user limit will soon be reached and the whiteboard will close.",
"whiteboardLimitTitle": "Whiteboard usage"
},
@@ -835,6 +837,7 @@
"audioModeration": "Unmute themselves",
"blockEveryoneMicCamera": "Block everyone's mic and camera",
"breakoutRooms": "Breakout rooms",
"goLive": "Go live",
"invite": "Invite Someone",
"moreModerationActions": "More moderation options",
"moreModerationControls": "More moderation controls",
@@ -852,6 +855,7 @@
"headings": {
"lobby": "Lobby ({{count}})",
"participantsList": "Meeting participants ({{count}})",
"visitorInQueue": " (waiting {{count}})",
"visitorRequests": " (requests {{count}})",
"visitors": "Visitors {{count}}",
"waitingLobby": "Waiting in lobby ({{count}})"
@@ -1495,7 +1499,8 @@
"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"
}
},
"waitingMessage": "You'll join the meeting as soon as it is live!"
},
"volumeSlider": "Volume slider",
"welcomepage": {

21
package-lock.json generated
View File

@@ -35,6 +35,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",
@@ -60,7 +61,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1838.0.0+1993a030/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1839.0.0+ea523fc6/lib-jitsi-meet.tgz",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
@@ -5309,6 +5310,11 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@stomp/stompjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz",
"integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw=="
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.3.1.tgz",
@@ -12304,8 +12310,8 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1838.0.0+1993a030/lib-jitsi-meet.tgz",
"integrity": "sha512-IRZnKBO5o2OjmDDI8ocmq5nyi6RZN0DhLIBgNm5u2sWQBgUa7Na1MqSKd/kDI9pGrw5BfygAz4cPOGCnRmkqww==",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1839.0.0+ea523fc6/lib-jitsi-meet.tgz",
"integrity": "sha512-pRIOBZFtIT4Y0WrrfxNKJnMbDx17ZYYbU3ygUd/hYvvyOkKMMteS4cZWqONAxn+esKhpkWksPSuZ8L4y4V5wnQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -22865,6 +22871,11 @@
"@sinonjs/commons": "^3.0.0"
}
},
"@stomp/stompjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz",
"integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw=="
},
"@svgr/babel-plugin-add-jsx-attribute": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.3.1.tgz",
@@ -28085,8 +28096,8 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1838.0.0+1993a030/lib-jitsi-meet.tgz",
"integrity": "sha512-IRZnKBO5o2OjmDDI8ocmq5nyi6RZN0DhLIBgNm5u2sWQBgUa7Na1MqSKd/kDI9pGrw5BfygAz4cPOGCnRmkqww==",
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1839.0.0+ea523fc6/lib-jitsi-meet.tgz",
"integrity": "sha512-pRIOBZFtIT4Y0WrrfxNKJnMbDx17ZYYbU3ygUd/hYvvyOkKMMteS4cZWqONAxn+esKhpkWksPSuZ8L4y4V5wnQ==",
"requires": {
"@jitsi/js-utils": "2.2.1",
"@jitsi/logger": "2.0.2",

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",
@@ -66,7 +67,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1838.0.0+1993a030/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1839.0.0+ea523fc6/lib-jitsi-meet.tgz",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",

View File

@@ -54,6 +54,9 @@ export interface IConferenceMetadata {
recording?: {
isTranscribingEnabled: boolean;
};
visitors?: {
live: 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

@@ -1,5 +1,8 @@
import { appNavigate } from '../../app/actions.native';
import { IStore } from '../../app/types';
import { navigateRoot } from '../../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../../mobile/navigation/routes';
import { JitsiConnectionErrors } from '../lib-jitsi-meet';
import { _connectInternal } from './actions.any';
@@ -13,7 +16,12 @@ export * from './actions.any';
* @returns {Function}
*/
export function connect(id?: string, password?: string) {
return (dispatch: IStore['dispatch']) => dispatch(_connectInternal(id, password));
return (dispatch: IStore['dispatch']) => dispatch(_connectInternal(id, password))
.catch(error => {
if (error === JitsiConnectionErrors.NOT_LIVE_ERROR) {
navigateRoot(screen.visitorsQueue);
}
});
}
/**

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';
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

@@ -9,6 +9,7 @@ import DialInSummary from '../../../invite/components/dial-in-summary/native/Dia
import Prejoin from '../../../prejoin/components/native/Prejoin';
import UnsafeRoomWarning from '../../../prejoin/components/native/UnsafeRoomWarning';
import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
import VisitorsQueue from '../../../visitors/components/native/VisitorsQueue';
// eslint-disable-next-line
// @ts-ignore
import WelcomePage from '../../../welcome/components/WelcomePage';
@@ -23,6 +24,7 @@ import {
navigationContainerTheme,
preJoinScreenOptions,
unsafeMeetingScreenOptions,
visitorsScreenOptions,
welcomeScreenOptions
} from '../screenOptions';
@@ -105,6 +107,10 @@ const RootNavigationContainer = ({ dispatch, isUnsafeRoomWarningAvailable, isWel
name = { screen.unsafeRoomWarning }
options = { unsafeMeetingScreenOptions } />
}
<RootStack.Screen
component = { VisitorsQueue }
name = { screen.visitorsQueue }
options = { visitorsScreenOptions } />
<RootStack.Screen
component = { ConferenceNavigationContainer }
name = { screen.conference.root }

View File

@@ -44,6 +44,7 @@ export const screen = {
profile: 'Profile'
},
unsafeRoomWarning: 'Unsafe Room Warning',
visitorsQueue: 'Visitors Queue',
welcome: {
main: 'Welcome',
tabs: {

View File

@@ -55,6 +55,11 @@ export const welcomeScreenOptions = {
*/
export const conferenceScreenOptions = fullScreenOptions;
/**
* Screen options for visitors queue.
*/
export const visitorsScreenOptions = fullScreenOptions;
/**
* Tab bar options for chat screen.
*/

View File

@@ -178,6 +178,7 @@ const Notification = ({
description,
descriptionArguments,
descriptionKey,
disableClosing,
hideErrorSupportLink,
icon,
onDismissed,
@@ -336,14 +337,16 @@ const Notification = ({
))}
</div>
</div>
<Icon
className = { classes.closeIcon }
color = { theme.palette.icon04 }
id = 'close-notification'
onClick = { onDismiss }
size = { 20 }
src = { IconCloseLarge }
testId = { `${titleKey || descriptionKey}-dismiss` } />
{ !disableClosing && (
<Icon
className = { classes.closeIcon }
color = { theme.palette.icon04 }
id = 'close-notification'
onClick = { onDismiss }
size = { 20 }
src = { IconCloseLarge }
testId = { `${titleKey || descriptionKey}-dismiss` } />
)}
</div>
</div>
);

View File

@@ -104,12 +104,19 @@ export const RAISE_HAND_NOTIFICATION_ID = 'RAISE_HAND_NOTIFICATION';
export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION';
/**
* The identifier of the lobby notification.
* The identifier of the visitors promotion notification.
*
* @type {string}
*/
export const VISITORS_PROMOTION_NOTIFICATION_ID = 'VISITORS_PROMOTION_NOTIFICATION';
/**
* The identifier of the visitors notification indicating the meeting is not live.
*
* @type {string}
*/
export const VISITORS_NOT_LIVE_NOTIFICATION_ID = 'VISITORS_NOT_LIVE_NOTIFICATION_ID';
/**
* Amount of participants beyond which no join notification will be emitted.
*/

View File

@@ -9,6 +9,7 @@ export interface INotificationProps {
description?: string | React.ReactNode;
descriptionArguments?: Object;
descriptionKey?: string;
disableClosing?: boolean;
hideErrorSupportLink?: boolean;
icon?: string;
maxLines?: number;

View File

@@ -1,5 +1,5 @@
import { IStore } from '../app/types';
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
import { JitsiConferenceErrors, JitsiConnectionErrors } from '../base/lib-jitsi-meet';
import {
isFatalJitsiConferenceError,
isFatalJitsiConnectionError
@@ -8,7 +8,6 @@ import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { openPageReloadDialog } from './actions';
/**
* Error type. Basically like Error, but augmented with a recoverable property.
*/
@@ -34,6 +33,7 @@ type ErrorType = {
* List of errors that are not fatal (or handled differently) so then the page reload dialog won't kick in.
*/
const RN_NO_RELOAD_DIALOG_ERRORS = [
JitsiConnectionErrors.NOT_LIVE_ERROR,
JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED,
JitsiConferenceErrors.CONFERENCE_DESTROYED,
JitsiConferenceErrors.CONNECTION_ERROR,

View File

@@ -3,17 +3,21 @@ import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_MODES, BUTTON_TYPES } from '../../../base/ui/constants.native';
import { admitMultiple } from '../../../visitors/actions';
import { getPromotionRequests } from '../../../visitors/functions';
import { admitMultiple, goLive } from '../../../visitors/actions';
import {
getPromotionRequests,
getVisitorsCount,
getVisitorsInQueueCount,
isVisitorsLive
} from '../../../visitors/functions';
import { VisitorsItem } from './VisitorsItem';
import styles from './styles';
const VisitorsList = () => {
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const visitorsCount = useSelector(getVisitorsCount);
const dispatch = useDispatch();
@@ -22,9 +26,16 @@ const VisitorsList = () => {
const admitAll = useCallback(() => {
dispatch(admitMultiple(requests));
}, [ dispatch, requests ]);
const goLiveCb = useCallback(() => {
dispatch(goLive());
}, [ dispatch ]);
const { t } = useTranslation();
if (visitorsCount <= 0) {
const visitorsInQueueCount = useSelector(getVisitorsInQueueCount);
const isLive = useSelector(isVisitorsLive);
const showVisitorsInQueue = visitorsInQueueCount > 0 && isLive === false;
if (visitorsCount <= 0 && !showVisitorsInQueue) {
return null;
}
@@ -34,6 +45,10 @@ const VisitorsList = () => {
title += t('participantsPane.headings.visitorRequests', { count: requests.length });
}
if (showVisitorsInQueue) {
title += t('participantsPane.headings.visitorInQueue', { count: visitorsInQueueCount });
}
return (
<>
<View style = { styles.listDetails as ViewStyle } >
@@ -41,7 +56,7 @@ const VisitorsList = () => {
{ title }
</Text>
{
requests.length > 1 && (
requests.length > 1 && !showVisitorsInQueue && (
<Button
accessibilityLabel = 'participantsPane.actions.admitAll'
labelKey = 'participantsPane.actions.admitAll'
@@ -50,6 +65,16 @@ const VisitorsList = () => {
type = { BUTTON_TYPES.PRIMARY } />
)
}
{
showVisitorsInQueue && (
<Button
accessibilityLabel = 'participantsPane.actions.goLive'
labelKey = 'participantsPane.actions.goLive'
mode = { BUTTON_MODES.TEXT }
onClick = { goLiveCb }
type = { BUTTON_TYPES.PRIMARY } />
)
}
</View>
{
requests.map(r => (

View File

@@ -3,10 +3,14 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { admitMultiple } from '../../../visitors/actions';
import { getPromotionRequests } from '../../../visitors/functions';
import { admitMultiple, goLive } from '../../../visitors/actions';
import {
getPromotionRequests,
getVisitorsCount,
getVisitorsInQueueCount,
isVisitorsLive
} from '../../../visitors/functions';
import { VisitorsItem } from './VisitorsItem';
@@ -66,7 +70,10 @@ const useStyles = makeStyles()(theme => {
*/
export default function VisitorsList() {
const requests = useSelector(getPromotionRequests);
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count || 0);
const visitorsCount = useSelector(getVisitorsCount);
const visitorsInQueueCount = useSelector(getVisitorsInQueueCount);
const isLive = useSelector(isVisitorsLive);
const showVisitorsInQueue = visitorsInQueueCount > 0 && isLive === false;
const { t } = useTranslation();
const { classes, cx } = useStyles();
@@ -76,7 +83,11 @@ export default function VisitorsList() {
dispatch(admitMultiple(requests));
}, [ dispatch, requests ]);
if (visitorsCount <= 0) {
const goLiveCb = useCallback(() => {
dispatch(goLive());
}, [ dispatch ]);
if (visitorsCount <= 0 && !showVisitorsInQueue) {
return null;
}
@@ -87,12 +98,20 @@ export default function VisitorsList() {
{ t('participantsPane.headings.visitors', { count: visitorsCount })}
{ requests.length > 0
&& t('participantsPane.headings.visitorRequests', { count: requests.length }) }
{ showVisitorsInQueue
&& t('participantsPane.headings.visitorInQueue', { count: visitorsInQueueCount }) }
</div>
{
requests.length > 1
requests.length > 1 && !showVisitorsInQueue // Go live button is with higher priority
&& <div
className = { classes.link }
onClick = { admitAll }>{t('participantsPane.actions.admitAll')}</div>
onClick = { admitAll }>{ t('participantsPane.actions.admitAll') }</div>
}
{
showVisitorsInQueue
&& <div
className = { classes.link }
onClick = { goLiveCb }>{ t('participantsPane.actions.goLive') }</div>
}
</div>
<div

View File

@@ -1,4 +1,8 @@
import { IStore } from '../app/types';
import { connect } from '../base/connection/actions.native';
import { navigateRoot } from '../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import { showVisitorsQueue } from '../visitors/functions';
/**
* Action used to start the conference.
@@ -8,7 +12,12 @@ import { IStore } from '../app/types';
* @returns {Function}
*/
export function joinConference(options?: Object, _ignoreJoiningInProgress = false) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return async function(_dispatch: IStore['dispatch'], _getState: IStore['getState']) {
return async function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const _showVisitorsQueue = showVisitorsQueue(getState);
if (_showVisitorsQueue) {
dispatch(connect());
navigateRoot(screen.conference.root);
}
};
}

View File

@@ -8,6 +8,16 @@
*/
export const UPDATE_VISITORS_COUNT = 'UPDATE_VISITORS_COUNT';
/**
* The type of (redux) action to update visitors in queue count.
*
* {
* type: UPDATE_VISITORS_IN_QUEUE_COUNT,
* count: number
* }
*/
export const UPDATE_VISITORS_IN_QUEUE_COUNT = 'UPDATE_VISITORS_IN_QUEUE_COUNT';
/**
* The type of (redux) action which enables/disables visitors UI mode.
*
@@ -39,6 +49,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,9 +8,11 @@ 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,
UPDATE_VISITORS_IN_QUEUE_COUNT,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
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,34 @@ export function updateVisitorsCount(count: number) {
count
};
}
/**
* Visitors in queue count has been updated.
*
* @param {number} count - The new visitors in queue count.
* @returns {{
* type: UPDATE_VISITORS_IN_QUEUE_COUNT,
* }}
*/
export function updateVisitorsInQueueCount(count: number) {
return {
type: UPDATE_VISITORS_IN_QUEUE_COUNT,
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: true
});
};
}

View File

@@ -5,7 +5,7 @@ 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 { getVisitorsCount, getVisitorsShortText, iAmVisitor } from '../../functions';
const styles = {
raisedHandsCountLabel: {
@@ -25,8 +25,7 @@ const styles = {
const VisitorsCountLabel = () => {
const visitorsMode = useSelector((state: IReduxState) => iAmVisitor(state));
const visitorsCount = useSelector((state: IReduxState) =>
state['features/visitors'].count || 0);
const visitorsCount = useSelector(getVisitorsCount);
return !visitorsMode && visitorsCount > 0 ? (
<Label

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import styles from '../../../lobby/components/native/styles';
/**
* The component that renders visitors queue UI.
*
* @returns {ReactElement}
*/
export default function VisitorsQueue() {
const { t } = useTranslation();
return (
<View style = { styles.lobbyWaitingFragmentContainer }>
<Text style = { styles.lobbyTitle }>
{ t('visitors.waitingMessage') }
</Text>
<LoadingIndicator
color = { BaseTheme.palette.icon01 }
style = { styles.loadingIndicator } />
</View>
);
}

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 { getVisitorsCount, getVisitorsShortText, iAmVisitor } from '../../functions';
const useStyles = makeStyles()(theme => {
return {
@@ -21,8 +21,7 @@ const useStyles = makeStyles()(theme => {
const VisitorsCountLabel = () => {
const { classes: styles, theme } = useStyles();
const visitorsMode = useSelector((state: IReduxState) => iAmVisitor(state));
const visitorsCount = useSelector((state: IReduxState) =>
state['features/visitors'].count || 0);
const visitorsCount = useSelector(getVisitorsCount);
const { t } = useTranslation();
return !visitorsMode && visitorsCount > 0 ? (<Tooltip

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

@@ -45,3 +45,47 @@ export function iAmVisitor(stateful: IStateful) {
export function getVisitorsCount(stateful: IStateful) {
return toState(stateful)['features/visitors'].count ?? 0;
}
/**
* Returns the number of visitors that are waiting in queue.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @returns {number} - The number of visitors in queue.
*/
export function getVisitorsInQueueCount(stateful: IStateful) {
return toState(stateful)['features/visitors'].inQueueCount ?? 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?.visitors?.live;
}
/**
* 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'].inQueue;
}

View File

@@ -2,38 +2,55 @@ 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
} 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 {
NOTIFICATION_ICON,
NOTIFICATION_TIMEOUT_TYPE,
VISITORS_NOT_LIVE_NOTIFICATION_ID,
VISITORS_PROMOTION_NOTIFICATION_ID
} from '../notifications/constants';
import { INotificationProps } from '../notifications/types';
import { open as openParticipantsPane } from '../participants-pane/actions';
import { joinConference } from '../prejoin/actions';
import { UPDATE_VISITORS_IN_QUEUE_COUNT } from './actionTypes';
import {
approveRequest,
clearPromotionRequest,
denyRequest,
goLive,
promotionRequestReceived,
setInVisitorsQueue,
setVisitorDemoteActor,
setVisitorsSupported,
updateVisitorsCount
updateVisitorsCount,
updateVisitorsInQueueCount
} from './actions';
import { getPromotionRequests } from './functions';
import { getPromotionRequests, getVisitorsCount, getVisitorsInQueueCount } from './functions';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.type) {
@@ -43,7 +60,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
conference.on(JitsiConferenceEvents.PROPERTIES_CHANGED, (properties: { 'visitor-count': number; }) => {
const visitorCount = Number(properties?.['visitor-count']);
if (!isNaN(visitorCount) && getState()['features/visitors'].count !== visitorCount) {
if (!isNaN(visitorCount) && getVisitorsCount(getState) !== visitorCount) {
dispatch(updateVisitorsCount(visitorCount));
}
});
@@ -130,17 +147,173 @@ 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_LIVE_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') {
logger.info('The conference is now live!');
WebsocketClient.getInstance().disconnect();
let delay = 0;
// now let's connect to meeting
if ('randomDelayMs' in msg) {
delay = msg.randomDelayMs;
}
setTimeout(() => {
dispatch(joinConference());
dispatch(setInVisitorsQueue(false));
}, delay);
}
},
getState()['features/base/jwt'].jwt,
() => {
dispatch(setInVisitorsQueue(true));
});
break;
}
case PARTICIPANT_UPDATED: {
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (visitorsConfig?.queueService && isLocalParticipantModerator(getState)) {
const { metadata } = getState()['features/base/conference'];
if (metadata?.visitors?.live === false && !WebsocketClient.getInstance().isActive()) {
// 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;
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (!visitorsConfig?.queueService) {
break;
}
if (isLocalParticipantModerator(getState)) {
if (metadata?.visitors?.live === false) {
if (!WebsocketClient.getInstance().isActive()) {
// 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);
}
_showNotLiveNotification(dispatch, getVisitorsInQueueCount(getState));
} else if (metadata?.visitors?.live) {
dispatch(hideNotification(VISITORS_NOT_LIVE_NOTIFICATION_ID));
WebsocketClient.getInstance().disconnect();
}
}
break;
}
case UPDATE_VISITORS_IN_QUEUE_COUNT: {
_showNotLiveNotification(dispatch, action.count);
break;
}
}
return next(action);
});
/**
* Shows a notification that the meeting is not live.
*
* @param {Dispatch} dispatch - The Redux dispatch function.
* @param {number} count - The count of visitors waiting.
* @returns {void}
*/
function _showNotLiveNotification(dispatch: IStore['dispatch'], count: number): void {
// let's show notification
dispatch(showNotification({
titleKey: 'notify.waitingVisitorsTitle',
descriptionKey: 'notify.waitingVisitors',
descriptionArguments: {
waitingVisitors: count
},
disableClosing: true,
uid: VISITORS_NOT_LIVE_NOTIFICATION_ID,
customActionNameKey: [ 'participantsPane.actions.goLive' ],
customActionType: [ BUTTON_TYPES.PRIMARY ],
customActionHandler: [ () => batch(() => {
dispatch(hideNotification(VISITORS_NOT_LIVE_NOTIFICATION_ID));
dispatch(goLive());
}) ],
icon: NOTIFICATION_ICON.PARTICIPANTS
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
/**
* 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(updateVisitorsInQueueCount(msg.visitorsWaiting));
}
},
toState(stateful)['features/base/jwt'].jwt);
}
/**
* Function to handle the promotion notification.
*

View File

@@ -4,16 +4,20 @@ 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,
UPDATE_VISITORS_IN_QUEUE_COUNT,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import { IPromotionRequest } from './types';
const DEFAULT_STATE = {
count: -1,
count: 0,
iAmVisitor: false,
inQueue: false,
inQueueCount: 0,
showNotification: false,
supported: false,
promotionRequests: []
@@ -23,6 +27,8 @@ export interface IVisitorsState {
count?: number;
demoteActorDisplayName?: string;
iAmVisitor: boolean;
inQueue: boolean;
inQueueCount?: number;
promotionRequests: IPromotionRequest[];
supported: boolean;
}
@@ -49,12 +55,28 @@ ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_S
count: action.count
};
}
case UPDATE_VISITORS_IN_QUEUE_COUNT: {
if (state.count === action.count) {
return state;
}
return {
...state,
inQueueCount: action.count
};
}
case I_AM_VISITOR_MODE: {
return {
...state,
iAmVisitor: action.enabled
};
}
case SET_IN_VISITORS_QUEUE: {
return {
...state,
inQueue: action.value
};
}
case SET_VISITOR_DEMOTE_ACTOR: {
return {
...state,

View File

@@ -0,0 +1,138 @@
/* 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,
forceBinaryWSFrames: true,
appendMissingNULLonIncoming: true
});
const errorConnecting = (error: any) => {
if (this.retriesCount > 3) {
this.stompClient?.deactivate();
this.stompClient = undefined;
return;
}
this.retriesCount++;
logger.error(`Error connecting to ${queueServiceURL} ${JSON.stringify(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;
});
}
/**
* Checks whether the instance is created and connected or in connecting state.
*
* @returns {boolean} Whether the connect method was executed.
*/
isActive() {
return 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
@@ -148,6 +149,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
@@ -158,6 +162,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,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
@@ -294,6 +304,33 @@ 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)
};
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)
@@ -451,6 +488,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.visitors and room.jitsiMetadata.visitors.live then
go_live(room);
end
end, -2); -- metadata hook on -1
host_module:hook('jitsi-metadata-updated', function (event)
if event.key == 'visitors' then
local room = event.room;
if room.jitsiMetadata.visitors and room.jitsiMetadata.visitors.live 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');
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;