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

@@ -909,6 +909,7 @@
"visitorInQueue": " (waiting {{count}})",
"visitorRequests": " (requests {{count}})",
"visitors": "Viewers {{count}}",
"visitorsList": "Viewers ({{count}})",
"waitingLobby": "Waiting in lobby ({{count}})"
},
"search": "Search participants",

16
package-lock.json generated
View File

@@ -105,6 +105,7 @@
"react-native-youtube-iframe": "2.3.0",
"react-redux": "7.2.9",
"react-textarea-autosize": "8.3.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.6",
"react-youtube": "10.1.0",
"redux": "4.0.4",
@@ -22298,6 +22299,16 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-virtualized-auto-sizer": {
"version": "1.0.26",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz",
"integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==",
"license": "MIT",
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window": {
"version": "1.8.6",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz",
@@ -41943,6 +41954,11 @@
"tslib": "^2.1.0"
}
},
"react-virtualized-auto-sizer": {
"version": "1.0.26",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz",
"integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="
},
"react-window": {
"version": "1.8.6",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz",

View File

@@ -111,6 +111,7 @@
"react-native-youtube-iframe": "2.3.0",
"react-redux": "7.2.9",
"react-textarea-autosize": "8.3.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "1.8.6",
"react-youtube": "10.1.0",
"redux": "4.0.4",

View File

@@ -18,6 +18,7 @@ export const MEET_FEATURES: Record<string, ParticipantFeaturesKey> = {
ROOM: 'room',
SCREEN_SHARING: 'screen-sharing',
SEND_GROUPCHAT: 'send-groupchat',
LIST_VISITORS: 'list-visitors',
SIP_INBOUND_CALL: 'sip-inbound-call',
SIP_OUTBOUND_CALL: 'sip-outbound-call',
TRANSCRIPTION: 'transcription'

View File

@@ -61,6 +61,7 @@ export interface IParticipantFeatures {
'file-upload'?: boolean | string;
'flip'?: boolean | string;
'inbound-call'?: boolean | string;
'list-visitors'?: boolean | string;
'livestreaming'?: boolean | string;
'lobby'?: boolean | string;
'moderation'?: boolean | string;

View File

@@ -0,0 +1,169 @@
/* eslint-disable react/no-multi-comp */
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { normalizeAccents } from '../../../base/util/strings.web';
import { subscribeVisitorsList } from '../../../visitors/actions';
import {
getVisitorsCount,
getVisitorsList,
isVisitorsListEnabled,
isVisitorsListSubscribed,
shouldDisplayCurrentVisitorsList
} from '../../../visitors/functions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import ParticipantItem from './ParticipantItem';
/**
* Props for the {@code CurrentVisitorsList} component.
*/
interface IProps {
searchString: string;
}
const useStyles = makeStyles()(theme => {
return {
container: {
marginTop: theme.spacing(3),
display: 'flex',
flexDirection: 'column',
minHeight: 0,
flexGrow: 1
},
heading: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
padding: `${theme.spacing(1)} 0`,
...withPixelLineHeight(theme.typography.bodyShortBold),
color: theme.palette.text02,
flexShrink: 0
},
arrowContainer: {
backgroundColor: theme.palette.ui03,
width: '24px',
height: '24px',
borderRadius: '6px',
marginLeft: theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none'
},
listContainer: {
flex: 1,
minHeight: '200px',
maxHeight: '100%'
}
};
});
/**
* Renders the visitors list inside the participants pane.
*
* @param {IProps} props - Component props.
* @returns {React$Element<any>} The component.
*/
export default function CurrentVisitorsList({ searchString }: IProps) {
const visitorsCount = useSelector(getVisitorsCount);
const visitors = useSelector(getVisitorsList);
const featureEnabled = useSelector(isVisitorsListEnabled);
const shouldDisplayList = useSelector(shouldDisplayCurrentVisitorsList);
const { t } = useTranslation();
const { classes } = useStyles();
const dispatch = useDispatch();
const [ collapsed, setCollapsed ] = useState(true);
const isSubscribed = useSelector(isVisitorsListSubscribed);
const toggleCollapsed = useCallback(() => {
setCollapsed(c => {
const newCollapsed = !c;
if (featureEnabled && !newCollapsed && !isSubscribed) {
dispatch(subscribeVisitorsList());
}
return newCollapsed;
});
}, [ dispatch, isSubscribed, featureEnabled ]);
useEffect(() => {
if (featureEnabled && searchString) {
setCollapsed(false);
if (!isSubscribed) {
dispatch(subscribeVisitorsList());
}
}
}, [ searchString, dispatch, isSubscribed, featureEnabled ]);
if (!shouldDisplayList) {
return null;
}
const filtered = visitors.filter(v =>
normalizeAccents(v.name).toLowerCase().includes(normalizeAccents(searchString).toLowerCase())
);
// ListItem height is 56px including padding so the item size
// for virtualization needs to match it exactly to avoid clipping.
const itemSize = 56;
const Row = ({ index, style }: { index: number; style: any; }) => {
const v = filtered[index];
return (
<div style = { style }>
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { v.name }
participantID = { v.id }
videoMediaState = { MEDIA_STATE.NONE } />
</div>
);
};
const styles = {
overflowX: 'hidden' as const,
overflowY: 'auto' as const,
};
return (
<div className = { classes.container }>
<div
className = { classes.heading }
onClick = { toggleCollapsed }>
<span>{ t('participantsPane.headings.visitorsList', { count: visitorsCount }) }</span>
<span className = { classes.arrowContainer }>
<Icon
size = { 14 }
src = { collapsed ? IconArrowDown : IconArrowUp } />
</span>
</div>
{!collapsed && (
<div className = { classes.listContainer }>
<AutoSizer>
{ ({ height, width }) => (
<FixedSizeList
height = { Math.max(height, 200) }
itemCount = { filtered.length }
itemSize = { itemSize }
style = { styles }
width = { width }>
{ Row }
</FixedSizeList>
)}
</AutoSizer>
</div>
)}
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { findAncestorByClass } from '../../../base/ui/functions.web';
import { isAddBreakoutRoomButtonVisible } from '../../../breakout-rooms/functions';
import MuteEveryoneDialog from '../../../video-menu/components/web/MuteEveryoneDialog';
import { shouldDisplayCurrentVisitorsList } from '../../../visitors/functions';
import { close } from '../../actions.web';
import {
getParticipantsPaneOpen,
@@ -24,6 +25,7 @@ import {
import { AddBreakoutRoomButton } from '../breakout-rooms/components/web/AddBreakoutRoomButton';
import { RoomList } from '../breakout-rooms/components/web/RoomList';
import CurrentVisitorsList from './CurrentVisitorsList';
import { FooterContextMenu } from './FooterContextMenu';
import LobbyParticipants from './LobbyParticipants';
import MeetingParticipants from './MeetingParticipants';
@@ -73,6 +75,8 @@ const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
overflowY: 'auto',
position: 'relative',
padding: `0 ${participantsPaneTheme.panePadding}px`,
display: 'flex',
flexDirection: 'column',
'&::-webkit-scrollbar': {
display: 'none'
@@ -129,6 +133,7 @@ const ParticipantsPane = () => {
const paneOpen = useSelector(getParticipantsPaneOpen);
const isBreakoutRoomsSupported = useSelector((state: IReduxState) => state['features/base/conference'])
.conference?.getBreakoutRooms()?.isSupported();
const showCurrentVisitorsList = useSelector(shouldDisplayCurrentVisitorsList);
const showAddRoomButton = useSelector(isAddBreakoutRoomButtonVisible);
const showFooter = useSelector(isLocalParticipantModerator);
const showMuteAllButton = useSelector(isMuteAllVisible);
@@ -193,6 +198,7 @@ const ParticipantsPane = () => {
setSearchString = { setSearchString } />
{isBreakoutRoomsSupported && <RoomList searchString = { searchString } />}
{showAddRoomButton && <AddBreakoutRoomButton />}
{showCurrentVisitorsList && <CurrentVisitorsList searchString = { searchString } />}
</div>
{showFooter && (
<div className = { classes.footer }>

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();
}
}