feat(chat): Make chat panel resizeable

This commit is contained in:
Hristo Terezov
2025-05-05 16:47:38 -05:00
parent 8157e528c3
commit 0ee2378ff0
33 changed files with 776 additions and 318 deletions

View File

@@ -141,32 +141,6 @@
left: 0;
}
.smileys-panel {
bottom: 100%;
box-sizing: border-box;
background-color: rgba(0, 0, 0, .6) !important;
height: auto;
display: flex;
overflow: hidden;
position: absolute;
width: calc(#{$sidebarWidth} - 32px);
margin-bottom: 5px;
margin-left: -5px;
/**
* CSS transitions do not apply for auto dimensions. So to produce the css
* accordion effect for showing and hiding the smiley-panel, while allowing
* for variable panel, height, use a very large max-height and animate off
* of that.
*/
transition: max-height 0.3s;
#smileysContainer {
background-color: $chatBackgroundColor;
border-top: 1px solid #A4B8D1;
}
}
#smileysContainer .smiley {
font-size: 1.625rem;
}

View File

@@ -4,9 +4,3 @@
border-radius: 3px;
}
}
.mobile-browser.shift-right {
.participants_pane {
z-index: -1;
}
}

View File

@@ -60,21 +60,3 @@
}
}
}
.desktop-browser {
&.shift-right {
@media only screen and (max-width: $verySmallScreen + $sidebarWidth) {
#videoResolutionLabel {
display: none;
}
.vertical-filmstrip .filmstrip {
display: none;
}
.chrome-extension-banner {
display: none;
}
}
}
}

View File

@@ -22,7 +22,6 @@ $newToolbarSizeWithPadding: calc(#{$newToolbarSize} + 24px);
* Chat
*/
$chatBackgroundColor: #131519;
$sidebarWidth: 315px;
/**
* Misc.

View File

@@ -91,15 +91,3 @@
}
}
}
.shift-right .remote-videos > div {
/**
* Max-width corresponding to the ASPECT_RATIO_BREAKPOINT from features/filmstrip/constants,
* from which we subtract the chat size.
*/
@media only screen and (max-width: calc(500px + #{$sidebarWidth})) {
video {
object-fit: cover;
}
}
}

View File

@@ -478,9 +478,11 @@ export default class LargeVideoManager {
if (isOpen && window.innerWidth > 580) {
/**
* If chat state is open, we re-compute the container width
* by subtracting the default width of the chat.
* by subtracting the chat width, which may be resized by the user.
*/
widthToUse -= CHAT_SIZE;
const chatWidth = state['features/chat'].width?.current ?? CHAT_SIZE;
widthToUse -= chatWidth;
}
if (resizableFilmstrip && visible && filmstripWidth.current >= FILMSTRIP_BREAKPOINT) {

View File

@@ -2,8 +2,7 @@ import { batch } from 'react-redux';
import { IStore } from '../../app/types';
import { CHAT_SIZE } from '../../chat/constants';
import { getParticipantsPaneOpen } from '../../participants-pane/functions';
import theme from '../components/themes/participantsPaneTheme.json';
import { getParticipantsPaneWidth } from '../../participants-pane/functions';
import {
CLIENT_RESIZED,
@@ -43,16 +42,13 @@ export function clientResized(clientWidth: number, clientHeight: number) {
if (navigator.product !== 'ReactNative') {
const state = getState();
const { isOpen: isChatOpen } = state['features/chat'];
const isParticipantsPaneOpen = getParticipantsPaneOpen(state);
const { isOpen: isChatOpen, width } = state['features/chat'];
if (isChatOpen) {
availableWidth -= CHAT_SIZE;
availableWidth -= width?.current ?? CHAT_SIZE;
}
if (isParticipantsPaneOpen) {
availableWidth -= theme.participantsPaneWidth;
}
availableWidth -= getParticipantsPaneWidth(state);
}
batch(() => {

View File

@@ -18,3 +18,11 @@ export const ASPECT_RATIO_WIDE = Symbol('ASPECT_RATIO_WIDE');
* Smallest supported mobile width.
*/
export const SMALL_MOBILE_WIDTH = '320';
/**
* The width for desktop that we start hiding elements from the UI (video quality label, filmstrip, etc).
* This should match the value for $verySmallScreen in _variables.scss.
*
* @type {number}
*/
export const SMALL_DESKTOP_WIDTH = 500;

View File

@@ -0,0 +1,20 @@
import { IStateful } from '../app/types';
import { isMobileBrowser } from '../environment/utils';
import { toState } from '../redux/functions';
import { SMALL_DESKTOP_WIDTH } from './constants';
/**
* Determines if the screen is narrow with the chat panel open. If the function returns true video quality label,
* filmstrip, etc will be hidden.
*
* @param {IStateful} stateful - The stateful object representing the application state.
* @returns {boolean} - True if the screen is narrow with the chat panel open, otherwise `false`.
*/
export function isNarrowScreenWithChatOpen(stateful: IStateful) {
const state = toState(stateful);
const isDesktopBrowser = !isMobileBrowser();
const { isOpen, width } = state['features/chat'];
const { clientWidth } = state['features/base/responsive-ui'];
return isDesktopBrowser && isOpen && (width?.current + SMALL_DESKTOP_WIDTH) > clientWidth;
}

View File

@@ -115,23 +115,51 @@ export const SET_FOCUSED_TAB = 'SET_FOCUSED_TAB';
* type: SET_LOBBY_CHAT_RECIPIENT
* }
*/
export const SET_LOBBY_CHAT_RECIPIENT = 'SET_LOBBY_CHAT_RECIPIENT';
export const SET_LOBBY_CHAT_RECIPIENT = 'SET_LOBBY_CHAT_RECIPIENT';
/**
* The type of action sets the state of lobby messaging status.
*
* {
* type: SET_LOBBY_CHAT_ACTIVE_STATE
* payload: boolean
* }
*/
export const SET_LOBBY_CHAT_ACTIVE_STATE = 'SET_LOBBY_CHAT_ACTIVE_STATE';
/**
* The type of action sets the state of lobby messaging status.
*
* {
* type: SET_LOBBY_CHAT_ACTIVE_STATE
* payload: boolean
* }
*/
export const SET_LOBBY_CHAT_ACTIVE_STATE = 'SET_LOBBY_CHAT_ACTIVE_STATE';
/**
* The type of action removes the lobby messaging from participant.
*
* {
* type: REMOVE_LOBBY_CHAT_PARTICIPANT
* }
*/
export const REMOVE_LOBBY_CHAT_PARTICIPANT = 'REMOVE_LOBBY_CHAT_PARTICIPANT';
/**
* The type of action removes the lobby messaging from participant.
*
* {
* type: REMOVE_LOBBY_CHAT_PARTICIPANT
* }
*/
export const REMOVE_LOBBY_CHAT_PARTICIPANT = 'REMOVE_LOBBY_CHAT_PARTICIPANT';
/**
* The type of action which signals to set the width of the chat panel.
*
* {
* type: SET_CHAT_WIDTH,
* width: number
* }
*/
export const SET_CHAT_WIDTH = 'SET_CHAT_WIDTH';
/**
* The type of action which sets the width for the chat panel (user resized).
* {
* type: SET_USER_CHAT_WIDTH,
* width: number
* }
*/
export const SET_USER_CHAT_WIDTH = 'SET_USER_CHAT_WIDTH';
/**
* The type of action which sets whether the user is resizing the chat panel or not.
* {
* type: SET_CHAT_IS_RESIZING,
* resizing: boolean
* }
*/
export const SET_CHAT_IS_RESIZING = 'SET_CHAT_IS_RESIZING';

View File

@@ -2,7 +2,12 @@
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { IStore } from '../app/types';
import { OPEN_CHAT } from './actionTypes';
import {
OPEN_CHAT,
SET_CHAT_IS_RESIZING,
SET_CHAT_WIDTH,
SET_USER_CHAT_WIDTH
} from './actionTypes';
import { closeChat } from './actions.any';
export * from './actions.any';
@@ -45,3 +50,48 @@ export function toggleChat() {
VideoLayout.onResize();
};
}
/**
* Sets the chat panel's width.
*
* @param {number} width - The new width of the chat panel.
* @returns {{
* type: SET_CHAT_WIDTH,
* width: number
* }}
*/
export function setChatWidth(width: number) {
return {
type: SET_CHAT_WIDTH,
width
};
}
/**
* Sets the chat panel's width and the user preferred width.
*
* @param {number} width - The new width of the chat panel.
* @returns {{
* type: SET_USER_CHAT_WIDTH,
* width: number
* }}
*/
export function setUserChatWidth(width: number) {
return {
type: SET_USER_CHAT_WIDTH,
width
};
}
/**
* Sets whether the user is resizing the chat panel or not.
*
* @param {boolean} resizing - Whether the user is resizing or not.
* @returns {Object}
*/
export function setChatIsResizing(resizing: boolean) {
return {
type: SET_CHAT_IS_RESIZING,
resizing
};
}

View File

@@ -1,16 +1,16 @@
import React, { useCallback } from 'react';
import { connect } from 'react-redux';
import React, { useCallback, useEffect, useState } from 'react';
import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { throttle } from 'lodash-es';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { getLocalParticipant } from '../../../base/participants/functions';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import Tabs from '../../../base/ui/components/web/Tabs';
import { arePollsDisabled } from '../../../conference/functions.any';
import PollsPane from '../../../polls/components/web/PollsPane';
import { isCCTabEnabled } from '../../../subtitles/functions.any';
import { sendMessage, setFocusedTab, toggleChat } from '../../actions.web';
import { setChatIsResizing, setUserChatWidth, sendMessage, setFocusedTab, toggleChat } from '../../actions.web';
import { CHAT_SIZE, ChatTabs, SMALL_WIDTH_THRESHOLD } from '../../constants';
import { IChatProps as AbstractProps } from '../../types';
@@ -21,6 +21,7 @@ import DisplayNameForm from './DisplayNameForm';
import KeyboardAvoider from './KeyboardAvoider';
import MessageContainer from './MessageContainer';
import MessageRecipient from './MessageRecipient';
import { getChatMaxSize } from '../../functions';
interface IProps extends AbstractProps {
@@ -49,6 +50,11 @@ interface IProps extends AbstractProps {
*/
_isPollsEnabled: boolean;
/**
* Whether the user is currently resizing the chat panel.
*/
_isResizing: boolean;
/**
* Number of unread poll messages.
*/
@@ -84,19 +90,30 @@ interface IProps extends AbstractProps {
* Whether or not to block chat access with a nickname input form.
*/
_showNamePrompt: boolean;
/**
* The current width of the chat panel.
*/
_width: number;
}
const useStyles = makeStyles()(theme => {
const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme, { _isResizing, width }) => {
return {
container: {
backgroundColor: theme.palette.ui01,
flexShrink: 0,
overflow: 'hidden',
position: 'relative',
transition: 'width .16s ease-in-out',
width: `${CHAT_SIZE}px`,
transition: _isResizing ? undefined : 'width .16s ease-in-out',
width: `${width}px`,
zIndex: 300,
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
}
},
'@media (max-width: 580px)': {
height: '100dvh',
position: 'fixed',
@@ -123,7 +140,8 @@ const useStyles = makeStyles()(theme => {
alignItems: 'center',
boxSizing: 'border-box',
color: theme.palette.text01,
...withPixelLineHeight(theme.typography.heading6),
...theme.typography.heading6,
fontWeight: theme.typography.heading6.fontWeight as any,
'.jitsi-icon': {
cursor: 'pointer'
@@ -146,6 +164,48 @@ const useStyles = makeStyles()(theme => {
pollsPanel: {
// extract header + tabs height
height: 'calc(100% - 110px)'
},
resizableChat: {
flex: 1,
display: 'flex',
flexDirection: 'column',
width: '100%'
},
dragHandleContainer: {
height: '100%',
width: '9px',
backgroundColor: 'transparent',
position: 'absolute',
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden',
right: '4px',
top: 0,
'&:hover': {
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.visible': {
visibility: 'visible',
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
}
},
dragHandle: {
backgroundColor: theme.palette.icon02,
height: '100px',
width: '3px',
borderRadius: '1px'
}
};
});
@@ -156,6 +216,7 @@ const Chat = ({
_isPollsEnabled,
_isCCTabEnabled,
_focusedTab,
_isResizing,
_messages,
_nbUnreadMessages,
_nbUnreadPolls,
@@ -164,10 +225,100 @@ const Chat = ({
_onToggleChatTab,
_onTogglePollsTab,
_showNamePrompt,
_width,
dispatch,
t
}: IProps) => {
const { classes, cx } = useStyles();
const { classes, cx } = useStyles({ _isResizing, width: _width });
const [ isMouseDown, setIsMouseDown ] = useState(false);
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
const [ dragChatWidth, setDragChatWidth ] = useState<number | null>(null);
const maxChatWidth = useSelector(getChatMaxSize);
/**
* Handles mouse down on the drag handle.
*
* @param {MouseEvent} e - The mouse down event.
* @returns {void}
*/
const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Store the initial mouse position and chat width
setIsMouseDown(true);
setMousePosition(e.clientX);
setDragChatWidth(_width);
// Indicate that resizing is in progress
dispatch(setChatIsResizing(true));
// Add visual feedback that we're dragging
document.body.style.cursor = 'col-resize';
// Disable text selection during resize
document.body.style.userSelect = 'none';
console.log('Chat resize: Mouse down', { clientX: e.clientX, initialWidth: _width });
}, [ _width, dispatch ]);
/**
* Drag handle mouse up handler.
*
* @returns {void}
*/
const onDragMouseUp = useCallback(() => {
if (isMouseDown) {
setIsMouseDown(false);
dispatch(setChatIsResizing(false));
// Restore cursor and text selection
document.body.style.cursor = '';
document.body.style.userSelect = '';
console.log('Chat resize: Mouse up');
}
}, [ isMouseDown, dispatch ]);
/**
* Handles drag handle mouse move.
*
* @param {MouseEvent} e - The mousemove event.
* @returns {void}
*/
const onChatResize = useCallback(throttle((e: MouseEvent) => {
// console.log('Chat resize: Mouse move', { clientX: e.clientX, isMouseDown, mousePosition, _width });
if (isMouseDown && mousePosition !== null && dragChatWidth !== null) {
// For chat panel resizing on the left edge:
// - Dragging left (decreasing X coordinate) should make the panel wider
// - Dragging right (increasing X coordinate) should make the panel narrower
const diff = e.clientX - mousePosition;
const newWidth = Math.max(
Math.min(dragChatWidth + diff, maxChatWidth),
CHAT_SIZE
);
// Update the width only if it has changed
if (newWidth !== _width) {
dispatch(setUserChatWidth(newWidth));
}
}
}, 50, {
leading: true,
trailing: false
}), [ isMouseDown, mousePosition, dragChatWidth, _width, maxChatWidth, dispatch ]);
// Set up event listeners when component mounts
useEffect(() => {
document.addEventListener('mouseup', onDragMouseUp);
document.addEventListener('mousemove', onChatResize);
return () => {
document.removeEventListener('mouseup', onDragMouseUp);
document.removeEventListener('mousemove', onChatResize);
};
}, [ onDragMouseUp, onChatResize ]);
/**
* Sends a text message.
@@ -329,6 +480,15 @@ const Chat = ({
isCCTabEnabled = { _isCCTabEnabled }
isPollsEnabled = { _isPollsEnabled } />
: renderChat()}
<div
className = { cx(
classes.dragHandleContainer,
(isMouseDown || _isResizing) && 'visible',
'dragHandleContainer'
) }
onMouseDown = { onDragHandleMouseDown }>
<div className = { cx(classes.dragHandle, 'dragHandle') } />
</div>
</div> : null
);
};
@@ -349,11 +509,13 @@ const Chat = ({
* _messages: Array<Object>,
* _nbUnreadMessages: number,
* _nbUnreadPolls: number,
* _showNamePrompt: boolean
* _showNamePrompt: boolean,
* _width: number,
* _isResizing: boolean
* }}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { isOpen, focusedTab, messages, nbUnreadMessages } = state['features/chat'];
const { isOpen, focusedTab, messages, nbUnreadMessages, width, isResizing } = state['features/chat'];
const { nbUnreadPolls } = state['features/polls'];
const _localParticipant = getLocalParticipant(state);
@@ -366,7 +528,9 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) {
_messages: messages,
_nbUnreadMessages: nbUnreadMessages,
_nbUnreadPolls: nbUnreadPolls,
_showNamePrompt: !_localParticipant?.name
_showNamePrompt: !_localParticipant?.name,
_width: width?.current || CHAT_SIZE,
_isResizing: isResizing
};
}

View File

@@ -1,6 +1,8 @@
import { Theme } from '@mui/material';
import React, { Component, RefObject } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
@@ -11,6 +13,31 @@ import Input from '../../../base/ui/components/web/Input';
import { areSmileysDisabled, isSendGroupChatDisabled } from '../../functions';
import SmileysPanel from './SmileysPanel';
import { CHAT_SIZE } from '../../constants';
const styles = (_theme: Theme, { _chatWidth }: IProps) => {
return {
smileysPanel: {
bottom: '100%',
boxSizing: 'border-box' as const,
backgroundColor: 'rgba(0, 0, 0, .6) !important',
height: 'auto',
display: 'flex' as const,
overflow: 'hidden',
position: 'absolute' as const,
width: `${_chatWidth - 32}px`,
marginBottom: '5px',
marginLeft: '-5px',
transition: 'max-height 0.3s',
'& #smileysContainer': {
backgroundColor: '#131519',
borderTop: '1px solid #A4B8D1'
}
}
};
};
/**
* The type of the React {@code Component} props of {@link ChatInput}.
@@ -22,6 +49,9 @@ interface IProps extends WithTranslation {
*/
_areSmileysDisabled: boolean;
_chatWidth: number;
/**
* Whether sending group chat messages is disabled.
*/
@@ -32,6 +62,11 @@ interface IProps extends WithTranslation {
*/
_privateMessageRecipientId?: string;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* Invoked to send chat messages.
*/
@@ -123,6 +158,9 @@ class ChatInput extends Component<IProps, IState> {
* @returns {ReactElement}
*/
override render() {
const classes = withStyles.getClasses(this.props);
return (
<div className = { `chat-input-container${this.state.message.trim().length ? ' populated' : ''}` }>
<div id = 'chat-input' >
@@ -130,7 +168,7 @@ class ChatInput extends Component<IProps, IState> {
<div
className = 'smiley-input'>
<div
className = 'smileys-panel' >
className = { classes.smileysPanel } >
<SmileysPanel
onSmileySelect = { this._onSmileySelect } />
</div>
@@ -291,14 +329,15 @@ class ChatInput extends Component<IProps, IState> {
* }}
*/
const mapStateToProps = (state: IReduxState) => {
const { privateMessageRecipient } = state['features/chat'];
const { privateMessageRecipient, width } = state['features/chat'];
const isGroupChatDisabled = isSendGroupChatDisabled(state);
return {
_areSmileysDisabled: areSmileysDisabled(state),
_privateMessageRecipientId: privateMessageRecipient?.id,
_isSendGroupChatDisabled: isGroupChatDisabled
_isSendGroupChatDisabled: isGroupChatDisabled,
_chatWidth: width.current ?? CHAT_SIZE,
};
};
export default translate(connect(mapStateToProps)(ChatInput));
export default translate(connect(mapStateToProps)(withStyles(ChatInput, styles)));

View File

@@ -4,7 +4,7 @@
export const CHAR_LIMIT = 500;
/**
* The size of the chat. Equal to $sidebarWidth SCSS variable.
* The initial size of the chat.
*/
export const CHAT_SIZE = 315;

View File

@@ -14,6 +14,8 @@ import { escapeRegexp } from '../base/util/helpers';
import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants';
import { IMessage } from './types';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
/**
* An ASCII emoticon regexp array to find and replace old-style ASCII
@@ -209,3 +211,17 @@ export function isSendGroupChatDisabled(state: IReduxState) {
return !isJwtFeatureEnabled(state, MEET_FEATURES.SEND_GROUPCHAT, false);
}
/**
* Calculates the maximum width available for the chat panel based on the current window size
* and other UI elements.
*
* @param {IReduxState} state - The Redux state containing the application's current state.
* @returns {number} The maximum width in pixels available for the chat panel. Returns 0 if there
* is no space available.
*/
export function getChatMaxSize(state: IReduxState) {
const { clientWidth } = state['features/base/responsive-ui'];
return Math.max(clientWidth - getParticipantsPaneWidth(state) - VIDEO_SPACE_MIN_SIZE, 0);
}

View File

@@ -34,6 +34,8 @@ import { ENDPOINT_REACTION_NAME } from '../reactions/constants';
import { getReactionMessageFromBuffer, isReactionsEnabled } from '../reactions/functions.any';
import { showToolbox } from '../toolbox/actions';
import './subscriber';
import {
ADD_MESSAGE,
CLOSE_CHAT,

View File

@@ -10,11 +10,15 @@ import {
EDIT_MESSAGE,
OPEN_CHAT,
REMOVE_LOBBY_CHAT_PARTICIPANT,
SET_CHAT_IS_RESIZING,
SET_CHAT_WIDTH,
SET_FOCUSED_TAB,
SET_LOBBY_CHAT_ACTIVE_STATE,
SET_LOBBY_CHAT_RECIPIENT,
SET_PRIVATE_MESSAGE_RECIPIENT,
SET_FOCUSED_TAB
SET_USER_CHAT_WIDTH
} from './actionTypes';
import { CHAT_SIZE } from './constants';
import { IMessage } from './types';
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
@@ -27,7 +31,12 @@ const DEFAULT_STATE = {
privateMessageRecipient: undefined,
lobbyMessageRecipient: undefined,
isLobbyChatActive: false,
focusedTab: ChatTabs.CHAT
focusedTab: ChatTabs.CHAT,
isResizing: false,
width: {
current: CHAT_SIZE,
userSet: null
}
};
export interface IChatState {
@@ -35,6 +44,7 @@ export interface IChatState {
groupChatWithPermissions: boolean;
isLobbyChatActive: boolean;
isOpen: boolean;
isResizing: boolean;
lastReadMessage?: IMessage;
lobbyMessageRecipient?: {
id: string;
@@ -43,6 +53,10 @@ export interface IChatState {
messages: IMessage[];
nbUnreadMessages: number;
privateMessageRecipient?: IParticipant;
width: {
current: number;
userSet: number | null;
};
}
ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, action): IChatState => {
@@ -217,6 +231,35 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
focusedTab: action.tabId,
nbUnreadMessages: action.tabId === ChatTabs.CHAT ? 0 : state.nbUnreadMessages
};
case SET_CHAT_WIDTH: {
return {
...state,
width: {
...state.width,
current: action.width
}
};
}
case SET_USER_CHAT_WIDTH: {
const { width } = action;
return {
...state,
width: {
current: width,
userSet: width
}
};
}
case SET_CHAT_IS_RESIZING: {
return {
...state,
isResizing: action.resizing
};
}
}
return state;

View File

View File

@@ -0,0 +1,72 @@
// @ts-ignore
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { clientResized } from '../base/responsive-ui/actions';
import { setChatWidth } from './actions.web';
import { CHAT_SIZE } from './constants';
import { getChatMaxSize } from './functions';
// import { setChatWidth } from './actions.web';
interface IListenerState {
clientWidth: number;
isOpen: boolean;
maxWidth: number;
width: {
current: number;
userSet: number | null;
};
}
/**
* Listens for changes in the client width to determine when to resize the chat panel.
*/
StateListenerRegistry.register(
/* selector */ state => {
return {
clientWidth: state['features/base/responsive-ui']?.clientWidth,
isOpen: state['features/chat'].isOpen,
width: state['features/chat'].width,
maxWidth: getChatMaxSize(state)
};
},
/* listener */ (
currentState: IListenerState,
{ dispatch },
previousState: IListenerState
) => {
if (currentState.isOpen
&& (currentState.clientWidth !== previousState.clientWidth
|| currentState.width !== previousState.width)) {
const { userSet = 0 } = currentState.width;
const { maxWidth } = currentState;
let chatPanelWidthChanged = false;
if (currentState.clientWidth !== previousState.clientWidth) {
if (userSet !== null) {
// if userSet is set, we need to check if it is within the bounds and potentially adjust it.
// This is in the case when screen gets smaller and the user set width is more than the maxWidth
// and we need to set it to the maxWidth. And also when the user set width has been larger than
// the maxWidth and we have reduced the current width to the maxWidth but now the screen gets bigger
// and we can increase the current width.
dispatch(setChatWidth(Math.max(Math.min(maxWidth, userSet), CHAT_SIZE)));
chatPanelWidthChanged = true;
} // else { // when userSet is null:
// no-op. The chat panel width will be the default one which is the min too.
// }
} else { // currentState.width !== previousState.width
chatPanelWidthChanged = true;
}
if (chatPanelWidthChanged) {
const { innerWidth, innerHeight } = window;
// Since the videoSpaceWidth relies on the chat panel width, we need to adjust it when the chat panel size changes
dispatch(clientResized(innerWidth, innerHeight));
}
// Recompute the large video size when chat is open and window resizes
VideoLayout.onResize();
}
});

View File

@@ -19,6 +19,7 @@ import { IconCloseLarge } from '../../base/icons/svg';
import { browser } from '../../base/lib-jitsi-meet';
import { isVpaasMeeting } from '../../jaas/functions';
import logger from '../logger';
import { isNarrowScreenWithChatOpen } from '../../base/responsive-ui/functions';
const emptyObject = {};
@@ -57,6 +58,11 @@ interface IProps extends WithTranslation {
* Whether it's a vpaas meeting or not.
*/
isVpaas: boolean;
/**
* Whether the screen is too small to show the banner or not.
*/
notEnoughAvailableWidth: boolean;
}
/**
@@ -253,6 +259,11 @@ class ChromeExtensionBanner extends PureComponent<IProps, IState> {
return null;
}
if (!this.props.notEnoughAvailableWidth) {
return null;
}
const { bannerCfg, t } = this.props;
const mainClassNames = this.props.conference
? 'chrome-extension-banner chrome-extension-banner__pos_in_meeting'
@@ -333,7 +344,8 @@ const _mapStateToProps = (state: IReduxState) => {
bannerCfg: state['features/base/config'].chromeExtensionBanner || emptyObject,
conference: getCurrentConference(state),
iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
isVpaas: isVpaasMeeting(state)
isVpaas: isVpaasMeeting(state),
notEnoughAvailableWidth: isNarrowScreenWithChatOpen(state)
};
};

View File

@@ -47,12 +47,204 @@ import {
import AudioTracksContainer from './AudioTracksContainer';
import Thumbnail from './Thumbnail';
import ThumbnailWrapper from './ThumbnailWrapper';
import { styles } from './styles';
import { Theme } from '@mui/material';
import { isNarrowScreenWithChatOpen } from '../../../base/responsive-ui/functions';
const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current theme.
* @param {IProps} props - The component props.
* @returns {Object}
*/
function styles(theme: Theme, props: IProps) {
const result = {
toggleFilmstripContainer: {
display: 'flex',
flexWrap: 'nowrap' as const,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BACKGROUND_COLOR,
width: '32px',
height: '24px',
position: 'absolute' as const,
borderRadius: '4px',
top: 'calc(-24px - 2px)',
left: 'calc(50% - 16px)',
opacity: 0,
transition: 'opacity .3s',
zIndex: 1,
'&:hover, &:focus-within': {
backgroundColor: theme.palette.ui02
}
},
toggleFilmstripButton: {
fontSize: '14px',
lineHeight: 1.2,
textAlign: 'center' as const,
background: 'transparent',
height: 'auto',
width: '100%',
padding: 0,
margin: 0,
border: 'none',
'-webkit-appearance': 'none',
'& svg': {
fill: theme.palette.icon01
}
},
toggleVerticalFilmstripContainer: {
transform: 'rotate(-90deg)',
left: 'calc(-24px - 2px - 4px)',
top: 'calc(50% - 12px)'
},
toggleTopPanelContainer: {
transform: 'rotate(180deg)',
bottom: 'calc(-24px - 6px)',
top: 'auto'
},
toggleTopPanelContainerHidden: {
visibility: 'hidden' as const
},
filmstrip: {
transition: 'background .2s ease-in-out, right 1s, bottom 1s, top 1s, height .3s ease-in',
right: 0,
bottom: 0,
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
'& .filmstrip-hover': {
backgroundColor: BACKGROUND_COLOR
},
'& .toggleFilmstripContainer': {
opacity: 1
},
'& .dragHandleContainer': {
visibility: 'visible' as const
}
},
'.horizontal-filmstrip &.hidden': {
bottom: '-50px',
'&:hover': {
backgroundColor: 'transparent'
}
},
'&.hidden': {
'& .toggleFilmstripContainer': {
opacity: 1
}
}
},
filmstripBackground: {
backgroundColor: theme.palette.uiBackground,
'&:hover, &:focus-within': {
backgroundColor: theme.palette.uiBackground
}
},
resizableFilmstripContainer: {
display: 'flex',
position: 'relative' as const,
flexDirection: 'row' as const,
alignItems: 'center',
height: '100%',
width: '100%',
transition: 'background .2s ease-in-out' as const,
'& .avatar-container': {
maxWidth: 'initial',
maxHeight: 'initial'
},
'&.top-panel-filmstrip': {
flexDirection: 'column' as const
}
},
dragHandleContainer: {
height: '100%',
width: '9px',
backgroundColor: 'transparent',
position: 'relative' as const,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden' as const,
'&:hover': {
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.visible': {
visibility: 'visible' as const,
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.top-panel': {
order: 2,
width: '100%',
height: '9px',
cursor: 'row-resize',
'& .dragHandle': {
height: '3px',
width: '100px'
}
}
},
dragHandle: {
backgroundColor: theme.palette.icon02,
height: '100px',
width: '3px',
borderRadius: '1px'
}
};
if (props._isNarrowScreenWithChatOpen) {
result.filmstrip = {
...result.filmstrip,
'& .vertical-filmstrip': {
display: 'none' as const
}
} as typeof result.filmstrip;
}
return result;
}
/**
* The type of the React {@code Component} props of {@link Filmstrip}.
*/
interface IProps extends WithTranslation {
export interface IProps extends WithTranslation {
/**
* Additional CSS class names top add to the root.
@@ -104,6 +296,11 @@ interface IProps extends WithTranslation {
*/
_isFilmstripButtonEnabled: boolean;
/**
* Whether the available space is when the chat is open. The filmstrip will be hidden if true.
*/
_isNarrowScreenWithChatOpen: boolean;
/**
* Whether or not the toolbox is displayed.
*/
@@ -384,7 +581,7 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
}
let toolbar = null;
let toolbar: React.ReactNode = null;
if (!this.props._iAmRecorder && this.props._isFilmstripButtonEnabled
&& _currentLayout !== LAYOUTS.TILE_VIEW
@@ -889,7 +1086,6 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
const { localScreenShare } = state['features/base/participants'];
const reduceHeight = state['features/toolbox'].visible && toolbarButtons?.length;
const remoteVideosVisible = shouldRemoteVideosBeVisible(state);
const { isOpen: shiftRight } = state['features/chat'];
const disableSelfView = getHideSelfView(state);
const { videoSpaceWidth, clientHeight } = state['features/base/responsive-ui'];
const filmstripDisabled = isFilmstripDisabled(state);
@@ -910,7 +1106,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
const videosClassName = `filmstrip__videos${isVisible ? '' : ' hidden'}${_hasScroll ? ' has-scroll' : ''}`;
const className = `${remoteVideosVisible || ownProps._verticalViewGrid ? '' : 'hide-videos'} ${
shouldReduceHeight ? 'reduce-height' : ''
} ${shiftRight ? 'shift-right' : ''} ${collapseTileView ? 'collapse' : ''} ${isVisible ? '' : 'hidden'}`.trim();
} ${collapseTileView ? 'collapse' : ''} ${isVisible ? '' : 'hidden'}`.trim();
const _currentLayout = getCurrentLayout(state);
const _isVerticalFilmstrip = _currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW
@@ -925,6 +1121,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
_hasScroll,
_iAmRecorder: Boolean(iAmRecorder),
_isFilmstripButtonEnabled: isButtonEnabled('filmstrip', state),
_isNarrowScreenWithChatOpen: isNarrowScreenWithChatOpen(state),
_isToolboxVisible: isToolboxVisible(state),
_isVerticalFilmstrip,
_localScreenShareId: localScreenShare?.id,

View File

@@ -1,178 +0,0 @@
import { Theme } from '@mui/material';
const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current theme.
* @returns {Object}
*/
export const styles = (theme: Theme) => {
return {
toggleFilmstripContainer: {
display: 'flex',
flexWrap: 'nowrap' as const,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BACKGROUND_COLOR,
width: '32px',
height: '24px',
position: 'absolute' as const,
borderRadius: '4px',
top: 'calc(-24px - 2px)',
left: 'calc(50% - 16px)',
opacity: 0,
transition: 'opacity .3s',
zIndex: 1,
'&:hover, &:focus-within': {
backgroundColor: theme.palette.ui02
}
},
toggleFilmstripButton: {
fontSize: '14px',
lineHeight: 1.2,
textAlign: 'center' as const,
background: 'transparent',
height: 'auto',
width: '100%',
padding: 0,
margin: 0,
border: 'none',
'-webkit-appearance': 'none',
'& svg': {
fill: theme.palette.icon01
}
},
toggleVerticalFilmstripContainer: {
transform: 'rotate(-90deg)',
left: 'calc(-24px - 2px - 4px)',
top: 'calc(50% - 12px)'
},
toggleTopPanelContainer: {
transform: 'rotate(180deg)',
bottom: 'calc(-24px - 6px)',
top: 'auto'
},
toggleTopPanelContainerHidden: {
visibility: 'hidden' as const
},
filmstrip: {
transition: 'background .2s ease-in-out, right 1s, bottom 1s, top 1s, height .3s ease-in',
right: 0,
bottom: 0,
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
'& .filmstrip-hover': {
backgroundColor: BACKGROUND_COLOR
},
'& .toggleFilmstripContainer': {
opacity: 1
},
'& .dragHandleContainer': {
visibility: 'visible' as const
}
},
'.horizontal-filmstrip &.hidden': {
bottom: '-50px',
'&:hover': {
backgroundColor: 'transparent'
}
},
'&.hidden': {
'& .toggleFilmstripContainer': {
opacity: 1
}
}
},
filmstripBackground: {
backgroundColor: theme.palette.uiBackground,
'&:hover, &:focus-within': {
backgroundColor: theme.palette.uiBackground
}
},
resizableFilmstripContainer: {
display: 'flex',
position: 'relative' as const,
flexDirection: 'row' as const,
alignItems: 'center',
height: '100%',
width: '100%',
transition: 'background .2s ease-in-out',
'& .avatar-container': {
maxWidth: 'initial',
maxHeight: 'initial'
},
'&.top-panel-filmstrip': {
flexDirection: 'column'
}
},
dragHandleContainer: {
height: '100%',
width: '9px',
backgroundColor: 'transparent',
position: 'relative' as const,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden' as const,
'&:hover': {
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.visible': {
visibility: 'visible',
'& .dragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.top-panel': {
order: 2,
width: '100%',
height: '9px',
cursor: 'row-resize',
'& .dragHandle': {
height: '3px',
width: '100px'
}
}
},
dragHandle: {
backgroundColor: theme.palette.icon02,
height: '100px',
width: '3px',
borderRadius: '1px'
}
};
};

View File

@@ -105,12 +105,6 @@ StateListenerRegistry.register(
/* listener */ (isChatOpen, store) => {
const { innerWidth, innerHeight } = window;
if (isChatOpen) {
document.body.classList.add('shift-right');
} else {
document.body.classList.remove('shift-right');
}
store.dispatch(clientResized(innerWidth, innerHeight));
});

View File

@@ -209,7 +209,7 @@ class LargeVideo extends Component<IProps> {
_showSubtitles
} = this.props;
const style = this._getCustomStyles();
const className = `videocontainer${_isChatOpen ? ' shift-right' : ''}`;
const className = 'videocontainer';
return (
<div

View File

@@ -27,8 +27,18 @@ import { FooterContextMenu } from './FooterContextMenu';
import LobbyParticipants from './LobbyParticipants';
import MeetingParticipants from './MeetingParticipants';
import VisitorsList from './VisitorsList';
import { isMobileBrowser } from '../../../base/environment/utils';
const useStyles = makeStyles()(theme => {
/**
* Interface representing the properties used for styles.
*
* @property {boolean} [isMobileBrowser] - Indicates whether the application is being accessed from a mobile browser.
* @property {boolean} [isChatOpen] - Specifies whether the chat panel is currently open.
*/
interface IStylesProps {
isChatOpen?: boolean;
}
const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
return {
participantsPane: {
backgroundColor: theme.palette.ui01,
@@ -37,7 +47,7 @@ const useStyles = makeStyles()(theme => {
position: 'relative',
transition: 'width .16s ease-in-out',
width: '315px',
zIndex: 0,
zIndex: isMobileBrowser() && isChatOpen ? -1 : 0,
display: 'flex',
flexDirection: 'column',
fontWeight: 600,
@@ -114,7 +124,8 @@ const useStyles = makeStyles()(theme => {
});
const ParticipantsPane = () => {
const { classes, cx } = useStyles();
const isChatOpen = useSelector((state: IReduxState) => state['features/chat'].isOpen);
const { classes } = useStyles({ isChatOpen });
const paneOpen = useSelector(getParticipantsPaneOpen);
const isBreakoutRoomsSupported = useSelector((state: IReduxState) => state['features/base/conference'])
.conference?.getBreakoutRooms()?.isSupported();
@@ -163,7 +174,9 @@ const ParticipantsPane = () => {
}
return (
<div className = { cx('participants_pane', classes.participantsPane) }>
<div
className = { classes.participantsPane }
id = 'participants-pane'>
<div className = { classes.header }>
<ClickableIcon
accessibilityLabel = { t('participantsPane.close', 'Close') }

View File

@@ -6,6 +6,7 @@ import {
isSupported
} from '../av-moderation/functions';
import { IStateful } from '../base/app/types';
import theme from '../base/components/themes/participantsPaneTheme.json';
import { getCurrentConference } from '../base/conference/functions';
import { INVITE_ENABLED, PARTICIPANTS_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
@@ -320,3 +321,19 @@ export const isParticipantsPaneEnabled = (stateful: IStateful) => {
return Boolean(getFeatureFlag(state, PARTICIPANTS_ENABLED, true) && enabled);
};
/**
* Returns the width of the participants pane based on its open state.
*
* @param {IReduxState} state - The Redux state object containing the application state.
* @returns {number} - The width of the participants pane in pixels when open, or 0 when closed.
*/
export function getParticipantsPaneWidth(state: IReduxState) {
const { isOpen } = getState(state);
if (isOpen) {
return theme.participantsPaneWidth;
}
return 0;
}

View File

@@ -165,6 +165,7 @@ function _mapStateToProps(state: IReduxState) {
const { videoUrl } = state['features/shared-video'];
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const { visible, isResizing } = state['features/filmstrip'];
const { isResizing: isChatResizing } = state['features/chat'];
const onStage = getLargeVideoParticipant(state)?.fakeParticipant === FakeParticipant.SharedVideo;
const isVideoShared = isVideoPlaying(state);
@@ -174,7 +175,7 @@ function _mapStateToProps(state: IReduxState) {
filmstripVisible: visible,
filmstripWidth: getVerticalViewMaxWidth(state),
isEnabled: isSharedVideoEnabled(state),
isResizing,
isResizing: isResizing || isChatResizing,
isVideoShared,
onStage,
videoUrl

View File

@@ -88,7 +88,6 @@ export default function Toolbox({
= useSelector((state: IReduxState) => state['features/toolbox'].buttonsWithNotifyClick);
const reduxToolbarButtons = useSelector((state: IReduxState) => state['features/toolbox'].toolbarButtons);
const toolbarButtonsToUse = toolbarButtons || reduxToolbarButtons;
const chatOpen = useSelector((state: IReduxState) => state['features/chat'].isOpen);
const isDialogVisible = useSelector((state: IReduxState) => Boolean(state['features/base/dialog'].component));
const localParticipant = useSelector(getLocalParticipant);
const transcribing = useSelector(isTranscribing);
@@ -220,7 +219,7 @@ export default function Toolbox({
const rootClassNames = `new-toolbox ${toolbarVisible ? 'visible' : ''} ${
toolbarButtonsToUse.length ? '' : 'no-buttons'} ${chatOpen ? 'shift-right' : ''}`;
toolbarButtonsToUse.length ? '' : 'no-buttons'}`;
const toolbarAccLabel = 'toolbar.accessibilityLabel.moreActionsMenu';
const containerClassName = `toolbox-content${isMobile || isNarrowLayout ? ' toolbox-content-mobile' : ''}`;

View File

@@ -22,3 +22,8 @@ export const LAYOUT_CLASSNAMES = {
[LAYOUTS.VERTICAL_FILMSTRIP_VIEW]: 'vertical-filmstrip',
[LAYOUTS.STAGE_FILMSTRIP_VIEW]: 'stage-filmstrip'
};
/**
* The minimum width of the video space. Used for calculating the maximum chat size.
*/
export const VIDEO_SPACE_MIN_SIZE = 500;

View File

@@ -8,9 +8,9 @@ import { IconPerformance } from '../../base/icons/svg';
import Label from '../../base/label/components/web/Label';
import { COLORS } from '../../base/label/constants';
import Tooltip from '../../base/tooltip/components/Tooltip';
import { shouldDisplayTileView } from '../../video-layout/functions.web';
import VideoQualityDialog from './VideoQualityDialog.web';
import { shouldDisplayVideoQualityLabel } from '../selector';
/**
* React {@code Component} responsible for displaying a label that indicates
@@ -23,8 +23,7 @@ import VideoQualityDialog from './VideoQualityDialog.web';
*/
const VideoQualityLabel = () => {
const _audioOnly = useSelector((state: IReduxState) => state['features/base/audio-only'].enabled);
const _visible = useSelector((state: IReduxState) => !(shouldDisplayTileView(state)
|| interfaceConfig.VIDEO_QUALITY_LABEL_DISABLED));
const _visible = useSelector(shouldDisplayVideoQualityLabel);
const dispatch = useDispatch();
const { t } = useTranslation();

View File

@@ -1,4 +1,6 @@
import { IReduxState } from '../app/types';
import { isNarrowScreenWithChatOpen } from '../base/responsive-ui/functions';
import { shouldDisplayTileView } from '../video-layout/functions.any';
/**
* Selects the thumbnail height to the quality level mapping from the config.
@@ -9,3 +11,21 @@ import { IReduxState } from '../app/types';
export function getMinHeightForQualityLvlMap(state: IReduxState): Map<number, number> {
return state['features/video-quality'].minHeightForQualityLvl;
}
/**
* Determines whether the video quality label should be displayed.
*
* @param {IReduxState} state - The current Redux state of the application.
* @returns {boolean} - True if the video quality label should be displayed, otherwise false.
*/
export function shouldDisplayVideoQualityLabel(state: IReduxState): boolean {
const hideVideoQualityLabel
= shouldDisplayTileView(state)
|| interfaceConfig.VIDEO_QUALITY_LABEL_DISABLED
// Hide the video quality label for desktop browser if the chat is open and there isn't enough space
// to display it.
|| isNarrowScreenWithChatOpen(state);
return !hideVideoQualityLabel;
}

View File

@@ -50,7 +50,9 @@ const Whiteboard = (props: WithTranslation): JSX.Element => {
const isVisible = useSelector(isWhiteboardVisible);
const isInTileView = useSelector(shouldDisplayTileView);
const { clientHeight, videoSpaceWidth } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
const { visible: filmstripVisible, isResizing } = useSelector((state: IReduxState) => state['features/filmstrip']);
const { visible: filmstripVisible, isResizing: isFilmstripResizing } = useSelector((state: IReduxState) => state['features/filmstrip']);
const isChatResizing = useSelector((state: IReduxState) => state['features/chat'].isResizing);
const isResizing = isFilmstripResizing || isChatResizing;
const filmstripWidth: number = useSelector(getVerticalViewMaxWidth);
const collabDetails = useSelector(getCollabDetails);
const collabServerUrl = useSelector(getCollabServerUrl);

View File

@@ -6,9 +6,9 @@ import AVModerationMenu from './AVModerationMenu';
import BasePageObject from './BasePageObject';
/**
* Classname of the closed/hidden participants pane
* ID of the closed/hidden participants pane
*/
const PARTICIPANTS_PANE = 'participants_pane';
const PARTICIPANTS_PANE = 'participants-pane';
const INVITE = 'Invite someone';
@@ -27,7 +27,7 @@ export default class ParticipantsPane extends BasePageObject {
* Checks if the pane is open.
*/
isOpen() {
return this.participant.driver.$(`.${PARTICIPANTS_PANE}`).isExisting();
return this.participant.driver.$(`#${PARTICIPANTS_PANE}`).isExisting();
}
/**
@@ -36,7 +36,7 @@ export default class ParticipantsPane extends BasePageObject {
async open() {
await this.participant.getToolbar().clickParticipantsPaneButton();
const pane = this.participant.driver.$(`.${PARTICIPANTS_PANE}`);
const pane = this.participant.driver.$(`#${PARTICIPANTS_PANE}`);
await pane.waitForExist();
await pane.waitForStable();
@@ -49,7 +49,7 @@ export default class ParticipantsPane extends BasePageObject {
async close() {
await this.participant.getToolbar().clickCloseParticipantsPaneButton();
await this.participant.driver.$(`.${PARTICIPANTS_PANE}`).waitForDisplayed({ reverse: true });
await this.participant.driver.$(`#${PARTICIPANTS_PANE}`).waitForDisplayed({ reverse: true });
}
/**