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

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

View File

@@ -0,0 +1,175 @@
import { Client, StompSubscription } from '@stomp/stompjs';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
/**
* Websocket client impl, used for visitors list.
* Uses STOMP for authenticating (https://stomp.github.io/).
*/
export class VisitorsListWebsocketClient extends WebsocketClient {
private static client: VisitorsListWebsocketClient;
private _topicSubscription: StompSubscription | undefined;
private _queueSubscription: StompSubscription | undefined;
/**
* Creates a new instance of the VisitorsListWebsocketClient.
*
* @static
* @returns {VisitorsListWebsocketClient}
*/
static override getInstance(): VisitorsListWebsocketClient {
if (!this.client) {
this.client = new VisitorsListWebsocketClient();
}
return this.client;
}
/**
* Connects to the visitors list with initial queue subscription, then switches to topic deltas.
*
* @param {string} queueServiceURL - The service URL to use.
* @param {string} queueEndpoint - The queue endpoint for initial list.
* @param {string} topicEndpoint - The topic endpoint for deltas.
* @param {Function} initialCallback - Callback executed with initial visitors list.
* @param {Function} deltaCallback - Callback executed with delta updates.
* @param {string} token - The token to be used for authorization.
* @param {Function?} connectCallback - Callback executed when connected.
* @returns {void}
*/
connectVisitorsList(queueServiceURL: string,
queueEndpoint: string,
topicEndpoint: string,
initialCallback: (visitors: Array<{ n: string; r: string; }>) => void,
deltaCallback: (updates: Array<{ n: string; r: string; s: string; }>) => void,
token: string | undefined,
connectCallback?: () => 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 => {
logger.error('STOMP error received', frame);
errorConnecting(frame.headers.message);
};
if (token) {
this.stompClient.connectHeaders = {
Authorization: `Bearer ${token}`
};
}
this.stompClient.onConnect = () => {
if (!this.stompClient) {
return;
}
logger.debug('Connected to visitors list websocket');
connectCallback?.();
let initialReceived = false;
const cachedDeltas: Array<{ n: string; r: string; s: string; }> = [];
// Subscribe first for deltas so we don't miss any while waiting for the initial list
this._topicSubscription = this.stompClient.subscribe(topicEndpoint, deltaMessage => {
try {
const updates: Array<{ n: string; r: string; s: string; }> = JSON.parse(deltaMessage.body);
if (!initialReceived) {
cachedDeltas.push(...updates);
} else {
deltaCallback(updates);
}
} catch (e) {
logger.error(`Error parsing visitors delta response: ${deltaMessage}`, e);
}
});
// Subscribe for the initial list after topic subscription is active
this._queueSubscription = this.stompClient.subscribe(queueEndpoint, message => {
try {
const visitors: Array<{ n: string; r: string; }> = JSON.parse(message.body);
logger.debug(`Received initial visitors list with ${visitors.length} visitors`);
initialReceived = true;
initialCallback(visitors);
// Unsubscribe from queue after receiving initial list
if (this._queueSubscription) {
this._queueSubscription.unsubscribe();
this._queueSubscription = undefined;
}
if (cachedDeltas.length) {
deltaCallback(cachedDeltas);
cachedDeltas.length = 0;
}
} catch (e) {
logger.error(`Error parsing initial visitors response: ${message}`, e);
}
});
};
this.stompClient.activate();
}
/**
* Unsubscribes from both topic and queue subscriptions.
*
* @returns {void}
*/
override unsubscribe(): void {
if (this._topicSubscription) {
this._topicSubscription.unsubscribe();
logger.debug('Unsubscribed from visitors list topic');
this._topicSubscription = undefined;
}
if (this._queueSubscription) {
this._queueSubscription.unsubscribe();
logger.debug('Unsubscribed from visitors list queue');
this._queueSubscription = undefined;
}
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from any active subscriptions first.
*
* @returns {Promise}
*/
override disconnect(): Promise<any> {
if (!this.stompClient) {
return Promise.resolve();
}
const url = this.stompClient.brokerURL;
// Unsubscribe first (synchronous), then disconnect
this.unsubscribe();
return this.stompClient.deactivate().then(() => {
logger.debug(`disconnected from: ${url}`);
this.stompClient = undefined;
});
}
}

View File

@@ -0,0 +1,81 @@
/**
* 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.
*
* {
* type: I_AM_VISITOR_MODE,
* enabled: boolean
* }
*/
export const I_AM_VISITOR_MODE = 'I_AM_VISITOR_MODE';
/**
* The type of (redux) action which indicates that a promotion request was received from a visitor.
*
* {
* type: VISITOR_PROMOTION_REQUEST,
* nick: string,
* from: string
* }
*/
export const VISITOR_PROMOTION_REQUEST = 'VISITOR_PROMOTION_REQUEST';
/**
* The type of (redux) action which indicates that a promotion response denied was received.
*
* {
* type: CLEAR_VISITOR_PROMOTION_REQUEST,
* request: IPromotionRequest
* }
*/
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.
*
* {
* type: SET_VISITOR_DEMOTE_ACTOR,
* displayName: string
* }
*/
export const SET_VISITOR_DEMOTE_ACTOR = 'SET_VISITOR_DEMOTE_ACTOR';
/**
* The type of (redux) action which sets visitors support.
*
* {
* type: SET_VISITORS_SUPPORTED,
* value: string
* }
*/
export const SET_VISITORS_SUPPORTED = 'SET_VISITORS_SUPPORTED';
/**
* The type of (redux) action which updates the current visitors list.
*/
export const UPDATE_VISITORS_LIST = 'UPDATE_VISITORS_LIST';
/**
* Action dispatched when the visitors list is expanded for the first time
* and the client should subscribe for updates.
*/
export const SUBSCRIBE_VISITORS_LIST = 'SUBSCRIBE_VISITORS_LIST';

View File

@@ -0,0 +1,262 @@
import { createRemoteVideoMenuButtonEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { connect, disconnect, setPreferVisitor } from '../base/connection/actions';
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,
SUBSCRIBE_VISITORS_LIST,
UPDATE_VISITORS_IN_QUEUE_COUNT,
UPDATE_VISITORS_LIST,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import logger from './logger';
import { IPromotionRequest } from './types';
/**
* Action used to admit multiple participants in the conference.
*
* @param {Array<Object>} requests - A list of visitors requests.
* @returns {Function}
*/
export function admitMultiple(requests: Array<IPromotionRequest>): Function {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.sendMessage({
type: 'visitors',
action: 'promotion-response',
approved: true,
ids: requests.map(r => r.from)
});
};
}
/**
* Approves the request of a visitor to join the main meeting.
*
* @param {IPromotionRequest} request - The request from the visitor.
* @returns {Function}
*/
export function approveRequest(request: IPromotionRequest) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.sendMessage({
type: 'visitors',
action: 'promotion-response',
approved: true,
id: request.from
});
dispatch(clearPromotionRequest(request));
};
}
/**
* Denies the request of a visitor to join the main meeting.
*
* @param {IPromotionRequest} request - The request from the visitor.
* @returns {Function}
*/
export function denyRequest(request: IPromotionRequest) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.sendMessage({
type: 'visitors',
action: 'promotion-response',
approved: false,
id: request.from
});
dispatch(clearPromotionRequest(request));
};
}
/**
* Sends a demote request to a main participant to join the meeting as a visitor.
*
* @param {string} id - The ID for the participant.
* @returns {Function}
*/
export function demoteRequest(id: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
const localParticipant = getLocalParticipant(getState());
sendAnalytics(createRemoteVideoMenuButtonEvent('demote.button', { 'participant_id': id }));
if (id === localParticipant?.id) {
dispatch(disconnect(true))
.then(() => {
dispatch(setPreferVisitor(true));
logger.info('Dispatching connect to demote the local participant.');
return dispatch(connect());
});
} else {
conference?.sendMessage({
type: 'visitors',
action: 'demote-request',
id,
actor: localParticipant?.id
});
}
};
}
/**
* Removes a promotion request from the state.
*
* @param {IPromotionRequest} request - The request.
* @returns {{
* type: CLEAR_VISITOR_PROMOTION_REQUEST,
* request: IPromotionRequest
* }}
*/
export function clearPromotionRequest(request: IPromotionRequest) {
return {
type: CLEAR_VISITOR_PROMOTION_REQUEST,
request
};
}
/**
* Visitor has sent us a promotion request.
*
* @param {IPromotionRequest} request - The request.
* @returns {{
* type: VISITOR_PROMOTION_REQUEST,
* }}
*/
export function promotionRequestReceived(request: IPromotionRequest) {
return {
type: VISITOR_PROMOTION_REQUEST,
request
};
}
/**
* Sets Visitors mode on or off.
*
* @param {boolean} enabled - The new visitors mode state.
* @returns {{
* type: I_AM_VISITOR_MODE,
* }}
*/
export function setIAmVisitor(enabled: boolean) {
return {
type: I_AM_VISITOR_MODE,
enabled
};
}
/**
* 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.
*
* @param {string|undefined} displayName - The display name of the participant.
* @returns {{
* type: SET_VISITOR_DEMOTE_ACTOR,
* }}
*/
export function setVisitorDemoteActor(displayName: string | undefined) {
return {
type: SET_VISITOR_DEMOTE_ACTOR,
displayName
};
}
/**
* Visitors count has been updated.
*
* @param {boolean} value - The new value whether visitors are supported.
* @returns {{
* type: SET_VISITORS_SUPPORTED,
* }}
*/
export function setVisitorsSupported(value: boolean) {
return {
type: SET_VISITORS_SUPPORTED,
value
};
}
/**
* 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
};
}
/**
* Updates the current list of visitors.
*
* @param {Array<Object>} visitors - The visitors list.
* @returns {{
* type: UPDATE_VISITORS_LIST,
* }}
*/
export function updateVisitorsList(visitors: Array<{ id: string; name: string; }>) {
return {
type: UPDATE_VISITORS_LIST,
visitors
};
}
/**
* Signals the start of the visitors list websocket subscription.
*
* @returns {{ type: SUBSCRIBE_VISITORS_LIST }}
*/
export function subscribeVisitorsList() {
return {
type: SUBSCRIBE_VISITORS_LIST
};
}
/**
* 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', {
...(conference?.getMetadataHandler().getMetadata()?.visitors || {}),
live: true
});
};
}

View File

@@ -0,0 +1 @@
export { default as JoinMeetingDialog } from './native/JoinMeetingDialog';

View File

@@ -0,0 +1 @@
export { default as JoinMeetingDialog } from './web/JoinMeetingDialog';

View File

@@ -0,0 +1,40 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { View, ViewStyle } from 'react-native';
import Dialog from 'react-native-dialog';
import { StandaloneRaiseHandButton as RaiseHandButton } from '../../../reactions/components/native/RaiseHandButton';
import styles from '../../components/native/styles';
/**
* Component that renders the join meeting dialog for visitors.
*
* @returns {JSX.Element}
*/
export default function JoinMeetingDialog() {
const { t } = useTranslation();
const [ visible, setVisible ] = useState(true);
const closeDialog = useCallback(() => {
setVisible(false);
}, []);
return (
<Dialog.Container
coverScreen = { false }
visible = { visible }>
<Dialog.Title>{ t('visitors.joinMeeting.title') }</Dialog.Title>
<Dialog.Description>
{ t('visitors.joinMeeting.description') }
<View style = { styles.raiseHandButton as ViewStyle }>
{/* @ts-ignore */}
<RaiseHandButton disableClick = { true } />
</View>
</Dialog.Description>
<Dialog.Description>{t('visitors.joinMeeting.wishToSpeak')}</Dialog.Description>
<Dialog.Button
label = { t('dialog.Ok') }
onPress = { closeDialog } />
</Dialog.Container>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IconUsers } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import { getVisitorsCount, getVisitorsShortText } from '../../functions';
const styles = {
raisedHandsCountLabel: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.warning02,
borderRadius: BaseTheme.shape.borderRadius,
flexDirection: 'row',
marginLeft: BaseTheme.spacing[0],
marginBottom: BaseTheme.spacing[0]
},
raisedHandsCountLabelText: {
color: BaseTheme.palette.uiBackground,
paddingLeft: BaseTheme.spacing[2]
}
};
const VisitorsCountLabel = () => {
const visitorsCount = useSelector(getVisitorsCount);
return visitorsCount > 0 ? (
<Label
icon = { IconUsers }
iconColor = { BaseTheme.palette.uiBackground }
style = { styles.raisedHandsCountLabel }
text = { `${getVisitorsShortText(visitorsCount)}` }
textStyle = { styles.raisedHandsCountLabelText } />
) : null;
};
export default VisitorsCountLabel;

View File

@@ -0,0 +1,43 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { useDispatch } from 'react-redux';
import { hangup } from '../../../base/connection/actions.native';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import lobbyStyles from '../../../lobby/components/native/styles';
import styles from './styles';
/**
* The component that renders visitors queue UI.
*
* @returns {ReactElement}
*/
export default function VisitorsQueue() {
const { t } = useTranslation();
const dispatch = useDispatch();
const onHangupClick = useCallback(() => {
dispatch(hangup());
}, []);
return (
<View style = { styles.visitorsQueue as ViewStyle }>
<Text style = { styles.visitorsQueueTitle }>
{ t('visitors.waitingMessage') }
</Text>
<LoadingIndicator
color = { BaseTheme.palette.icon01 }
style = { lobbyStyles.loadingIndicator } />
<Button
accessibilityLabel = 'toolbar.accessibilityLabel.leaveConference'
labelKey = 'toolbar.accessibilityLabel.leaveConference'
onClick = { onHangupClick }
style = { styles.hangupButton }
type = { BUTTON_TYPES.DESTRUCTIVE } />
</View>
);
}

View File

@@ -0,0 +1,34 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
/**
* The styles of the feature visitors.
*/
export default {
hangupButton: {
marginTop: BaseTheme.spacing[3],
width: 240
},
raiseHandButton: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%'
},
visitorsQueue: {
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
height: '100%',
width: '100%',
},
visitorsQueueTitle: {
...BaseTheme.typography.heading5,
color: BaseTheme.palette.text01,
marginBottom: BaseTheme.spacing[3],
textAlign: 'center'
},
};

View File

@@ -0,0 +1,72 @@
import { noop } from 'lodash-es';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import ToolboxButtonWithPopup from '../../../base/toolbox/components/web/ToolboxButtonWithPopup';
import Dialog from '../../../base/ui/components/web/Dialog';
import { RaiseHandButton } from '../../../reactions/components/web/RaiseHandButton';
const useStyles = makeStyles()(theme => {
return {
raiseHand: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
pointerEvents: 'none'
},
raiseHandTooltip: {
border: '1px solid #444',
borderRadius: theme.shape.borderRadius,
paddingBottom: theme.spacing(1),
paddingTop: theme.spacing(1),
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2)
},
raiseHandButton: {
display: 'inline-block',
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
position: 'relative'
}
};
});
/**
* Component that renders the join meeting dialog for visitors.
*
* @returns {JSX.Element}
*/
export default function JoinMeetingDialog() {
const { t } = useTranslation();
const { classes } = useStyles();
return (
<Dialog
cancel = {{ hidden: true }}
ok = {{ translationKey: 'dialog.Ok' }}
titleKey = 'visitors.joinMeeting.title'>
<div className = 'join-meeting-dialog'>
<p>{t('visitors.joinMeeting.description')}</p>
<div className = { classes.raiseHand }>
<p className = { classes.raiseHandTooltip }>{t('visitors.joinMeeting.raiseHand')}</p>
<div className = { classes.raiseHandButton }>
<ToolboxButtonWithPopup
onPopoverClose = { noop }
onPopoverOpen = { noop }
popoverContent = { null }
visible = { false }>
{/* @ts-ignore */}
<RaiseHandButton
disableClick = { true }
raisedHand = { true } />
</ToolboxButtonWithPopup>
</div>
</div>
<p>{t('visitors.joinMeeting.wishToSpeak')}</p>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IconUsers } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { getVisitorsCount, getVisitorsShortText } from '../../functions';
const useStyles = makeStyles()(theme => {
return {
label: {
backgroundColor: theme.palette.warning02,
color: theme.palette.uiBackground
}
};
});
const VisitorsCountLabel = () => {
const { classes: styles, theme } = useStyles();
const visitorsCount = useSelector(getVisitorsCount);
const { t } = useTranslation();
return visitorsCount > 0 ? (<Tooltip
content = { t('visitors.labelTooltip', { count: visitorsCount }) }
position = { 'bottom' }>
<Label
className = { styles.label }
icon = { IconUsers }
iconColor = { theme.palette.icon04 }
id = 'visitorsCountLabel'
text = { `${getVisitorsShortText(visitorsCount)}` } />
</Tooltip>) : null;
};
export default VisitorsCountLabel;

View File

@@ -0,0 +1,107 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { hangup } from '../../../base/connection/actions.web';
import Button from '../../../base/ui/components/web/Button';
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: {
...theme.typography.heading5,
color: theme.palette.text01,
marginBottom: theme.spacing(4),
overflow: 'hidden',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%'
},
spinner: {
margin: theme.spacing(4),
}
};
});
/**
* The component that renders visitors queue UI.
*
* @returns {ReactElement}
*/
export default function VisitorsQueue() {
const { classes } = useStyles();
const { t } = useTranslation();
const dispatch = useDispatch();
const onHangupClick = useCallback(() => {
dispatch(hangup());
}, []);
return (<div
className = { classes.container }
id = 'visitors-waiting-queue'>
<div className = { classes.content }>
<div className = { classes.contentControls }>
<span className = { classes.roomName }>
{ t('visitors.waitingMessage') }
</span>
<div className = { classes.spinner }>
<LoadingIndicator size = 'large' />
</div>
<Button
labelKey = 'toolbar.accessibilityLabel.leaveConference'
onClick = { onHangupClick }
testId = 'toolbar.accessibilityLabel.leaveConference'
type = 'destructive' />
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,159 @@
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { toState } from '../base/redux/functions';
/**
* A short string to represent the number of visitors.
* Over 1000 we show numbers like 1.0 K or 9.5 K.
*
* @param {number} visitorsCount - The number of visitors to shorten.
*
* @returns {string} Short string representing the number of visitors.
*/
export function getVisitorsShortText(visitorsCount: number) {
return visitorsCount >= 1000 ? `${Math.round(visitorsCount / 100) / 10} K` : String(visitorsCount);
}
/**
* Selector to return a list of promotion requests from visitors.
*
* @param {IReduxState} state - State object.
* @returns {Array<Object>}
*/
export function getPromotionRequests(state: IReduxState) {
return state['features/visitors'].promotionRequests;
}
/**
* Whether current UI is in visitor mode.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @returns {boolean} Whether iAmVisitor is set.
*/
export function iAmVisitor(stateful: IStateful) {
return toState(stateful)['features/visitors'].iAmVisitor;
}
/**
* Returns the number of visitors.
*
* @param {Function|Object} stateful - The redux store or {@code getState}
* function.
* @returns {number} - The number of visitors.
*/
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;
}
/**
* Returns the current visitor list.
*
* @param {IStateful} stateful - The redux store or {@code getState} function.
* @returns {Array<Object>}
*/
export function getVisitorsList(stateful: IStateful) {
return toState(stateful)['features/visitors'].visitors ?? [];
}
/**
* Whether the visitors list websocket subscription has been requested.
*
* @param {IStateful} stateful - The redux store or {@code getState} function.
* @returns {boolean}
*/
export function isVisitorsListSubscribed(stateful: IStateful) {
return toState(stateful)['features/visitors'].visitorsListSubscribed;
}
/**
* 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;
}
/**
* Checks if the visitors list feature is enabled based on JWT and config.js.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} Whether the feature is allowed.
*/
export function isVisitorsListEnabled(state: IReduxState): boolean {
const { visitors: visitorsConfig } = state['features/base/config'];
if (!visitorsConfig?.queueService) { // if the queue service is not configured, we can't retrieve the visitors list
return false;
}
return isJwtFeatureEnabled(state, MEET_FEATURES.LIST_VISITORS, false);
}
/**
* Determines whether the current visitors list should be displayed.
*
* @param {IStateful} stateful - The redux store or {@code getState} function.
* @returns {boolean} Whether the visitors list should be shown.
*/
export function shouldDisplayCurrentVisitorsList(stateful: IStateful): boolean {
const state = toState(stateful);
return isVisitorsListEnabled(state) && getVisitorsCount(state) > 0;
}
/**
*
* @param state
* @param displayName
* @returns
*/
/**
* Returns visitor's display name, falling back to the default remote display name
* from config, or 'Fellow Jitster' if neither is available.
*
* @param {IReduxState} state - The Redux state.
* @param {string} [displayName] - Optional display name to use if available.
* @returns {string} - The display name for a visitor.
*/
export function getVisitorDisplayName(state: IReduxState, displayName?: string): string {
return displayName || state['features/base/config'].defaultRemoteDisplayName || 'Fellow Jitster';
}

View File

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

View File

@@ -0,0 +1,479 @@
import i18n from 'i18next';
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { IStateful } from '../base/app/types';
import {
CONFERENCE_JOINED,
CONFERENCE_WILL_LEAVE,
ENDPOINT_MESSAGE_RECEIVED,
UPDATE_CONFERENCE_METADATA
} from '../base/conference/actionTypes';
import { IConferenceMetadata } from '../base/conference/reducer';
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 { openDialog } from '../base/dialog/actions';
import { JitsiConferenceEvents, JitsiConnectionErrors } from '../base/lib-jitsi-meet';
import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes';
import { raiseHand } from '../base/participants/actions';
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 { VisitorsListWebsocketClient } from './VisitorsListWebsocketClient';
import { SUBSCRIBE_VISITORS_LIST, UPDATE_VISITORS_IN_QUEUE_COUNT } from './actionTypes';
import {
approveRequest,
clearPromotionRequest,
denyRequest,
goLive,
promotionRequestReceived,
setInVisitorsQueue,
setVisitorDemoteActor,
setVisitorsSupported,
updateVisitorsInQueueCount,
updateVisitorsList
} from './actions';
import { JoinMeetingDialog } from './components';
import { getPromotionRequests, getVisitorsInQueueCount, isVisitorsListEnabled } from './functions';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.type) {
case CONFERENCE_JOINED: {
const { conference } = action;
if (getState()['features/visitors'].iAmVisitor) {
const { demoteActorDisplayName } = getState()['features/visitors'];
if (demoteActorDisplayName) {
const notificationParams: INotificationProps = {
titleKey: 'visitors.notification.title',
descriptionKey: 'visitors.notification.demoteDescription',
descriptionArguments: {
actor: demoteActorDisplayName
}
};
batch(() => {
dispatch(showNotification(notificationParams, NOTIFICATION_TIMEOUT_TYPE.STICKY));
dispatch(setVisitorDemoteActor(undefined));
});
} else {
dispatch(openDialog(JoinMeetingDialog));
}
} else {
dispatch(setVisitorsSupported(conference.isVisitorsSupported()));
conference.on(JitsiConferenceEvents.VISITORS_SUPPORTED_CHANGED, (value: boolean) => {
dispatch(setVisitorsSupported(value));
});
}
conference.on(JitsiConferenceEvents.VISITORS_MESSAGE, (
msg: { action: string; actor: string; from: string; id: string; nick: string; on: boolean; }) => {
if (msg.action === 'demote-request') {
// we need it before the disconnect
const participantById = getParticipantById(getState, msg.actor);
const localParticipant = getLocalParticipant(getState);
if (localParticipant && localParticipant.id === msg.id) {
// handle demote
dispatch(disconnect(true))
.then(() => {
dispatch(setPreferVisitor(true));
// we need to set the name, so we can use it later in the notification
if (participantById) {
dispatch(setVisitorDemoteActor(participantById.name));
}
logger.info('Dispatching connect on demote request visitor message for local participant.');
return dispatch(connect());
});
}
} else if (msg.action === 'promotion-request') {
const request = {
from: msg.from,
nick: msg.nick
};
if (msg.on) {
dispatch(promotionRequestReceived(request));
} else {
dispatch(clearPromotionRequest(request));
}
_handlePromotionNotification({
dispatch,
getState
});
} else {
logger.error('Unknown action:', msg.action);
}
});
conference.on(JitsiConferenceEvents.VISITORS_REJECTION, () => {
dispatch(raiseHand(false));
});
break;
}
case CONFERENCE_WILL_LEAVE: {
WebsocketClient.getInstance().disconnect();
VisitorsListWebsocketClient.getInstance().disconnect();
break;
}
case SUBSCRIBE_VISITORS_LIST: {
if (isVisitorsListEnabled(getState()) && !VisitorsListWebsocketClient.getInstance().isActive()) {
_subscribeVisitorsList(getState, dispatch);
}
break;
}
case ENDPOINT_MESSAGE_RECEIVED: {
const { data } = action;
if (data?.action === 'promotion-response' && data.approved) {
const request = getPromotionRequests(getState())
.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, visitors: visitorsConfig } = getState()['features/base/config'];
const { locationURL } = getState()['features/base/connection'];
if (!visitorsConfig?.queueService || !locationURL) {
break;
}
// let's subscribe for visitor waiting queue
const { room } = getState()['features/base/conference'];
const { disableBeforeUnloadHandlers = false } = getState()['features/base/config'];
const conferenceJid = `${room}@${hosts?.muc}`;
const beforeUnloadHandler = () => {
WebsocketClient.getInstance().disconnect();
};
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()
.then(() => {
window.removeEventListener(
disableBeforeUnloadHandlers ? 'unload' : 'beforeunload',
beforeUnloadHandler);
let delay = 0;
// now let's connect to meeting
if ('randomDelayMs' in msg) {
delay = msg.randomDelayMs;
}
if (WebsocketClient.getInstance().connectCount > 3) {
// if we keep connecting/disconnecting, let's slow it down
delay = 30 * 1000;
}
setTimeout(() => {
logger.info('Dispatching joinConference on conference live event.');
dispatch(joinConference());
dispatch(setInVisitorsQueue(false));
}, Math.random() * delay);
});
}
},
getState()['features/base/jwt'].jwt,
() => {
dispatch(setInVisitorsQueue(true));
});
/**
* Disconnecting the WebSocket client when the user closes the page.
*/
window.addEventListener(disableBeforeUnloadHandlers ? 'unload' : 'beforeunload', beforeUnloadHandler);
break;
}
case PARTICIPANT_UPDATED: {
const { metadata } = getState()['features/base/conference'];
_handleQueueAndNotification(dispatch, getState, metadata);
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;
_handleQueueAndNotification(dispatch, getState, metadata);
break;
}
case UPDATE_VISITORS_IN_QUEUE_COUNT: {
_showNotLiveNotification(dispatch, action.count);
break;
}
}
return next(action);
});
/**
* Handles the queue connection and notification for visitors if needed.
*
* @param {IStore.dispatch} dispatch - The Redux dispatch function.
* @param {IStore.getState} getState - The Redux getState function.
* @param {IConferenceMetadata} metadata - The conference metadata.
* @returns {void}
*/
function _handleQueueAndNotification(
dispatch: IStore['dispatch'],
getState: IStore['getState'],
metadata: IConferenceMetadata | undefined): void {
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (!(visitorsConfig?.queueService && isLocalParticipantModerator(getState))) {
return;
}
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();
}
}
/**
* 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);
}
/**
* Subscribes to the visitors list via WebSocket for real-time updates. This function establishes a WebSocket
* connection to track visitors in a conference.
*
* @param {IStore.getState} getState - Function to retrieve the current Redux state.
* @param {IStore.dispatch} dispatch - Function to dispatch Redux actions.
* @returns {void}
*/
function _subscribeVisitorsList(getState: IStore['getState'], dispatch: IStore['dispatch']) {
const state = getState();
const { visitors: visitorsConfig } = state['features/base/config'];
const conference = state['features/base/conference'].conference;
const meetingId = conference?.getMeetingUniqueId();
const localParticipant = getLocalParticipant(state);
const participantId = localParticipant?.id;
if (!visitorsConfig?.queueService || !meetingId || !participantId) {
logger.warn(`Missing required data for visitors list subscription', ${JSON.stringify({
queueService: visitorsConfig?.queueService,
meetingId,
participantId: participantId ? 'participantId present' : 'participantId missing'
})}`);
return;
}
const queueEndpoint = `/secured/conference/visitors-list/queue/${meetingId}/${participantId}`;
const topicEndpoint = `/secured/conference/visitors-list/topic/${meetingId}`;
logger.debug('Starting visitors list subscription');
VisitorsListWebsocketClient.getInstance()
.connectVisitorsList(
`wss://${visitorsConfig.queueService}/visitors-list/websocket`,
queueEndpoint,
topicEndpoint,
// Initial list callback - replace entire list
initialVisitors => {
const visitors = initialVisitors.map(v => ({ id: v.r, name: v.n }));
dispatch(updateVisitorsList(visitors));
},
// Delta updates callback - apply incremental changes
updates => {
let visitors = [ ...(getState()['features/visitors'].visitors ?? []) ];
updates.forEach(u => {
if (u.s === 'j') {
const index = visitors.findIndex(v => v.id === u.r);
if (index === -1) {
visitors.push({ id: u.r, name: u.n });
} else {
visitors[index] = { id: u.r, name: u.n };
}
} else if (u.s === 'l') {
visitors = visitors.filter(v => v.id !== u.r);
}
});
dispatch(updateVisitorsList(visitors));
},
getState()['features/base/jwt'].jwt);
}
/**
* Function to handle the promotion notification.
*
* @param {Object} store - The Redux store.
* @returns {void}
*/
function _handlePromotionNotification(
{ dispatch, getState }: { dispatch: IStore['dispatch']; getState: IStore['getState']; }) {
const requests = getPromotionRequests(getState());
if (requests.length === 0) {
dispatch(hideNotification(VISITORS_PROMOTION_NOTIFICATION_ID));
return;
}
let notificationTitle;
let customActionNameKey;
let customActionHandler;
let customActionType;
let descriptionKey;
let icon;
if (requests.length === 1) {
const firstRequest = requests[0];
descriptionKey = 'notify.participantWantsToJoin';
notificationTitle = firstRequest.nick;
icon = NOTIFICATION_ICON.PARTICIPANT;
customActionNameKey = [ 'participantsPane.actions.admit', 'participantsPane.actions.reject' ];
customActionType = [ BUTTON_TYPES.PRIMARY, BUTTON_TYPES.DESTRUCTIVE ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(VISITORS_PROMOTION_NOTIFICATION_ID));
dispatch(approveRequest(firstRequest));
}),
() => batch(() => {
dispatch(hideNotification(VISITORS_PROMOTION_NOTIFICATION_ID));
dispatch(denyRequest(firstRequest));
}) ];
} else {
descriptionKey = 'notify.participantsWantToJoin';
notificationTitle = i18n.t('notify.waitingParticipants', {
waitingParticipants: requests.length
});
icon = NOTIFICATION_ICON.PARTICIPANTS;
customActionNameKey = [ 'notify.viewVisitors' ];
customActionType = [ BUTTON_TYPES.PRIMARY ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(VISITORS_PROMOTION_NOTIFICATION_ID));
dispatch(openParticipantsPane());
}) ];
}
dispatch(showNotification({
title: notificationTitle,
descriptionKey,
uid: VISITORS_PROMOTION_NOTIFICATION_ID,
customActionNameKey,
customActionType,
customActionHandler,
icon
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}

View File

@@ -0,0 +1,132 @@
import { CONFERENCE_PROPERTIES_CHANGED, CONFERENCE_WILL_LEAVE } from '../base/conference/actionTypes';
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,
SUBSCRIBE_VISITORS_LIST,
UPDATE_VISITORS_IN_QUEUE_COUNT,
UPDATE_VISITORS_LIST,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import { IPromotionRequest, IVisitorListParticipant } from './types';
const DEFAULT_STATE = {
count: 0,
iAmVisitor: false,
inQueue: false,
inQueueCount: 0,
showNotification: false,
supported: false,
promotionRequests: [],
visitors: [] as IVisitorListParticipant[],
visitorsListSubscribed: false
};
export interface IVisitorsState {
count?: number;
demoteActorDisplayName?: string;
iAmVisitor: boolean;
inQueue: boolean;
inQueueCount?: number;
promotionRequests: IPromotionRequest[];
supported: boolean;
visitors: IVisitorListParticipant[];
visitorsListSubscribed: boolean;
}
ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_STATE, action): IVisitorsState => {
switch (action.type) {
case CONFERENCE_PROPERTIES_CHANGED: {
const visitorCount = Number(action.properties?.['visitor-count']);
if (!isNaN(visitorCount) && state.count !== visitorCount) {
return {
...state,
count: visitorCount
};
}
break;
}
case CONFERENCE_WILL_LEAVE: {
return {
...state,
...DEFAULT_STATE,
// If the action was called because a visitor was promoted don't clear the iAmVisitor field. It will be set
// to false with the I_AM_VISITOR_MODE action and we will be able to distinguish leaving the conference use
// case and promoting a visitor use case.
iAmVisitor: action.isRedirect ? state.iAmVisitor : DEFAULT_STATE.iAmVisitor
};
}
case UPDATE_VISITORS_IN_QUEUE_COUNT: {
if (state.inQueueCount === 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,
demoteActorDisplayName: action.displayName
};
}
case SET_VISITORS_SUPPORTED: {
return {
...state,
supported: action.value
};
}
case SUBSCRIBE_VISITORS_LIST: {
return {
...state,
visitorsListSubscribed: true
};
}
case UPDATE_VISITORS_LIST: {
return {
...state,
visitors: action.visitors
};
}
case VISITOR_PROMOTION_REQUEST: {
const currentRequests = state.promotionRequests || [];
return {
...state,
promotionRequests: [ ...currentRequests, action.request ]
};
}
case CLEAR_VISITOR_PROMOTION_REQUEST: {
let currentRequests = state.promotionRequests || [];
currentRequests = currentRequests.filter(r => r.from !== action.request.from);
return {
...state,
promotionRequests: currentRequests
};
}
}
return state;
});

View File

@@ -0,0 +1,15 @@
export interface IPromotionRequest {
from: string;
nick: string;
}
export interface IVisitorListParticipant {
id: string;
name: string;
}
export interface IVisitorChatParticipant {
id: string;
isVisitor: true;
name: string;
}

View File

@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Client, StompSubscription } 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 {
protected stompClient: Client | undefined;
private static instance: WebsocketClient;
protected retriesCount = 0;
private _connectCount = 0;
private _subscription: StompSubscription | undefined;
/**
* 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}`);
this._connectCount++;
connectCallback?.();
this._subscription = this.stompClient.subscribe(endpoint, message => {
try {
callback(JSON.parse(message.body));
} catch (e) {
logger.error(`Error parsing response: ${message}`, e);
}
});
};
this.stompClient.activate();
}
/**
* Unsubscribes from the current subscription.
*
* @returns {void}
*/
unsubscribe(): void {
if (this._subscription) {
this._subscription.unsubscribe();
logger.debug('Unsubscribed from WebSocket topic');
this._subscription = undefined;
}
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from any active subscriptions first if available.
*
* @returns {Promise}
*/
disconnect(): Promise<any> {
if (!this.stompClient) {
return Promise.resolve();
}
const url = this.stompClient.brokerURL;
// Unsubscribe first (synchronous), then disconnect
this.unsubscribe();
return this.stompClient.deactivate().then(() => {
logger.debug(`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;
}
/**
* Returns the number of connections.
*
* @returns {number} The number of connections for the life of the app.
*/
get connectCount(): number {
return this._connectCount;
}
}