This commit is contained in:
175
react/features/visitors/VisitorsListWebsocketClient.ts
Normal file
175
react/features/visitors/VisitorsListWebsocketClient.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
81
react/features/visitors/actionTypes.ts
Normal file
81
react/features/visitors/actionTypes.ts
Normal 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';
|
||||
262
react/features/visitors/actions.ts
Normal file
262
react/features/visitors/actions.ts
Normal 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
|
||||
});
|
||||
};
|
||||
}
|
||||
1
react/features/visitors/components/index.native.ts
Normal file
1
react/features/visitors/components/index.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as JoinMeetingDialog } from './native/JoinMeetingDialog';
|
||||
1
react/features/visitors/components/index.web.ts
Normal file
1
react/features/visitors/components/index.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as JoinMeetingDialog } from './web/JoinMeetingDialog';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
43
react/features/visitors/components/native/VisitorsQueue.tsx
Normal file
43
react/features/visitors/components/native/VisitorsQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
react/features/visitors/components/native/styles.ts
Normal file
34
react/features/visitors/components/native/styles.ts
Normal 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'
|
||||
},
|
||||
};
|
||||
72
react/features/visitors/components/web/JoinMeetingDialog.tsx
Normal file
72
react/features/visitors/components/web/JoinMeetingDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
107
react/features/visitors/components/web/VisitorsQueue.tsx
Normal file
107
react/features/visitors/components/web/VisitorsQueue.tsx
Normal 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>);
|
||||
}
|
||||
159
react/features/visitors/functions.ts
Normal file
159
react/features/visitors/functions.ts
Normal 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';
|
||||
}
|
||||
3
react/features/visitors/logger.ts
Normal file
3
react/features/visitors/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/visitors');
|
||||
479
react/features/visitors/middleware.ts
Normal file
479
react/features/visitors/middleware.ts
Normal 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));
|
||||
}
|
||||
132
react/features/visitors/reducer.ts
Normal file
132
react/features/visitors/reducer.ts
Normal 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;
|
||||
});
|
||||
15
react/features/visitors/types.ts
Normal file
15
react/features/visitors/types.ts
Normal 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;
|
||||
}
|
||||
169
react/features/visitors/websocket-client.ts
Normal file
169
react/features/visitors/websocket-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user