Compare commits

...

3 Commits

Author SHA1 Message Date
Mihaela Dumitru
b4cc466608 feat(files): add external api events for upload/delete (#16908) 2026-02-09 14:10:42 +02:00
Jaya Allamsetty
bde8dca825 fix(filmstrip) Ensures dominant speaker is always visible in filmstrip (#16901)
* fix(filmstrip) Ensures dominant speaker is always visible in filmstrip
2026-02-06 14:29:32 -05:00
Hristo Terezov
2ab86cffb8 feat(draggable-panels): Enable touch-screen support
Adds touch-screen support for resizing filmstrip and chat panels to enable tablet and touch-laptop users to adjust panel widths. Previously, drag handles only worked with mouse hover, making panels non-resizable on touch devices.

Changes:
- Implement Pointer Events API for unified mouse/touch handling
- Add touch device detection with screen size threshold
- Make drag handles always visible on touch devices with padding for easier tapping
- Maintain identical visual layout between touch and non-touch versions

Touch devices with sufficiently large screens now have fully functional drag handles with appropriate hit targets while smaller devices remain disabled to preserve mobile UX.
2026-02-06 09:48:44 -06:00
19 changed files with 819 additions and 186 deletions

View File

@@ -2253,6 +2253,32 @@ class API {
});
}
/**
* Notify the external application that a file has been uploaded.
*
* @param {Object} fileMetadata - The file metadata.
* @returns {void}
*/
notifyFileUploaded(fileMetadata) {
this._sendEvent({
name: 'file-uploaded',
file: fileMetadata
});
}
/**
* Notify the external application that a file has been deleted.
*
* @param {string} fileId - The ID of the deleted file.
* @returns {void}
*/
notifyFileDeleted(fileId) {
this._sendEvent({
name: 'file-deleted',
fileId
});
}
/**
* Notify the external application that the audio or video is being shared by a participant.
*

View File

@@ -133,6 +133,8 @@ const events = {
'face-landmark-detected': 'faceLandmarkDetected',
'feedback-submitted': 'feedbackSubmitted',
'feedback-prompt-displayed': 'feedbackPromptDisplayed',
'file-deleted': 'fileDeleted',
'file-uploaded': 'fileUploaded',
'filmstrip-display-changed': 'filmstripDisplayChanged',
'incoming-message': 'incomingMessage',
'knocking-participant': 'knockingParticipant',

View File

@@ -29,4 +29,3 @@ export function isIpadMobileBrowser() {
// @ts-ignore
return isIosMobileBrowser() && Platform.isPad;
}

View File

@@ -0,0 +1 @@
export * from './utils.any';

View File

@@ -0,0 +1,37 @@
import { MIN_FILMSTRIP_RESIZE_WIDTH } from '../../filmstrip/constants';
/**
* Detects if the current device has touch capability.
* This includes smartphones, tablets, and laptops with touch screens.
*
* @returns {boolean} True if the device supports touch events.
*/
export function isTouchDevice(): boolean {
// Check maxTouchPoints (most reliable for modern browsers)
if ('maxTouchPoints' in navigator) {
return navigator.maxTouchPoints > 0;
}
return false;
}
/**
* Determines if resize functionality should be enabled based on device capabilities
* and screen size. On touch devices, resize is only enabled for larger screens.
* On non-touch devices (desktop), resize is always enabled.
*
* @returns {boolean} True if resize functionality should be available to the user.
*/
export function shouldEnableResize(): boolean {
const hasTouch = isTouchDevice();
// On non-touch devices (desktop), always enable resize
if (!hasTouch) {
return true;
}
// On touch devices, only enable if screen is large enough.
return window?.innerWidth >= MIN_FILMSTRIP_RESIZE_WIDTH;
}
export * from './utils.any';

View File

@@ -60,66 +60,6 @@ const AVATAR_CHECKER_FUNCTIONS = [
return null;
}
];
/* eslint-enable arrow-body-style */
/**
* Returns the list of active speakers that should be moved to the top of the sorted list of participants so that the
* dominant speaker is visible always on the vertical filmstrip in stage layout.
*
* @param {Function | Object} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @returns {Array<string>}
*/
export function getActiveSpeakersToBeDisplayed(stateful: IStateful) {
const state = toState(stateful);
const {
dominantSpeaker,
fakeParticipants,
sortedRemoteVirtualScreenshareParticipants,
speakersList
} = state['features/base/participants'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
let activeSpeakers = new Map(speakersList);
// Do not re-sort the active speakers if dominant speaker is currently visible.
if (dominantSpeaker && visibleRemoteParticipants.has(dominantSpeaker)) {
return activeSpeakers;
}
let availableSlotsForActiveSpeakers = visibleRemoteParticipants.size;
if (activeSpeakers.has(dominantSpeaker ?? '')) {
activeSpeakers.delete(dominantSpeaker ?? '');
}
// Add dominant speaker to the beginning of the list (not including self) since the active speaker list is always
// alphabetically sorted.
if (dominantSpeaker && dominantSpeaker !== getLocalParticipant(state)?.id) {
const updatedSpeakers = Array.from(activeSpeakers);
updatedSpeakers.splice(0, 0, [ dominantSpeaker, getParticipantById(state, dominantSpeaker)?.name ?? '' ]);
activeSpeakers = new Map(updatedSpeakers);
}
// Remove screenshares from the count.
if (sortedRemoteVirtualScreenshareParticipants) {
availableSlotsForActiveSpeakers -= sortedRemoteVirtualScreenshareParticipants.size * 2;
for (const screenshare of Array.from(sortedRemoteVirtualScreenshareParticipants.keys())) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare as string);
activeSpeakers.delete(ownerId);
}
}
// Remove fake participants from the count.
if (fakeParticipants) {
availableSlotsForActiveSpeakers -= fakeParticipants.size;
}
const truncatedSpeakersList = Array.from(activeSpeakers).slice(0, availableSlotsForActiveSpeakers);
truncatedSpeakersList.sort((a: any, b: any) => a[1].localeCompare(b[1]));
return new Map(truncatedSpeakersList);
}
/**
* Resolves the first loadable avatar URL for a participant.

View File

@@ -68,6 +68,7 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
];
const DEFAULT_STATE = {
activeSpeakers: new Set<string>(),
dominantSpeaker: undefined,
fakeParticipants: new Map(),
local: undefined,
@@ -82,10 +83,10 @@ const DEFAULT_STATE = {
remoteVideoSources: new Set<string>(),
sortedRemoteVirtualScreenshareParticipants: new Map(),
sortedRemoteParticipants: new Map(),
speakersList: new Map()
};
export interface IParticipantsState {
activeSpeakers: Set<string>;
dominantSpeaker?: string;
fakeParticipants: Map<string, IParticipant>;
local?: ILocalParticipant;
@@ -100,7 +101,6 @@ export interface IParticipantsState {
remoteVideoSources: Set<string>;
sortedRemoteParticipants: Map<string, string>;
sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
speakersList: Map<string, string>;
}
/**
@@ -157,22 +157,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
const { participant } = action;
const { id, previousSpeakers = [] } = participant;
const { dominantSpeaker, local } = state;
const newSpeakers = [ id, ...previousSpeakers ];
const sortedSpeakersList: Array<Array<string>> = [];
for (const speaker of newSpeakers) {
if (speaker !== local?.id) {
const remoteParticipant = state.remote.get(speaker);
remoteParticipant
&& sortedSpeakersList.push(
[ speaker, _getDisplayName(state, remoteParticipant?.name) ]
);
}
}
// Keep the remote speaker list sorted alphabetically.
sortedSpeakersList.sort((a, b) => a[1].localeCompare(b[1]));
const activeSpeakers = new Set(previousSpeakers.filter((speakerId: string) => speakerId !== local?.id));
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
@@ -183,7 +168,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
return {
...state,
dominantSpeaker: id, // @ts-ignore
speakersList: new Map(sortedSpeakersList)
activeSpeakers
};
}
@@ -438,7 +423,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
}
// Remove the participant from the list of speakers.
state.speakersList.has(id) && state.speakersList.delete(id);
state.activeSpeakers.delete(id);
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;

View File

@@ -4,6 +4,7 @@ import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg';
import { getLocalParticipant, getRemoteParticipants, isPrivateChatEnabledSelf } from '../../../base/participants/functions';
@@ -23,7 +24,16 @@ import {
setUserChatWidth,
toggleChat
} from '../../actions.web';
import { CHAT_SIZE, ChatTabs, OPTION_GROUPCHAT, SMALL_WIDTH_THRESHOLD } from '../../constants';
import {
CHAT_DRAG_HANDLE_HEIGHT,
CHAT_DRAG_HANDLE_OFFSET,
CHAT_DRAG_HANDLE_WIDTH,
CHAT_SIZE,
CHAT_TOUCH_HANDLE_SIZE,
ChatTabs,
OPTION_GROUPCHAT,
SMALL_WIDTH_THRESHOLD
} from '../../constants';
import { getChatMaxSize, getFocusedTab, isChatDisabled } from '../../functions';
import { IChatProps as AbstractProps } from '../../types';
@@ -104,7 +114,12 @@ interface IProps extends AbstractProps {
_width: number;
}
const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme, { _isResizing, width }) => {
const useStyles = makeStyles<{
_isResizing: boolean;
isTouch: boolean;
resizeEnabled: boolean;
width: number;
}>()((theme, { _isResizing, isTouch, resizeEnabled, width }) => {
return {
container: {
backgroundColor: theme.palette.chatBackground,
@@ -115,11 +130,15 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
width: `${width}px`,
zIndex: 300,
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
// On non-touch devices (desktop), show handle on hover
// On touch devices, handle is always visible if resize is enabled
...(!isTouch && {
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
}
}
},
}),
'@media (max-width: 580px)': {
height: '100dvh',
@@ -183,16 +202,23 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
dragHandleContainer: {
height: '100%',
width: '9px',
// Touch devices need larger hit target but positioned to not take extra space
width: isTouch ? `${CHAT_TOUCH_HANDLE_SIZE}px` : `${CHAT_DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'absolute',
cursor: 'col-resize',
display: 'flex',
display: resizeEnabled ? 'flex' : 'none', // Hide if resize not enabled
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden',
right: '4px',
// On touch devices, always visible if resize enabled. On desktop, hidden by default
visibility: (isTouch && resizeEnabled) ? 'visible' : 'hidden',
// Position touch handle centered on offset from edge, maintaining same gap as non-touch
right: isTouch
? `${CHAT_DRAG_HANDLE_OFFSET - Math.floor((CHAT_TOUCH_HANDLE_SIZE - CHAT_DRAG_HANDLE_WIDTH) / 2)}px`
: `${CHAT_DRAG_HANDLE_OFFSET}px`,
top: 0,
// Prevent touch scrolling while dragging
touchAction: 'none',
'&:hover': {
'& .dragHandle': {
@@ -210,10 +236,15 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
},
dragHandle: {
// Keep the same visual appearance on all devices
backgroundColor: theme.palette.icon02,
height: '100px',
width: '3px',
borderRadius: '1px'
height: `${CHAT_DRAG_HANDLE_HEIGHT}px`,
width: `${CHAT_DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px',
// Make more visible when actively shown
...(isTouch && resizeEnabled && {
backgroundColor: theme.palette.icon01
})
},
privateMessageRecipientsList: {
@@ -246,7 +277,10 @@ const Chat = ({
return null;
}
const { classes, cx } = useStyles({ _isResizing, width: _width });
// Detect touch capability and screen size for resize functionality
const isTouch = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const { classes, cx } = useStyles({ _isResizing, width: _width, isTouch, resizeEnabled });
const [ isMouseDown, setIsMouseDown ] = useState(false);
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
const [ dragChatWidth, setDragChatWidth ] = useState<number | null>(null);
@@ -282,16 +316,21 @@ const Chat = ({
}, [ participants, defaultRemoteDisplayName, t, notifyTimestamp ]);
/**
* Handles mouse down on the drag handle.
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mouse down event.
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => {
const onDragHandlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Store the initial mouse position and chat width
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Store the initial pointer position and chat width
setIsMouseDown(true);
setMousePosition(e.clientX);
setDragChatWidth(_width);
@@ -299,7 +338,7 @@ const Chat = ({
// Indicate that resizing is in progress
dispatch(setChatIsResizing(true));
// Add visual feedback that we're dragging
// Add visual feedback that we're dragging (cursor for mouse, not visible on touch)
document.body.style.cursor = 'col-resize';
// Disable text selection during resize
@@ -307,11 +346,12 @@ const Chat = ({
}, [ _width, dispatch ]);
/**
* Drag handle mouse up handler.
* Drag handle pointer up handler.
* Supports both mouse and touch events via Pointer Events API.
*
* @returns {void}
*/
const onDragMouseUp = useCallback(() => {
const onDragPointerUp = useCallback(() => {
if (isMouseDown) {
setIsMouseDown(false);
dispatch(setChatIsResizing(false));
@@ -323,12 +363,13 @@ const Chat = ({
}, [ isMouseDown, dispatch ]);
/**
* Handles drag handle mouse move.
* Handles drag handle pointer move.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mousemove event.
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
const onChatResize = useCallback(throttle((e: MouseEvent) => {
const onChatResize = useCallback(throttle((e: PointerEvent) => {
if (isMouseDown && mousePosition !== null && dragChatWidth !== null) {
// For chat panel resizing on the left edge:
// - Dragging left (decreasing X coordinate) should make the panel wider
@@ -352,14 +393,14 @@ const Chat = ({
// Set up event listeners when component mounts
useEffect(() => {
document.addEventListener('mouseup', onDragMouseUp);
document.addEventListener('mousemove', onChatResize);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointermove', onChatResize);
return () => {
document.removeEventListener('mouseup', onDragMouseUp);
document.removeEventListener('mousemove', onChatResize);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointermove', onChatResize);
};
}, [ onDragMouseUp, onChatResize ]);
}, [ onDragPointerUp, onChatResize ]);
/**
* Sends a text message.
@@ -600,7 +641,7 @@ const Chat = ({
(isMouseDown || _isResizing) && 'visible',
'dragHandleContainer'
) }
onMouseDown = { onDragHandleMouseDown }>
onPointerDown = { onDragHandlePointerDown }>
<div className = { cx(classes.dragHandle, 'dragHandle') } />
</div>
</div> : null

View File

@@ -33,6 +33,23 @@ export const MESSAGE_TYPE_REMOTE = 'remote';
export const SMALL_WIDTH_THRESHOLD = 580;
/**
* Drag handle dimensions for resizable chat.
*/
export const CHAT_DRAG_HANDLE_WIDTH = 9;
export const CHAT_DRAG_HANDLE_HEIGHT = 100;
/**
* Touch target size for chat drag handle on touch devices.
* Provides adequate hit area (44px) for comfortable tapping.
*/
export const CHAT_TOUCH_HANDLE_SIZE = 44;
/**
* Offset from edge for positioning the chat drag handle.
*/
export const CHAT_DRAG_HANDLE_OFFSET = 4;
/**
* Lobby message type.

View File

@@ -72,6 +72,9 @@ StateListenerRegistry.register(
type: _FILE_REMOVED,
fileId
});
// Notify external API about file deletion (for remote participants)
APP.API.notifyFileDeleted(fileId);
});
conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => {
@@ -83,6 +86,11 @@ StateListenerRegistry.register(
files,
localParticipantId: localParticipant?.id
});
// Notify external API about existing files (for participants joining late)
Object.values(files).forEach((file: IFileMetadata) => {
APP.API.notifyFileUploaded(file);
});
});
}
});
@@ -152,6 +160,8 @@ MiddlewareRegistry.register(store => next => action => {
privateMessage: false,
timestamp: file.timestamp
}));
APP.API.notifyFileUploaded(file);
}
return result;
@@ -178,6 +188,9 @@ MiddlewareRegistry.register(store => next => action => {
fileId
});
// Notify external API about file deletion (for the local participant who deleted it)
APP.API.notifyFileDeleted(fileId);
const { fileSharing } = state['features/base/config'];
const sessionId = conference.getMeetingUniqueId();

View File

@@ -10,7 +10,7 @@ import { withStyles } from 'tss-react/mui';
import { ACTION_SHORTCUT_TRIGGERED, createShortcutEvent, createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { isMobileBrowser, isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
@@ -32,12 +32,17 @@ import {
import {
ASPECT_RATIO_BREAKPOINT,
DEFAULT_FILMSTRIP_WIDTH,
DRAG_HANDLE_HEIGHT,
DRAG_HANDLE_TOP_PANEL_HEIGHT,
DRAG_HANDLE_TOP_PANEL_WIDTH,
DRAG_HANDLE_WIDTH,
FILMSTRIP_TYPE,
MIN_STAGE_VIEW_HEIGHT,
MIN_STAGE_VIEW_WIDTH,
TILE_HORIZONTAL_MARGIN,
TILE_VERTICAL_MARGIN,
TOP_FILMSTRIP_HEIGHT
TOP_FILMSTRIP_HEIGHT,
TOUCH_DRAG_HANDLE_PADDING
} from '../../constants';
import {
getVerticalViewMaxWidth,
@@ -52,6 +57,21 @@ import ThumbnailWrapper from './ThumbnailWrapper';
const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
const TOUCH_DEVICE_PADDING = {
paddingLeft: `${TOUCH_DRAG_HANDLE_PADDING}px`,
paddingRight: `${TOUCH_DRAG_HANDLE_PADDING}px`,
paddingTop: 0,
paddingBottom: 0
};
const TOUCH_DEVICE_TOP_PANEL_PADDING = {
paddingLeft: 0,
paddingRight: 0,
paddingTop: `${TOUCH_DRAG_HANDLE_PADDING}px`,
paddingBottom: `${TOUCH_DRAG_HANDLE_PADDING}px`
};
const NON_TOUCH_DEVICE_PANEL = {
pading: 0
};
/**
* Creates the styles for the component.
@@ -61,6 +81,14 @@ const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
* @returns {Object}
*/
function styles(theme: Theme, props: IProps) {
const { _topPanelFilmstrip: isTopPanel } = props;
const _isTouchDevice = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const handlePaddding = _isTouchDevice
? (isTopPanel ? TOUCH_DEVICE_TOP_PANEL_PADDING : TOUCH_DEVICE_PADDING)
: NON_TOUCH_DEVICE_PANEL;
const result = {
toggleFilmstripContainer: {
display: 'flex',
@@ -122,23 +150,27 @@ function styles(theme: Theme, props: IProps) {
right: 0,
bottom: 0,
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
// On touch devices, handle is always visible via base styles, so no hover needed.
// On desktop, show handle on hover/focus.
...(!_isTouchDevice && {
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
'& .filmstrip-hover': {
backgroundColor: BACKGROUND_COLOR
},
'& .filmstrip-hover': {
backgroundColor: BACKGROUND_COLOR
},
'& .toggleFilmstripContainer': {
opacity: 1
},
'& .toggleFilmstripContainer': {
opacity: 1
},
'& .dragHandleContainer': {
visibility: 'visible' as const
'& .dragHandleContainer': {
visibility: 'visible' as const
}
}
},
}),
'.horizontal-filmstrip &.hidden': {
bottom: '-50px',
@@ -187,14 +219,22 @@ function styles(theme: Theme, props: IProps) {
dragHandleContainer: {
height: '100%',
width: '9px',
width: `${DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'relative' as const,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden' as const,
// On touch devices, always visible if resize enabled. On desktop, hidden by default
visibility: (_isTouchDevice && resizeEnabled) ? 'visible' as const : 'hidden' as const,
marginLeft: 0,
marginTop: 0,
// Touch devices get padding for easier tapping
// Vertical filmstrip: left/right padding. Top panel: top/bottom padding.
...handlePaddding,
// Prevent touch scrolling while dragging
touchAction: 'none',
'&:hover': {
'& .dragHandle': {
@@ -213,20 +253,21 @@ function styles(theme: Theme, props: IProps) {
'&.top-panel': {
order: 2,
width: '100%',
height: '9px',
height: `${DRAG_HANDLE_WIDTH}px`,
cursor: 'row-resize',
'& .dragHandle': {
height: '3px',
width: '100px'
height: `${DRAG_HANDLE_TOP_PANEL_HEIGHT}px`,
width: `${DRAG_HANDLE_TOP_PANEL_WIDTH}px`
}
}
},
dragHandle: {
// Keep the same visual appearance on all devices
backgroundColor: theme.palette.filmstripDragHandle,
height: '100px',
width: '3px',
height: `${DRAG_HANDLE_HEIGHT}px`,
width: `${DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px'
}
};
@@ -313,6 +354,11 @@ export interface IProps extends WithTranslation {
*/
_isToolboxVisible: Boolean;
/**
* Whether the device has touch capability.
*/
_isTouchDevice?: boolean;
/**
* Whether or not the current layout is vertical filmstrip.
*/
@@ -358,6 +404,11 @@ export interface IProps extends WithTranslation {
*/
_resizableFilmstrip: boolean;
/**
* Whether resize functionality should be enabled based on device and screen size.
*/
_resizeEnabled?: boolean;
/**
* The number of rows in tile view.
*/
@@ -491,8 +542,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
this._onGridItemsRendered = this._onGridItemsRendered.bind(this);
this._onListItemsRendered = this._onListItemsRendered.bind(this);
this._onToggleButtonTouch = this._onToggleButtonTouch.bind(this);
this._onDragHandleMouseDown = this._onDragHandleMouseDown.bind(this);
this._onDragMouseUp = this._onDragMouseUp.bind(this);
this._onDragHandlePointerDown = this._onDragHandlePointerDown.bind(this);
this._onDragHandleClick = this._onDragHandleClick.bind(this);
this._onDragHandleTouchStart = this._onDragHandleTouchStart.bind(this);
this._onDragPointerUp = this._onDragPointerUp.bind(this);
this._onFilmstripResize = this._onFilmstripResize.bind(this);
this._throttledResize = throttle(
@@ -516,10 +569,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
handler: this._onShortcutToggleFilmstrip
}));
document.addEventListener('mouseup', this._onDragMouseUp);
document.addEventListener('pointerup', this._onDragPointerUp);
// @ts-ignore
document.addEventListener('mousemove', this._throttledResize);
document.addEventListener('pointermove', this._throttledResize);
}
/**
@@ -530,10 +583,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
override componentWillUnmount() {
this.props.dispatch(unregisterShortcut('F'));
document.removeEventListener('mouseup', this._onDragMouseUp);
document.removeEventListener('pointerup', this._onDragPointerUp);
// @ts-ignore
document.removeEventListener('mousemove', this._throttledResize);
document.removeEventListener('pointermove', this._throttledResize);
}
/**
@@ -678,7 +731,9 @@ class Filmstrip extends PureComponent <IProps, IState> {
(isMouseDown || _alwaysShowResizeBar) && 'visible',
_topPanelFilmstrip && 'top-panel')
}
onMouseDown = { this._onDragHandleMouseDown }>
onClick = { this._onDragHandleClick }
onPointerDown = { this._onDragHandlePointerDown }
onTouchStart = { this._onDragHandleTouchStart }>
<div className = { clsx(classes.dragHandle, 'dragHandle') } />
</div>
{filmstrip}
@@ -691,14 +746,23 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
/**
* Handles mouse down on the drag handle.
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mouse down event.
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
_onDragHandleMouseDown(e: React.MouseEvent) {
_onDragHandlePointerDown(e: React.PointerEvent) {
const { _topPanelFilmstrip, _topPanelHeight, _verticalFilmstripWidth } = this.props;
// Prevent toolbar from appearing and stop event propagation
e.preventDefault();
e.stopPropagation();
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
this.setState({
isMouseDown: true,
mousePosition: _topPanelFilmstrip ? e.clientY : e.clientX,
@@ -709,11 +773,33 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
/**
* Drag handle mouse up handler.
* Prevents click events on drag handle from triggering toolbar.
*
* @param {React.MouseEvent} e - The click event.
* @returns {void}
*/
_onDragHandleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
/**
* Prevents touch start events on drag handle from triggering toolbar.
*
* @param {React.TouchEvent} e - The touch start event.
* @returns {void}
*/
_onDragHandleTouchStart(e: React.TouchEvent) {
e.stopPropagation();
}
/**
* Drag handle pointer up handler.
* Supports both mouse and touch events via Pointer Events API.
*
* @returns {void}
*/
_onDragMouseUp() {
_onDragPointerUp() {
if (this.state.isMouseDown) {
this.setState({
isMouseDown: false
@@ -723,12 +809,13 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
/**
* Handles drag handle mouse move.
* Handles drag handle pointer move.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mousemove event.
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
_onFilmstripResize(e: React.MouseEvent) {
_onFilmstripResize(e: PointerEvent) {
if (this.state.isMouseDown) {
const {
dispatch,
@@ -1163,4 +1250,4 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
};
}
export default withStyles(translate(connect(_mapStateToProps)(Filmstrip)), styles);
export default translate(connect(_mapStateToProps)(withStyles(Filmstrip, styles)));

View File

@@ -269,6 +269,19 @@ export const MIN_STAGE_VIEW_HEIGHT = 700;
*/
export const MIN_STAGE_VIEW_WIDTH = 800;
/**
* Drag handle dimensions for resizable filmstrip.
*/
export const DRAG_HANDLE_WIDTH = 9;
export const DRAG_HANDLE_HEIGHT = 100;
export const DRAG_HANDLE_TOP_PANEL_HEIGHT = 3;
export const DRAG_HANDLE_TOP_PANEL_WIDTH = 100;
/**
* Touch padding added to each side of drag handle for easier tapping on touch devices.
*/
export const TOUCH_DRAG_HANDLE_PADDING = 6;
/**
* Horizontal margin used for the vertical filmstrip.
*/
@@ -298,3 +311,9 @@ export const MAX_ACTIVE_PARTICIPANTS = 6;
* Top filmstrip default height.
*/
export const TOP_FILMSTRIP_HEIGHT = 180;
/**
* Minimum screen width needed for functional filmstrip resizing.
* Calculated as stage minimum + filmstrip minimum (800px + 120px = 920px).
*/
export const MIN_FILMSTRIP_RESIZE_WIDTH = MIN_STAGE_VIEW_WIDTH + DEFAULT_FILMSTRIP_WIDTH;

View File

@@ -1,8 +1,5 @@
import { IReduxState, IStore } from '../app/types';
import {
getActiveSpeakersToBeDisplayed,
getVirtualScreenshareParticipantOwnerId
} from '../base/participants/functions';
import { getParticipantById, getVirtualScreenshareParticipantOwnerId } from '../base/participants/functions';
import { setRemoteParticipants } from './actions';
import { isFilmstripScrollVisible } from './functions';
@@ -33,44 +30,66 @@ export function updateRemoteParticipants(store: IStore, force?: boolean, partici
}
const {
activeSpeakers,
dominantSpeaker,
fakeParticipants,
sortedRemoteParticipants
} = state['features/base/participants'];
const config = state['features/base/config'];
const defaultRemoteDisplayName = config?.defaultRemoteDisplayName ?? 'Fellow Jitster';
const dominant = dominantSpeaker ? getParticipantById(state, dominantSpeaker) : undefined;
let dominantSpeakerSlot = 0;
const previousSpeakers = new Set(activeSpeakers);
const remoteParticipants = new Map(sortedRemoteParticipants);
const screenShareParticipants = sortedRemoteVirtualScreenshareParticipants
? [ ...sortedRemoteVirtualScreenshareParticipants.keys() ] : [];
const sharedVideos = fakeParticipants ? Array.from(fakeParticipants.keys()) : [];
const speakers = getActiveSpeakersToBeDisplayed(state);
const speakers = new Array<string>();
const { visibleRemoteParticipants } = state['features/filmstrip'];
for (const screenshare of screenShareParticipants) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
remoteParticipants.delete(ownerId);
remoteParticipants.delete(screenshare);
speakers.delete(ownerId);
}
for (const sharedVideo of sharedVideos) {
remoteParticipants.delete(sharedVideo);
}
for (const speaker of speakers.keys()) {
remoteParticipants.delete(speaker);
}
// Always update the order of the thubmnails.
const participantsWithScreenShare = screenShareParticipants.reduce<string[]>((acc, screenshare) => {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
acc.push(ownerId);
acc.push(screenshare);
remoteParticipants.delete(ownerId);
remoteParticipants.delete(screenshare);
previousSpeakers.delete(ownerId);
return acc;
}, []);
for (const sharedVideo of sharedVideos) {
remoteParticipants.delete(sharedVideo);
}
if (dominant && !dominant.local && participantsWithScreenShare.indexOf(dominant.id) === -1) {
dominantSpeakerSlot = 1;
remoteParticipants.delete(dominant.id);
speakers.push(dominant.id);
}
// Find the number of slots available for speakers.
const slotsForSpeakers
= visibleRemoteParticipants.size - (screenShareParticipants.length * 2) - sharedVideos.length - dominantSpeakerSlot;
// Construct the list of speakers to be shown.
if (slotsForSpeakers > 0) {
Array.from(previousSpeakers).slice(0, slotsForSpeakers).forEach((speakerId: string) => {
speakers.push(speakerId);
remoteParticipants.delete(speakerId);
});
speakers.sort((a: string, b: string) => {
return (getParticipantById(state, a)?.name ?? defaultRemoteDisplayName)
.localeCompare(getParticipantById(state, b)?.name ?? defaultRemoteDisplayName);
});
}
// Always update the order of the thumbnails.
reorderedParticipants = [
...participantsWithScreenShare,
...sharedVideos,
...Array.from(speakers.keys()),
...speakers,
...Array.from(remoteParticipants.keys())
];

View File

@@ -2,7 +2,7 @@ import { Theme } from '@mui/material/styles';
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { isMobileBrowser } from '../base/environment/utils';
import { isTouchDevice, shouldEnableResize } from '../base/environment/utils';
import { MEDIA_TYPE } from '../base/media/constants';
import {
getLocalParticipant,
@@ -30,6 +30,7 @@ import {
DEFAULT_LOCAL_TILE_ASPECT_RATIO,
DISPLAY_AVATAR,
DISPLAY_VIDEO,
DRAG_HANDLE_WIDTH,
FILMSTRIP_GRID_BREAKPOINT,
FILMSTRIP_TYPE,
INDICATORS_TOOLTIP_POSITION,
@@ -45,6 +46,7 @@ import {
TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES,
TILE_VIEW_GRID_HORIZONTAL_MARGIN,
TILE_VIEW_GRID_VERTICAL_MARGIN,
TOUCH_DRAG_HANDLE_PADDING,
VERTICAL_VIEW_HORIZONTAL_MARGIN
} from './constants';
@@ -621,6 +623,7 @@ export function getIndicatorsTooltipPosition(thumbnailType?: string) {
/**
* Returns whether or not the filmstrip is resizable.
* On touch devices, resize is only enabled for larger screens (tablets, not phones).
*
* @param {Object} state - Redux state.
* @returns {boolean}
@@ -629,7 +632,7 @@ export function isFilmstripResizable(state: IReduxState) {
const { filmstrip } = state['features/base/config'];
const _currentLayout = getCurrentLayout(state);
return !filmstrip?.disableResizable && !isMobileBrowser()
return !filmstrip?.disableResizable && shouldEnableResize()
&& (_currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW || _currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW);
}
@@ -662,8 +665,13 @@ export function getVerticalViewMaxWidth(state: IReduxState) {
// Adding 4px for the border-right and margin-right.
// On non-resizable filmstrip add 4px for the left margin and border.
// Also adding 7px for the scrollbar. Also adding 9px for the drag handle.
maxWidth += (_verticalViewGrid ? 0 : 11) + (_resizableFilmstrip ? 9 : 4);
// Also adding 7px for the scrollbar.
// Drag handle: DRAG_HANDLE_WIDTH + padding (TOUCH_DRAG_HANDLE_PADDING on each side for touch)
const dragHandleWidth = isTouchDevice()
? DRAG_HANDLE_WIDTH + (TOUCH_DRAG_HANDLE_PADDING * 2)
: DRAG_HANDLE_WIDTH;
maxWidth += (_verticalViewGrid ? 0 : 11) + (_resizableFilmstrip ? dragHandleWidth : 4);
return maxWidth;
}

View File

@@ -35,6 +35,8 @@ export const P1 = 'p1';
export const P2 = 'p2';
export const P3 = 'p3';
export const P4 = 'p4';
export const P5 = 'p5';
export const P6 = 'p6';
/**
* Participant.

View File

@@ -1,4 +1,4 @@
import { P1, P2, P3, P4, Participant } from './Participant';
import { P1, P2, P3, P4, P5, P6, Participant } from './Participant';
import { config } from './TestsConfig';
import { IJoinOptions, IParticipantOptions } from './types';
@@ -122,6 +122,50 @@ export async function ensureFourParticipants(options?: IJoinOptions): Promise<vo
]);
}
/**
* Ensure that there are six participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureSixParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// Join participants in batches
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
await Promise.all([
joinParticipant({ name: P5 }, options),
joinParticipant({ name: P6 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected(),
ctx.p5.waitForIceConnected(),
ctx.p6.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
ctx.p5.waitForSendReceiveData().then(() => ctx.p5.waitForRemoteStreams(1)),
ctx.p6.waitForSendReceiveData().then(() => ctx.p6.waitForRemoteStreams(1))
]);
}
/**
* Ensure that there are two participants.
*
@@ -244,10 +288,16 @@ export async function checkForScreensharingTile(sharer: Participant, observer: P
}
/**
* Hangs up all participants (p1, p2, p3 and p4)
* Hangs up all participants (p1, p2, p3, p4, p5, and p6)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
return Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup(), ctx.p3?.hangup(), ctx.p4?.hangup() ]
.map(p => p ?? Promise.resolve()));
return Promise.all([
ctx.p1?.hangup(),
ctx.p2?.hangup(),
ctx.p3?.hangup(),
ctx.p4?.hangup(),
ctx.p5?.hangup(),
ctx.p6?.hangup()
].map(p => p ?? Promise.resolve()));
}

View File

@@ -7,13 +7,16 @@ import { IToken, ITokenOptions } from './token';
export type IContext = {
/**
* The up-to-four browser instances provided by the framework. These can be initialized using
* The up-to-six browser instances provided by the framework. These can be initialized using
* ensureOneParticipant, ensureTwoParticipants, etc. from participants.ts.
**/
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
p5: Participant;
p6: Participant;
/** A room name automatically generated by the framework for convenience. */
roomName: string;
/**
@@ -39,7 +42,7 @@ export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Determines the browser instance to use. */
name: 'p1' | 'p2' | 'p3' | 'p4';
name: 'p1' | 'p2' | 'p3' | 'p4' | 'p5' | 'p6';
/** An optional token to use. */
token?: IToken;
};

View File

@@ -1,16 +1,20 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureThreeParticipants } from '../../helpers/participants';
import {
checkForScreensharingTile,
ensureSixParticipants,
ensureThreeParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import { muteAudioAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4', 'p5', 'p6' ]
});
describe('Active speaker', () => {
it('testActiveSpeaker', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await muteAudioAndCheck(p1, p2);
@@ -30,6 +34,306 @@ describe('Active speaker', () => {
await assertOneDominantSpeaker(p1);
await assertOneDominantSpeaker(p2);
await assertOneDominantSpeaker(p3);
await hangupAllParticipants();
});
/**
* Test that the dominant speaker appears in the filmstrip in stage view
* even when alphabetically last with limited visible slots.
* This tests the fix for the bug where dominant speakers at the bottom of
* the alphabetically sorted list would not appear when slots were limited.
*
* Note: This test verifies filmstrip ordering via Redux state
* (visibleRemoteParticipants), not large video behavior.
*/
it.skip('testDominantSpeakerInFilmstripWithLimitedSlots', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots to 2-3 tiles
// This creates the condition where not all participants fit in the filmstrip
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names to create alphabetical ordering
// Names chosen so p6 ("Zoe") is alphabetically last: Alice, Bob, Charlie, Eve, Frank, Zoe
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// Test with multiple speakers: Eve (p4), Frank (p5), and Zoe (p6)
// This verifies the fix works for different alphabetical positions
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) as dominant speaker`);
// Make this participant the dominant speaker by unmuting
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Verify that the participant appears in the visible remote participants
const filmstripState = await getFilmstripState(p1);
await p1.log(`Dominant speaker: ${filmstripState.dominantSpeaker}`);
await p1.log(`Visible remote participants: ${JSON.stringify(filmstripState.visibleRemoteParticipants)}`);
await p1.log(`${name} endpoint ID: ${participantId}`);
await p1.log(`Total remote: ${filmstripState.remoteParticipants.length}, Visible: ${filmstripState.visibleRemoteParticipants.length}`);
// Verify we actually have slot limitation (fewer visible than total)
expect(filmstripState.visibleRemoteParticipants.length).toBeLessThan(filmstripState.remoteParticipants.length);
// Assert that the dominant speaker is in the visible participants
// This is the key test - even though they may be alphabetically late and slots are limited,
// they should still be visible because the fix reserves a slot for dominant speaker
expect(filmstripState.visibleRemoteParticipants).toContain(participantId);
// Verify the dominant speaker thumbnail is visible in the filmstrip
await p1.driver.$(`//span[@id='participant_${participantId}']`).waitForDisplayed({
timeout: 5_000,
timeoutMsg: `${name} dominant speaker thumbnail not visible in filmstrip`
});
// Mute this participant back before testing the next one
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(2000);
}
await hangupAllParticipants();
});
/**
* Test dominant speaker in filmstrip with screensharing active.
* Verifies that dominant speaker is still visible when screen shares
* take up some of the visible slots.
*/
it.skip('testDominantSpeakerWithScreensharing', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// Start screensharing from p2
await p2.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p2, p1);
// Test with multiple speakers while screensharing is active
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) as dominant speaker with screensharing`);
// Make this participant the dominant speaker by unmuting
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Verify dominant speaker is still visible in filmstrip despite screenshare
const filmstripState = await getFilmstripState(p1);
await p1.log(`Dominant speaker (with screenshare): ${filmstripState.dominantSpeaker}`);
await p1.log(`Visible remote participants with screenshare: ${JSON.stringify(filmstripState.visibleRemoteParticipants)}`);
await p1.log(`${name} endpoint ID: ${participantId}`);
await p1.log(`Total remote: ${filmstripState.remoteParticipants.length}, Visible: ${filmstripState.visibleRemoteParticipants.length}`);
// Verify we have slot limitation even with screensharing
expect(filmstripState.visibleRemoteParticipants.length).toBeLessThan(filmstripState.remoteParticipants.length);
// The dominant speaker should still be in the visible participants despite screenshare taking slots
expect(filmstripState.visibleRemoteParticipants).toContain(participantId);
// Verify thumbnail visibility
await p1.driver.$(`//span[@id='participant_${participantId}']`).waitForDisplayed({
timeout: 5_000,
timeoutMsg: `${name} not visible with screensharing active`
});
// Mute this participant back before testing the next one
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
// Clean up - stop screensharing
await p2.getToolbar().clickStopDesktopSharingButton();
await hangupAllParticipants();
});
/**
* Test that filmstrip maintains stable ordering when multiple speakers alternate.
* Verifies that the alphabetical sorting prevents visual reordering when the same
* set of speakers take turns speaking.
*/
it.skip('testFilmstripStableOrderingWithMultipleSpeakers', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// First, have Eve, Frank, and Zoe all speak to get them into the active speakers list
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
await p1.log('Initial round: getting all three speakers into active speakers list');
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`${name} (${participantId}) speaking for the first time`);
await participant.getToolbar().clickAudioUnmuteButton();
await waitForDominantSpeaker(p1, participantId, name);
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
// Now cycle through them again and verify they maintain alphabetical order (Eve, Frank, Zoe)
await p1.log('Second round: verifying stable alphabetical ordering when speakers alternate');
const states = [];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) for stable ordering`);
// Make this participant the dominant speaker
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Capture filmstrip state
const filmstripState = await getFilmstripState(p1);
states.push({ name, id: participantId, state: filmstripState });
// Mute back
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
const [ eveState, frankState, zoeState ] = states;
// Helper function to get participant names in the order they appear
const getVisibleParticipantNames = async (visibleIds: string[]) => {
return await p1.execute(ids => {
const state = APP.store.getState();
const participants = state['features/base/participants'];
return ids.map(id => {
const participant = participants.remote.get(id);
return participant?.name || 'Unknown';
});
}, visibleIds);
};
// Get the names of visible participants for each state
const eveVisibleNames = await getVisibleParticipantNames(eveState.state.visibleRemoteParticipants);
const frankVisibleNames = await getVisibleParticipantNames(frankState.state.visibleRemoteParticipants);
const zoeVisibleNames = await getVisibleParticipantNames(zoeState.state.visibleRemoteParticipants);
await p1.log(`Visible participants when Eve is dominant: ${JSON.stringify(eveVisibleNames)}`);
await p1.log(`Visible participants when Frank is dominant: ${JSON.stringify(frankVisibleNames)}`);
await p1.log(`Visible participants when Zoe is dominant: ${JSON.stringify(zoeVisibleNames)}`);
await p1.log(`Eve visible count: ${eveState.state.visibleRemoteParticipants.length}, total remote: ${eveState.state.remoteParticipants.length}`);
await p1.log(`Frank visible count: ${frankState.state.visibleRemoteParticipants.length}, total remote: ${frankState.state.remoteParticipants.length}`);
await p1.log(`Zoe visible count: ${zoeState.state.visibleRemoteParticipants.length}, total remote: ${zoeState.state.remoteParticipants.length}`);
// Verify that each dominant speaker appears in visible participants
expect(eveState.state.visibleRemoteParticipants).toContain(eveState.id);
expect(frankState.state.visibleRemoteParticipants).toContain(frankState.id);
expect(zoeState.state.visibleRemoteParticipants).toContain(zoeState.id);
// Helper function to get the relative order of Eve, Frank, and Zoe
const getSpeakersOrder = (names: string[]) => {
return names.filter(n => [ 'Eve', 'Frank', 'Zoe' ].includes(n));
};
const eveOrder = getSpeakersOrder(eveVisibleNames);
const frankOrder = getSpeakersOrder(frankVisibleNames);
const zoeOrder = getSpeakersOrder(zoeVisibleNames);
await p1.log(`Speakers order when Eve is dominant: ${JSON.stringify(eveOrder)}`);
await p1.log(`Speakers order when Frank is dominant: ${JSON.stringify(frankOrder)}`);
await p1.log(`Speakers order when Zoe is dominant: ${JSON.stringify(zoeOrder)}`);
// Verify that the dominant speaker is always in the visible list (this tests the bug fix)
expect(eveOrder).toContain('Eve');
expect(frankOrder).toContain('Frank');
expect(zoeOrder).toContain('Zoe');
// Helper to check if array is alphabetically sorted
const isAlphabeticallySorted = (names: string[]) => {
for (let i = 0; i < names.length - 1; i++) {
if (names[i].localeCompare(names[i + 1]) > 0) {
return false;
}
}
return true;
};
// Verify that whatever speakers ARE visible maintain alphabetical order
// This is the key test - when the same speakers alternate, visible speakers stay in alphabetical order
expect(isAlphabeticallySorted(eveOrder)).toBe(true);
expect(isAlphabeticallySorted(frankOrder)).toBe(true);
expect(isAlphabeticallySorted(zoeOrder)).toBe(true);
// Additionally verify order consistency: if multiple speakers are visible in multiple states,
// their relative order should be the same
// For example, if Eve and Frank are both visible when Zoe speaks, they should be [Eve, Frank]
if (eveOrder.includes('Frank') && frankOrder.includes('Eve')) {
// Both Eve and Frank visible in both states
const eveAndFrankInEveState = eveOrder.filter(n => [ 'Eve', 'Frank' ].includes(n));
const eveAndFrankInFrankState = frankOrder.filter(n => [ 'Eve', 'Frank' ].includes(n));
expect(eveAndFrankInEveState).toEqual(eveAndFrankInFrankState);
}
await p1.log('Filmstrip maintains alphabetical ordering of visible speakers when dominant speaker changes');
await hangupAllParticipants();
});
});
@@ -95,3 +399,81 @@ async function assertOneDominantSpeaker(participant: Participant) {
expect(await participant.driver.$$(
'//span[not(contains(@class, "tile-view"))]//span[contains(@class,"dominant-speaker")]').length).toBe(1);
}
/**
* Wait for a participant to be detected as the dominant speaker.
*
* @param {Participant} observer - The participant observing the dominant speaker state.
* @param {string} participantId - The endpoint ID of the expected dominant speaker.
* @param {string} participantName - The name of the participant for logging.
*/
async function waitForDominantSpeaker(
observer: Participant,
participantId: string,
participantName: string
): Promise<void> {
await observer.driver.waitUntil(
async () => {
const state = await observer.execute(() => {
const participants = APP.store.getState()['features/base/participants'];
return participants.dominantSpeaker;
});
return state === participantId;
},
{
timeout: 10000,
timeoutMsg: `${participantName} (${participantId}) was not detected as dominant speaker within 10 seconds`
}
);
// Wait a bit more for filmstrip state to update after dominant speaker changes
await observer.driver.pause(1000);
}
/**
* Get the current filmstrip state from Redux.
*
* @param {Participant} participant - The participant to query.
* @returns {Promise<FilmstripState>} The filmstrip state.
*/
async function getFilmstripState(participant: Participant): Promise<{
dominantSpeaker: string | null;
remoteParticipants: string[];
visibleRemoteParticipants: string[];
}> {
return await participant.execute(() => {
const state = APP.store.getState();
const filmstrip = state['features/filmstrip'];
const participants = state['features/base/participants'];
return {
dominantSpeaker: participants.dominantSpeaker,
remoteParticipants: filmstrip.remoteParticipants,
visibleRemoteParticipants: Array.from(filmstrip.visibleRemoteParticipants)
};
});
}
/**
* Set display names for all 6 participants to create alphabetical ordering.
*/
async function setAlphabeticalDisplayNames(
p1: Participant,
p2: Participant,
p3: Participant,
p4: Participant,
p5: Participant,
p6: Participant
): Promise<void> {
await p1.setLocalDisplayName('Alice');
await p2.setLocalDisplayName('Bob');
await p3.setLocalDisplayName('Charlie');
await p4.setLocalDisplayName('Eve');
await p5.setLocalDisplayName('Frank');
await p6.setLocalDisplayName('Zoe');
// Wait for display names to propagate
await p1.driver.pause(2000);
}

View File

@@ -68,7 +68,7 @@ const specs = [
*/
function generateCapabilitiesFromSpecs(): Record<string, any> {
const allSpecFiles: string[] = [];
const browsers = [ 'p1', 'p2', 'p3', 'p4' ];
const browsers = [ 'p1', 'p2', 'p3', 'p4', 'p5', 'p6' ];
for (const pattern of specs) {
const matches = glob.sync(pattern, { cwd: path.join(__dirname) });
@@ -87,7 +87,9 @@ function generateCapabilitiesFromSpecs(): Record<string, any> {
p1: new Set(),
p2: new Set(),
p3: new Set(),
p4: new Set()
p4: new Set(),
p5: new Set(),
p6: new Set()
};
for (const file of allSpecFiles) {