From 877fc98eefcdcd2995af47452c19a62d0ea56218 Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Fri, 27 Jun 2025 19:17:32 -0500 Subject: [PATCH] feat(visitors-list): Add to participant pane. --- lang/main.json | 1 + package-lock.json | 16 ++ package.json | 1 + react/features/base/jwt/constants.ts | 1 + react/features/base/participants/types.ts | 1 + .../components/web/CurrentVisitorsList.tsx | 169 ++++++++++++++++++ .../components/web/ParticipantsPane.tsx | 6 + react/features/visitors/actionTypes.ts | 11 ++ react/features/visitors/actions.ts | 28 +++ react/features/visitors/functions.ts | 50 ++++++ react/features/visitors/middleware.ts | 75 +++++++- react/features/visitors/reducer.ts | 22 ++- react/features/visitors/types.ts | 5 + react/features/visitors/websocket-client.ts | 80 +++++++++ 14 files changed, 461 insertions(+), 5 deletions(-) create mode 100644 react/features/participants-pane/components/web/CurrentVisitorsList.tsx diff --git a/lang/main.json b/lang/main.json index 0e11145d9b..136e9dec5a 100644 --- a/lang/main.json +++ b/lang/main.json @@ -909,6 +909,7 @@ "visitorInQueue": " (waiting {{count}})", "visitorRequests": " (requests {{count}})", "visitors": "Viewers {{count}}", + "visitorsList": "Viewers ({{count}})", "waitingLobby": "Waiting in lobby ({{count}})" }, "search": "Search participants", diff --git a/package-lock.json b/package-lock.json index 9e2a2548c2..2977758635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e8123b9ce2..715e921fd5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/react/features/base/jwt/constants.ts b/react/features/base/jwt/constants.ts index f411589bfa..4a9a263a5f 100644 --- a/react/features/base/jwt/constants.ts +++ b/react/features/base/jwt/constants.ts @@ -18,6 +18,7 @@ export const MEET_FEATURES: Record = { 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' diff --git a/react/features/base/participants/types.ts b/react/features/base/participants/types.ts index e965c63527..04a0b8d3c2 100644 --- a/react/features/base/participants/types.ts +++ b/react/features/base/participants/types.ts @@ -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; diff --git a/react/features/participants-pane/components/web/CurrentVisitorsList.tsx b/react/features/participants-pane/components/web/CurrentVisitorsList.tsx new file mode 100644 index 0000000000..499a909064 --- /dev/null +++ b/react/features/participants-pane/components/web/CurrentVisitorsList.tsx @@ -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} 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 ( +
+ +
+ ); + }; + const styles = { + overflowX: 'hidden' as const, + overflowY: 'auto' as const, + }; + + return ( +
+
+ { t('participantsPane.headings.visitorsList', { count: visitorsCount }) } + + + +
+ {!collapsed && ( +
+ + { ({ height, width }) => ( + + { Row } + + )} + +
+ )} +
+ ); +} diff --git a/react/features/participants-pane/components/web/ParticipantsPane.tsx b/react/features/participants-pane/components/web/ParticipantsPane.tsx index 053b5d6151..d8aa773c0d 100644 --- a/react/features/participants-pane/components/web/ParticipantsPane.tsx +++ b/react/features/participants-pane/components/web/ParticipantsPane.tsx @@ -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()((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 && } {showAddRoomButton && } + {showCurrentVisitorsList && } {showFooter && (
diff --git a/react/features/visitors/actionTypes.ts b/react/features/visitors/actionTypes.ts index 464183d9ca..a1ae5fed34 100644 --- a/react/features/visitors/actionTypes.ts +++ b/react/features/visitors/actionTypes.ts @@ -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'; diff --git a/react/features/visitors/actions.ts b/react/features/visitors/actions.ts index 71357e3d66..93f1272d9a 100644 --- a/react/features/visitors/actions.ts +++ b/react/features/visitors/actions.ts @@ -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} 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. * diff --git a/react/features/visitors/functions.ts b/react/features/visitors/functions.ts index 825b1fe6ac..f40a82ac37 100644 --- a/react/features/visitors/functions.ts +++ b/react/features/visitors/functions.ts @@ -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} + */ +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; +} diff --git a/react/features/visitors/middleware.ts b/react/features/visitors/middleware.ts index 369dd49157..5bd23a7745 100644 --- a/react/features/visitors/middleware.ts +++ b/react/features/visitors/middleware.ts @@ -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. * diff --git a/react/features/visitors/reducer.ts b/react/features/visitors/reducer.ts index c1f3c77711..7a68001511 100644 --- a/react/features/visitors/reducer.ts +++ b/react/features/visitors/reducer.ts @@ -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('features/visitors', (state = DEFAULT_STATE, action): IVisitorsState => { switch (action.type) { @@ -90,6 +96,18 @@ ReducerRegistry.register('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 || []; diff --git a/react/features/visitors/types.ts b/react/features/visitors/types.ts index 1a858e7a23..dff15db2af 100644 --- a/react/features/visitors/types.ts +++ b/react/features/visitors/types.ts @@ -2,3 +2,8 @@ export interface IPromotionRequest { from: string; nick: string; } + +export interface IVisitor { + id: string; + name: string; +} diff --git a/react/features/visitors/websocket-client.ts b/react/features/visitors/websocket-client.ts index 6f08c8defc..23db9e05d9 100644 --- a/react/features/visitors/websocket-client.ts +++ b/react/features/visitors/websocket-client.ts @@ -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(); + } }