feat(visitors-list): Add to participant pane.

This commit is contained in:
Hristo Terezov
2025-06-27 19:17:32 -05:00
parent 7bed0b36bd
commit 877fc98eef
14 changed files with 461 additions and 5 deletions

View File

@@ -68,3 +68,14 @@ export const SET_VISITOR_DEMOTE_ACTOR = 'SET_VISITOR_DEMOTE_ACTOR';
* }
*/
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

@@ -11,7 +11,9 @@ import {
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';
@@ -216,6 +218,32 @@ export function updateVisitorsInQueueCount(count: number) {
};
}
/**
* 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.
*

View File

@@ -1,5 +1,7 @@
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';
/**
@@ -68,6 +70,26 @@ 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.
*
@@ -89,3 +111,31 @@ export function isVisitorsLive(stateful: IStateful) {
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;
}

View File

@@ -5,6 +5,7 @@ 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';
@@ -35,7 +36,7 @@ import { INotificationProps } from '../notifications/types';
import { open as openParticipantsPane } from '../participants-pane/actions';
import { joinConference } from '../prejoin/actions';
import { UPDATE_VISITORS_IN_QUEUE_COUNT } from './actionTypes';
import { SUBSCRIBE_VISITORS_LIST, UPDATE_VISITORS_IN_QUEUE_COUNT } from './actionTypes';
import {
approveRequest,
clearPromotionRequest,
@@ -45,10 +46,11 @@ import {
setInVisitorsQueue,
setVisitorDemoteActor,
setVisitorsSupported,
updateVisitorsInQueueCount
updateVisitorsInQueueCount,
updateVisitorsList
} from './actions';
import { JoinMeetingDialog } from './components';
import { getPromotionRequests, getVisitorsInQueueCount } from './functions';
import { getPromotionRequests, getVisitorsInQueueCount, isVisitorsListEnabled } from './functions';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
@@ -135,6 +137,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break;
}
case CONFERENCE_WILL_LEAVE: {
WebsocketClient.getInstance().disconnect();
break;
}
case SUBSCRIBE_VISITORS_LIST: {
if (isVisitorsListEnabled(getState()) && !WebsocketClient.getInstance().isActive()) {
_subscribeVisitorsList(getState, dispatch);
}
break;
}
case ENDPOINT_MESSAGE_RECEIVED: {
const { data } = action;
@@ -314,6 +326,63 @@ function _subscribeQueueStats(stateful: IStateful, dispatch: IStore['dispatch'])
toState(stateful)['features/base/jwt'].jwt);
}
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');
WebsocketClient.getInstance()
.connectVisitorsListWithInitial(
`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.
*

View File

@@ -7,10 +7,12 @@ import {
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 } from './types';
import { IPromotionRequest, IVisitor } from './types';
const DEFAULT_STATE = {
count: 0,
@@ -19,7 +21,9 @@ const DEFAULT_STATE = {
inQueueCount: 0,
showNotification: false,
supported: false,
promotionRequests: []
promotionRequests: [],
visitors: [] as IVisitor[],
visitorsListSubscribed: false
};
export interface IVisitorsState {
@@ -30,6 +34,8 @@ export interface IVisitorsState {
inQueueCount?: number;
promotionRequests: IPromotionRequest[];
supported: boolean;
visitors: IVisitor[];
visitorsListSubscribed: boolean;
}
ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_STATE, action): IVisitorsState => {
switch (action.type) {
@@ -90,6 +96,18 @@ ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_S
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 || [];

View File

@@ -2,3 +2,8 @@ export interface IPromotionRequest {
from: string;
nick: string;
}
export interface IVisitor {
id: string;
name: string;
}

View File

@@ -147,4 +147,84 @@ export class WebsocketClient {
get connectCount(): number {
return this._connectCount;
}
/**
* 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}
*/
connectVisitorsListWithInitial(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) => {
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?.();
// First subscribe to queue for initial list
const 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`);
initialCallback(visitors);
// Unsubscribe from queue and subscribe to topic for deltas
queueSubscription.unsubscribe();
this.stompClient?.subscribe(topicEndpoint, deltaMessage => {
try {
const updates: Array<{ n: string; r: string; s: string; }> = JSON.parse(deltaMessage.body);
deltaCallback(updates);
} catch (e) {
logger.error(`Error parsing visitors delta response: ${deltaMessage}`, e);
}
});
} catch (e) {
logger.error(`Error parsing initial visitors response: ${message}`, e);
}
});
};
this.stompClient.activate();
}
}