init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,191 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import {
requestDisableAudioModeration,
requestDisableDesktopModeration,
requestDisableVideoModeration,
requestEnableAudioModeration,
requestEnableDesktopModeration,
requestEnableVideoModeration
} from '../../../av-moderation/actions';
import { MEDIA_TYPE } from '../../../av-moderation/constants';
import {
isEnabled as isAvModerationEnabled,
isSupported as isAvModerationSupported
} from '../../../av-moderation/functions';
import { openDialog } from '../../../base/dialog/actions';
import {
IconCheck,
IconCloseLarge,
IconDotsHorizontal,
IconScreenshare,
IconVideoOff
} from '../../../base/icons/svg';
import { getRaiseHandsQueue } from '../../../base/participants/functions';
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { openSettingsDialog } from '../../../settings/actions.web';
import { SETTINGS_TABS } from '../../../settings/constants';
import { shouldShowModeratorSettings } from '../../../settings/functions.web';
import LowerHandButton from '../../../video-menu/components/web/LowerHandButton';
import MuteEveryonesDesktopDialog from '../../../video-menu/components/web/MuteEveryonesDesktopDialog';
import MuteEveryonesVideoDialog from '../../../video-menu/components/web/MuteEveryonesVideoDialog';
const useStyles = makeStyles()(theme => {
return {
contextMenu: {
bottom: 'auto',
margin: '0',
right: 0,
top: '-8px',
transform: 'translateY(-100%)',
width: '283px'
},
text: {
...theme.typography.bodyShortRegular,
color: theme.palette.text02,
padding: '10px 16px',
height: '40px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box'
},
indentedLabel: {
'& > span': {
marginLeft: '36px'
}
}
};
});
interface IProps {
/**
* Whether the menu is open.
*/
isOpen: boolean;
/**
* Drawer close callback.
*/
onDrawerClose: (e?: React.MouseEvent) => void;
/**
* Callback for the mouse leaving this item.
*/
onMouseLeave?: (e?: React.MouseEvent) => void;
}
export const FooterContextMenu = ({ isOpen, onDrawerClose, onMouseLeave }: IProps) => {
const dispatch = useDispatch();
const isModerationSupported = useSelector((state: IReduxState) => isAvModerationSupported()(state));
const raisedHandsQueue = useSelector(getRaiseHandsQueue);
const isModeratorSettingsTabEnabled = useSelector(shouldShowModeratorSettings);
const isAudioModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
const isDesktopModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.DESKTOP));
const isVideoModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.VIDEO));
const isBreakoutRoom = useSelector(isInBreakoutRoom);
const { t } = useTranslation();
const disableAudioModeration = useCallback(() => dispatch(requestDisableAudioModeration()), [ dispatch ]);
const disableDesktopModeration = useCallback(() => dispatch(requestDisableDesktopModeration()), [ dispatch ]);
const disableVideoModeration = useCallback(() => dispatch(requestDisableVideoModeration()), [ dispatch ]);
const enableAudioModeration = useCallback(() => dispatch(requestEnableAudioModeration()), [ dispatch ]);
const enableDesktopModeration = useCallback(() => dispatch(requestEnableDesktopModeration()), [ dispatch ]);
const enableVideoModeration = useCallback(() => dispatch(requestEnableVideoModeration()), [ dispatch ]);
const { classes } = useStyles();
const muteAllVideo = useCallback(
() => dispatch(openDialog(MuteEveryonesVideoDialog)), [ dispatch ]);
const muteAllDesktop = useCallback(
() => dispatch(openDialog(MuteEveryonesDesktopDialog)), [ dispatch ]);
const openModeratorSettings = () => dispatch(openSettingsDialog(SETTINGS_TABS.MODERATOR));
const actions = [
{
accessibilityLabel: t('participantsPane.actions.audioModeration'),
className: isAudioModerationEnabled ? classes.indentedLabel : '',
id: isAudioModerationEnabled
? 'participants-pane-context-menu-stop-audio-moderation'
: 'participants-pane-context-menu-start-audio-moderation',
icon: isAudioModerationEnabled ? IconCloseLarge : IconCheck,
onClick: isAudioModerationEnabled ? disableAudioModeration : enableAudioModeration,
text: t('participantsPane.actions.audioModeration')
}, {
accessibilityLabel: t('participantsPane.actions.videoModeration'),
className: isVideoModerationEnabled ? classes.indentedLabel : '',
id: isVideoModerationEnabled
? 'participants-pane-context-menu-stop-video-moderation'
: 'participants-pane-context-menu-start-video-moderation',
icon: isVideoModerationEnabled ? IconCloseLarge : IconCheck,
onClick: isVideoModerationEnabled ? disableVideoModeration : enableVideoModeration,
text: t('participantsPane.actions.videoModeration')
}, {
accessibilityLabel: t('participantsPane.actions.desktopModeration'),
className: isDesktopModerationEnabled ? classes.indentedLabel : '',
id: isDesktopModerationEnabled
? 'participants-pane-context-menu-stop-desktop-moderation'
: 'participants-pane-context-menu-start-desktop-moderation',
icon: isDesktopModerationEnabled ? IconCloseLarge : IconCheck,
onClick: isDesktopModerationEnabled ? disableDesktopModeration : enableDesktopModeration,
text: t('participantsPane.actions.desktopModeration')
}
];
return (
<ContextMenu
activateFocusTrap = { true }
className = { classes.contextMenu }
hidden = { !isOpen }
isDrawerOpen = { isOpen }
onDrawerClose = { onDrawerClose }
onMouseLeave = { onMouseLeave }>
<ContextMenuItemGroup
actions = { [
{
accessibilityLabel: t('participantsPane.actions.stopEveryonesVideo'),
id: 'participants-pane-context-menu-stop-video',
icon: IconVideoOff,
onClick: muteAllVideo,
text: t('participantsPane.actions.stopEveryonesVideo')
},
{
accessibilityLabel: t('participantsPane.actions.stopEveryonesDesktop'),
id: 'participants-pane-context-menu-stop-desktop',
icon: IconScreenshare,
onClick: muteAllDesktop,
text: t('participantsPane.actions.stopEveryonesDesktop')
}
] } />
{raisedHandsQueue.length !== 0 && <LowerHandButton />}
{!isBreakoutRoom && isModerationSupported && (
<ContextMenuItemGroup actions = { actions }>
<div className = { classes.text }>
<span>{t('participantsPane.actions.allow')}</span>
</div>
</ContextMenuItemGroup>
)}
{isModeratorSettingsTabEnabled && (
<ContextMenuItemGroup
actions = { [ {
accessibilityLabel: t('participantsPane.actions.moreModerationControls'),
id: 'participants-pane-open-moderation-control-settings',
icon: IconDotsHorizontal,
onClick: openModeratorSettings,
text: t('participantsPane.actions.moreModerationControls')
} ] } />
)}
</ContextMenu>
);
};

View File

@@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { IconAddUser } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { beginAddPeople } from '../../../invite/actions';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
const INVITE_BUTTON_KEY = 'invite';
export const InviteButton = () => {
const dispatch = useDispatch();
const { t } = useTranslation();
const notifyMode = useSelector(
(state: IReduxState) => state['features/toolbox'].buttonsWithNotifyClick?.get(INVITE_BUTTON_KEY));
const onInvite = useCallback(() => {
if (notifyMode) {
APP.API.notifyToolbarButtonClicked(
INVITE_BUTTON_KEY, notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY
);
}
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createToolbarEvent(INVITE_BUTTON_KEY));
dispatch(beginAddPeople());
}, [ dispatch, notifyMode ]);
return (
<Button
accessibilityLabel = { t('participantsPane.actions.invite') }
fullWidth = { true }
icon = { IconAddUser }
labelKey = { 'participantsPane.actions.invite' }
onClick = { onInvite }
type = { BUTTON_TYPES.PRIMARY } />
);
};

View File

@@ -0,0 +1,140 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IconDotsHorizontal, IconMessage, IconUserDeleted } from '../../../base/icons/svg';
import { hasRaisedHand } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import Button from '../../../base/ui/components/web/Button';
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { showLobbyChatButton } from '../../../lobby/functions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import { useLobbyActions } from '../../hooks';
import ParticipantItem from './ParticipantItem';
interface IProps {
/**
* Callback used to open a drawer with admit/reject actions.
*/
openDrawerForParticipant: Function;
/**
* If an overflow drawer should be displayed.
*/
overflowDrawer: boolean;
/**
* Participant reference.
*/
participant: IParticipant;
}
const useStyles = makeStyles()(theme => {
return {
button: {
marginRight: theme.spacing(2)
},
moreButton: {
paddingRight: '6px',
paddingLeft: '6px',
marginRight: theme.spacing(2)
},
contextMenu: {
position: 'fixed',
top: 'auto',
marginRight: '8px'
}
};
});
export const LobbyParticipantItem = ({
overflowDrawer,
participant: p,
openDrawerForParticipant
}: IProps) => {
const { id } = p;
const [ admit, reject, chat ] = useLobbyActions({ participantID: id });
const { t } = useTranslation();
const [ isOpen, setIsOpen ] = useState(false);
const { classes: styles } = useStyles();
const showChat = useSelector(showLobbyChatButton(p));
const moreButtonRef = useRef();
const openContextMenu = useCallback(() => setIsOpen(true), []);
const closeContextMenu = useCallback(() => setIsOpen(false), []);
const renderAdmitButton = () => (
<Button
accessibilityLabel = { `${t('participantsPane.actions.admit')} ${p.name}` }
className = { styles.button }
labelKey = { 'participantsPane.actions.admit' }
onClick = { admit }
size = 'small'
testId = { `admit-${id}` } />);
return (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.PERMANENT }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { p.name }
local = { p.local }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantID = { id }
raisedHand = { hasRaisedHand(p) }
videoMediaState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }>
{showChat ? <>
{renderAdmitButton()}
<Button
accessibilityLabel = { `${t('participantsPane.actions.moreModerationActions')} ${p.name}` }
className = { styles.moreButton }
icon = { IconDotsHorizontal }
onClick = { openContextMenu }
ref = { moreButtonRef }
size = 'small' />
<ContextMenu
className = { styles.contextMenu }
hidden = { !isOpen }
offsetTarget = { moreButtonRef.current }
onMouseLeave = { closeContextMenu }>
<ContextMenuItemGroup
actions = { [ {
accessibilityLabel: `${t('lobby.chat')} ${p.name}`,
onClick: chat,
testId: `lobby-chat-${id}`,
icon: IconMessage,
text: t('lobby.chat')
} ] } />
<ContextMenuItemGroup
actions = { [ {
accessibilityLabel: `${t('participantsPane.actions.reject')} ${p.name}`,
onClick: reject,
testId: `reject-${id}`,
icon: IconUserDeleted,
text: t('participantsPane.actions.reject')
} ] } />
</ContextMenu>
</> : <>
<Button
accessibilityLabel = { `${t('participantsPane.actions.reject')} ${p.name}` }
className = { styles.button }
labelKey = { 'participantsPane.actions.reject' }
onClick = { reject }
size = 'small'
testId = { `reject-${id}` }
type = { BUTTON_TYPES.DESTRUCTIVE } />
{renderAdmitButton()}
</>
}
</ParticipantItem>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import { IParticipant } from '../../../base/participants/types';
import { LobbyParticipantItem } from './LobbyParticipantItem';
interface IProps {
/**
* Opens a drawer with actions for a knocking participant.
*/
openDrawerForParticipant: Function;
/**
* If a drawer with actions should be displayed.
*/
overflowDrawer: boolean;
/**
* List with the knocking participants.
*/
participants: IParticipant[];
}
const useStyles = makeStyles()(theme => {
return {
container: {
margin: `${theme.spacing(3)} 0`
}
};
});
/**
* Component used to display a list of knocking participants.
*
* @param {Object} props - The props of the component.
* @returns {ReactNode}
*/
function LobbyParticipantItems({ openDrawerForParticipant, overflowDrawer, participants }: IProps) {
const { classes } = useStyles();
return (
<div
className = { classes.container }
id = 'lobby-list'>
{participants.map(p => (
<LobbyParticipantItem
key = { p.id }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participant = { p } />)
)}
</div>
);
}
// Memoize the component in order to avoid rerender on drawer open/close.
export default React.memo<IProps>(LobbyParticipantItems);

View File

@@ -0,0 +1,135 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Avatar from '../../../base/avatar/components/Avatar';
import Icon from '../../../base/icons/components/Icon';
import { IconCheck, IconCloseLarge } from '../../../base/icons/svg';
import { admitMultiple } from '../../../lobby/actions.web';
import { getKnockingParticipants, getLobbyEnabled } from '../../../lobby/functions';
import Drawer from '../../../toolbox/components/web/Drawer';
import JitsiPortal from '../../../toolbox/components/web/JitsiPortal';
import { showOverflowDrawer } from '../../../toolbox/functions.web';
import { useLobbyActions, useParticipantDrawer } from '../../hooks';
import LobbyParticipantItems from './LobbyParticipantItems';
const useStyles = makeStyles()(theme => {
return {
drawerActions: {
listStyleType: 'none',
margin: 0,
padding: 0
},
drawerItem: {
alignItems: 'center',
color: theme.palette.text01,
display: 'flex',
padding: '12px 16px',
...theme.typography.bodyShortRegularLarge,
'&:first-child': {
marginTop: '15px'
},
'&:hover': {
cursor: 'pointer',
background: theme.palette.action02
}
},
icon: {
marginRight: 16
},
headingContainer: {
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between'
},
heading: {
...theme.typography.bodyShortBold,
color: theme.palette.text02
},
link: {
...theme.typography.labelBold,
color: theme.palette.link01,
cursor: 'pointer'
}
};
});
/**
* Component used to display a list of participants waiting in the lobby.
*
* @returns {ReactNode}
*/
export default function LobbyParticipants() {
const lobbyEnabled = useSelector(getLobbyEnabled);
const participants = useSelector(getKnockingParticipants);
const { t } = useTranslation();
const { classes } = useStyles();
const dispatch = useDispatch();
const admitAll = useCallback(() => {
dispatch(admitMultiple(participants));
}, [ dispatch, participants ]);
const overflowDrawer = useSelector(showOverflowDrawer);
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
const [ admit, reject ] = useLobbyActions(drawerParticipant, closeDrawer);
if (!lobbyEnabled || !participants.length) {
return null;
}
return (
<>
<div className = { classes.headingContainer }>
<div className = { classes.heading }>
{t('participantsPane.headings.lobby', { count: participants.length })}
</div>
{
participants.length > 1
&& <div
className = { classes.link }
onClick = { admitAll }>{t('participantsPane.actions.admitAll')}</div>
}
</div>
<LobbyParticipantItems
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participants = { participants } />
<JitsiPortal>
<Drawer
isOpen = { Boolean(drawerParticipant && overflowDrawer) }
onClose = { closeDrawer }>
<ul className = { classes.drawerActions }>
<li className = { classes.drawerItem }>
<Avatar
className = { classes.icon }
participantId = { drawerParticipant?.participantID }
size = { 20 } />
<span>{ drawerParticipant?.displayName }</span>
</li>
<li
className = { classes.drawerItem }
onClick = { admit }>
<Icon
className = { classes.icon }
size = { 20 }
src = { IconCheck } />
<span>{ t('participantsPane.actions.admit') }</span>
</li>
<li
className = { classes.drawerItem }
onClick = { reject }>
<Icon
className = { classes.icon }
size = { 20 }
src = { IconCloseLarge } />
<span>{ t('participantsPane.actions.reject')}</span>
</li>
</ul>
</Drawer>
</JitsiPortal>
</>
);
}

View File

@@ -0,0 +1,143 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import {
getLocalParticipant,
getParticipantByIdOrUndefined
} from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import FakeParticipantContextMenu from '../../../video-menu/components/web/FakeParticipantContextMenu';
import ParticipantContextMenu from '../../../video-menu/components/web/ParticipantContextMenu';
interface IProps {
/**
* Shared video local participant owner.
*/
_localVideoOwner: boolean;
/**
* Participant reference.
*/
_participant?: IParticipant;
/**
* Closes a drawer if open.
*/
closeDrawer: () => void;
/**
* The dispatch function from redux.
*/
dispatch: IStore['dispatch'];
/**
* The participant for which the drawer is open.
* It contains the displayName & participantID.
*/
drawerParticipant: {
displayName: string;
participantID: string;
};
/**
* Target elements against which positioning calculations are made.
*/
offsetTarget?: HTMLElement;
/**
* Callback for the mouse entering the component.
*/
onEnter: (e?: React.MouseEvent) => void;
/**
* Callback for the mouse leaving the component.
*/
onLeave: (e?: React.MouseEvent) => void;
/**
* Callback for making a selection in the menu.
*/
onSelect: (e?: React.MouseEvent | boolean) => void;
/**
* The ID of the participant.
*/
participantID: string;
}
/**
* Implements the MeetingParticipantContextMenu component.
*/
class MeetingParticipantContextMenu extends Component<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_localVideoOwner,
_participant,
closeDrawer,
drawerParticipant,
offsetTarget,
onEnter,
onLeave,
onSelect
} = this.props;
if (!_participant) {
return null;
}
const props = {
closeDrawer,
drawerParticipant,
offsetTarget,
onEnter,
onLeave,
onSelect,
participant: _participant,
thumbnailMenu: false
};
if (_participant?.fakeParticipant) {
return (
<FakeParticipantContextMenu
{ ...props }
localVideoOwner = { _localVideoOwner } />
);
}
return <ParticipantContextMenu { ...props } />;
}
}
/**
* Maps (parts of) the redux state to the associated props for this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantID, overflowDrawer, drawerParticipant } = ownProps;
const { ownerId } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state)?.id;
const participant = getParticipantByIdOrUndefined(state,
overflowDrawer ? drawerParticipant?.participantID : participantID);
return {
_localVideoOwner: Boolean(ownerId === localParticipantId),
_participant: participant
};
}
export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu));

View File

@@ -0,0 +1,318 @@
import React, { useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../../../base/media/constants';
import {
getLocalParticipant,
getParticipantByIdOrUndefined,
getParticipantDisplayName,
hasRaisedHand,
isParticipantModerator
} from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import {
getLocalAudioTrack,
getTrackByMediaTypeAndParticipant,
isParticipantAudioMuted,
isParticipantVideoMuted
} from '../../../base/tracks/functions.web';
import { ITrack } from '../../../base/tracks/types';
import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../../constants';
import {
getParticipantAudioMediaState,
getParticipantVideoMediaState,
getQuickActionButtonType,
participantMatchesSearch
} from '../../functions';
import ParticipantActionEllipsis from './ParticipantActionEllipsis';
import ParticipantItem from './ParticipantItem';
import ParticipantQuickAction from './ParticipantQuickAction';
interface IProps {
/**
* Media state for audio.
*/
_audioMediaState: MediaState;
/**
* The audio track related to the participant.
*/
_audioTrack?: ITrack;
/**
* Whether or not to disable the moderator indicator.
*/
_disableModeratorIndicator: boolean;
/**
* The display name of the participant.
*/
_displayName: string;
/**
* Whether or not moderation is supported.
*/
_isModerationSupported: boolean;
/**
* True if the participant is the local participant.
*/
_local: boolean;
/**
* Whether or not the local participant is moderator.
*/
_localModerator: boolean;
/**
* Shared video local participant owner.
*/
_localVideoOwner: boolean;
/**
* Whether or not the participant name matches the search string.
*/
_matchesSearch: boolean;
/**
* The participant.
*/
_participant?: IParticipant;
/**
* The participant ID.
*
* NOTE: This ID may be different from participantID prop in the case when we pass undefined for the local
* participant. In this case the local participant ID will be filled through _participantID prop.
*/
_participantID: string;
/**
* The type of button to be rendered for the quick action.
*/
_quickActionButtonType: string;
/**
* True if the participant have raised hand.
*/
_raisedHand: boolean;
/**
* Media state for video.
*/
_videoMediaState: MediaState;
/**
* The translated ask unmute text for the quick action buttons.
*/
askUnmuteText: string;
/**
* Is this item highlighted.
*/
isHighlighted: boolean;
/**
* Whether or not the local participant is in a breakout room.
*/
isInBreakoutRoom: boolean;
/**
* The translated text for the mute participant button.
*/
muteParticipantButtonText: string;
/**
* Callback for the activation of this item's context menu.
*/
onContextMenu: () => void;
/**
* Callback for the mouse leaving this item.
*/
onLeave: (e?: React.MouseEvent) => void;
/**
* Callback used to open an actions drawer for a participant.
*/
openDrawerForParticipant: Function;
/**
* True if an overflow drawer should be displayed.
*/
overflowDrawer: boolean;
/**
* The aria-label for the ellipsis action.
*/
participantActionEllipsisLabel: string;
/**
* The ID of the participant.
*/
participantID?: string;
/**
* The translated "you" text.
*/
youText: string;
}
/**
* Implements the MeetingParticipantItem component.
*
* @param {IProps} props - The props of the component.
* @returns {ReactElement}
*/
function MeetingParticipantItem({
_audioMediaState,
_audioTrack,
_disableModeratorIndicator,
_displayName,
_local,
_localVideoOwner,
_matchesSearch,
_participant,
_participantID,
_quickActionButtonType,
_raisedHand,
_videoMediaState,
isHighlighted,
isInBreakoutRoom,
onContextMenu,
onLeave,
openDrawerForParticipant,
overflowDrawer,
participantActionEllipsisLabel,
youText
}: IProps) {
const [ hasAudioLevels, setHasAudioLevel ] = useState(false);
const [ registeredEvent, setRegisteredEvent ] = useState(false);
const _updateAudioLevel = useCallback(level => {
const audioLevel = typeof level === 'number' && !isNaN(level)
? level : 0;
setHasAudioLevel(audioLevel > 0.009);
}, []);
useEffect(() => {
if (_audioTrack && !registeredEvent) {
const { jitsiTrack } = _audioTrack;
if (jitsiTrack) {
jitsiTrack.on(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, _updateAudioLevel);
setRegisteredEvent(true);
}
}
return () => {
if (_audioTrack && registeredEvent) {
const { jitsiTrack } = _audioTrack;
jitsiTrack?.off(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, _updateAudioLevel);
}
};
}, [ _audioTrack ]);
if (!_matchesSearch) {
return null;
}
const audioMediaState = _audioMediaState === MEDIA_STATE.UNMUTED && hasAudioLevels
? MEDIA_STATE.DOMINANT_SPEAKER : _audioMediaState;
return (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
{
...(_participant?.fakeParticipant ? {} : {
audioMediaState,
videoMediaState: _videoMediaState
})
}
disableModeratorIndicator = { _disableModeratorIndicator }
displayName = { _displayName }
isHighlighted = { isHighlighted }
isModerator = { isParticipantModerator(_participant) }
local = { _local }
onLeave = { onLeave }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantID = { _participantID }
raisedHand = { _raisedHand }
youText = { youText }>
{!overflowDrawer && !_participant?.fakeParticipant
&& <>
{!isInBreakoutRoom && (
<ParticipantQuickAction
buttonType = { _quickActionButtonType }
participantID = { _participantID }
participantName = { _displayName } />
)}
<ParticipantActionEllipsis
accessibilityLabel = { participantActionEllipsisLabel }
onClick = { onContextMenu }
participantID = { _participantID } />
</>
}
{!overflowDrawer && (_localVideoOwner && _participant?.fakeParticipant) && (
<ParticipantActionEllipsis
accessibilityLabel = { participantActionEllipsisLabel }
onClick = { onContextMenu } />
)}
</ParticipantItem>
);
}
/**
* Maps (parts of) the redux state to the associated props for this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantID, searchString } = ownProps;
const { ownerId } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state)?.id;
const participant = getParticipantByIdOrUndefined(state, participantID);
const _displayName = getParticipantDisplayName(state, participant?.id ?? '');
const _matchesSearch = participantMatchesSearch(participant, searchString);
const _isAudioMuted = isParticipantAudioMuted(participant, state);
const _isVideoMuted = isParticipantVideoMuted(participant, state);
const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state);
const _videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state);
const _quickActionButtonType = getQuickActionButtonType(participant, state);
const tracks = state['features/base/tracks'];
const _audioTrack = participantID === localParticipantId
? getLocalAudioTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, participantID);
const { disableModeratorIndicator } = state['features/base/config'];
return {
_audioMediaState,
_audioTrack,
_disableModeratorIndicator: Boolean(disableModeratorIndicator),
_displayName,
_local: Boolean(participant?.local),
_localVideoOwner: Boolean(ownerId === localParticipantId),
_matchesSearch,
_participant: participant,
_participantID: participant?.id ?? '',
_quickActionButtonType,
_raisedHand: hasRaisedHand(participant),
_videoMediaState
};
}
export default connect(_mapStateToProps)(MeetingParticipantItem);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import MeetingParticipantItem from './MeetingParticipantItem';
interface IProps {
/**
* The translated ask unmute text for the quick action buttons.
*/
askUnmuteText?: string;
/**
* Whether or not the local participant is in a breakout room.
*/
isInBreakoutRoom: boolean;
/**
* Callback for the mouse leaving this item.
*/
lowerMenu: Function;
/**
* The translated text for the mute participant button.
*/
muteParticipantButtonText?: string;
/**
* Callback used to open an actions drawer for a participant.
*/
openDrawerForParticipant: Function;
/**
* True if an overflow drawer should be displayed.
*/
overflowDrawer?: boolean;
/**
* The aria-label for the ellipsis action.
*/
participantActionEllipsisLabel: string;
/**
* The meeting participants.
*/
participantIds: Array<string>;
/**
* The if of the participant for which the context menu should be open.
*/
raiseContextId?: string;
/**
* Current search string.
*/
searchString?: string;
/**
* Callback for the activation of this item's context menu.
*/
toggleMenu: Function;
/**
* The translated "you" text.
*/
youText: string;
}
/**
* Component used to display a list of meeting participants.
*
* @returns {ReactNode}
*/
function MeetingParticipantItems({
isInBreakoutRoom,
lowerMenu,
toggleMenu,
participantIds,
openDrawerForParticipant,
overflowDrawer,
raiseContextId,
participantActionEllipsisLabel,
searchString,
youText
}: IProps) {
const renderParticipant = (id: string) => (
<MeetingParticipantItem
isHighlighted = { raiseContextId === id }
isInBreakoutRoom = { isInBreakoutRoom }
key = { id }
onContextMenu = { toggleMenu(id) }
onLeave = { lowerMenu }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantID = { id }
searchString = { searchString }
youText = { youText } />
);
return (<>
{participantIds.map(renderParticipant)}
</>);
}
// Memoize the component in order to avoid rerender on drawer open/close.
export default React.memo<IProps>(MeetingParticipantItems);

View File

@@ -0,0 +1,192 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
import { getParticipantById, isScreenShareParticipant } from '../../../base/participants/functions';
import Input from '../../../base/ui/components/web/Input';
import useContextMenu from '../../../base/ui/hooks/useContextMenu.web';
import { normalizeAccents } from '../../../base/util/strings.web';
import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { isButtonEnabled, showOverflowDrawer } from '../../../toolbox/functions.web';
import { iAmVisitor } from '../../../visitors/functions';
import { getSortedParticipantIds, isCurrentRoomRenamable, shouldRenderInviteButton } from '../../functions';
import { useParticipantDrawer } from '../../hooks';
import RenameButton from '../breakout-rooms/components/web/RenameButton';
import { InviteButton } from './InviteButton';
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
import MeetingParticipantItems from './MeetingParticipantItems';
const useStyles = makeStyles()(theme => {
return {
headingW: {
color: theme.palette.warning02
},
heading: {
color: theme.palette.text02,
...theme.typography.bodyShortBold,
marginBottom: theme.spacing(3),
[`@media(max-width: ${participantsPaneTheme.MD_BREAKPOINT})`]: {
...theme.typography.bodyShortBoldLarge
}
},
search: {
margin: `${theme.spacing(3)} 0`,
'& input': {
textAlign: 'center',
paddingRight: '16px'
}
}
};
});
interface IProps {
currentRoom?: {
jid: string;
name: string;
};
overflowDrawer?: boolean;
participantsCount?: number;
searchString: string;
setSearchString: (newValue: string) => void;
showInviteButton?: boolean;
sortedParticipantIds?: Array<string>;
}
/**
* Renders the MeetingParticipantList component.
* NOTE: This component is not using useSelector on purpose. The child components MeetingParticipantItem
* and MeetingParticipantContextMenu are using connect. Having those mixed leads to problems.
* When this one was using useSelector and the other two were not -the other two were re-rendered before this one was
* re-rendered, so when participant is leaving, we first re-render the item and menu components,
* throwing errors (closing the page) before removing those components for the participant that left.
*
* @returns {ReactNode} - The component.
*/
function MeetingParticipants({
currentRoom,
overflowDrawer,
participantsCount,
searchString,
setSearchString,
showInviteButton,
sortedParticipantIds = []
}: IProps) {
const { t } = useTranslation();
const [ lowerMenu, , toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu<string>();
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
// FIXME:
// It seems that useTranslation is not very scalable. Unmount 500 components that have the useTranslation hook is
// taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
// solution!!!
// One potential proper fix would be to use react-window component in order to lower the number of components
// mounted.
const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
const youText = t('chat.you');
const isBreakoutRoom = useSelector(isInBreakoutRoom);
const _isCurrentRoomRenamable = useSelector(isCurrentRoomRenamable);
const { classes: styles } = useStyles();
return (
<>
<span
aria-level = { 1 }
className = 'sr-only'
role = 'heading'>
{ t('participantsPane.title') }
</span>
<div className = { styles.heading }>
{currentRoom?.name
? `${currentRoom.name} (${participantsCount})`
: t('participantsPane.headings.participantsList', { count: participantsCount })}
{ currentRoom?.name && _isCurrentRoomRenamable
&& <RenameButton
breakoutRoomJid = { currentRoom?.jid }
name = { currentRoom?.name } /> }
</div>
{showInviteButton && <InviteButton />}
<Input
accessibilityLabel = { t('participantsPane.search') }
className = { styles.search }
clearable = { true }
hiddenDescription = { t('participantsPane.searchDescription') }
id = 'participants-search-input'
onChange = { setSearchString }
placeholder = { t('participantsPane.search') }
value = { searchString } />
<div>
<MeetingParticipantItems
isInBreakoutRoom = { isBreakoutRoom }
lowerMenu = { lowerMenu }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantIds = { sortedParticipantIds }
raiseContextId = { raiseContext.entity }
searchString = { normalizeAccents(searchString) }
toggleMenu = { toggleMenu }
youText = { youText } />
</div>
<MeetingParticipantContextMenu
closeDrawer = { closeDrawer }
drawerParticipant = { drawerParticipant }
offsetTarget = { raiseContext?.offsetTarget }
onEnter = { menuEnter }
onLeave = { menuLeave }
onSelect = { lowerMenu }
overflowDrawer = { overflowDrawer }
participantID = { raiseContext?.entity } />
</>
);
}
/**
* Maps (parts of) the redux state to the associated props for this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
let sortedParticipantIds: any = getSortedParticipantIds(state);
const _iAmVisitor = iAmVisitor(state);
// Filter out the virtual screenshare participants since we do not want them to be displayed as separate
// participants in the participants pane.
// Filter local participant when in visitor mode
sortedParticipantIds = sortedParticipantIds.filter((id: any) => {
const participant = getParticipantById(state, id);
if (_iAmVisitor && participant?.local) {
return false;
}
return !isScreenShareParticipant(participant);
});
const participantsCount = sortedParticipantIds.length;
const showInviteButton = shouldRenderInviteButton(state) && isButtonEnabled('invite', state);
const overflowDrawer = showOverflowDrawer(state);
const currentRoomId = getCurrentRoomId(state);
const currentRoom = getBreakoutRooms(state)[currentRoomId];
return {
currentRoom,
overflowDrawer,
participantsCount,
showInviteButton,
sortedParticipantIds
};
}
export default connect(_mapStateToProps)(MeetingParticipants);

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { IconDotsHorizontal } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
interface IProps {
/**
* Label used for accessibility.
*/
accessibilityLabel: string;
/**
* Click handler function.
*/
onClick: () => void;
participantID?: string;
}
const ParticipantActionEllipsis = ({ accessibilityLabel, onClick, participantID }: IProps) => (
<Button
accessibilityLabel = { accessibilityLabel }
icon = { IconDotsHorizontal }
onClick = { onClick }
size = 'small'
testId = { participantID ? `participant-more-options-${participantID}` : undefined } />
);
export default ParticipantActionEllipsis;

View File

@@ -0,0 +1,199 @@
import React, { ReactNode, useCallback } from 'react';
import { WithTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import Avatar from '../../../base/avatar/components/Avatar';
import { translate } from '../../../base/i18n/functions';
import ListItem from '../../../base/ui/components/web/ListItem';
import {
ACTION_TRIGGER,
type ActionTrigger,
AudioStateIcons,
MEDIA_STATE,
MediaState,
VideoStateIcons
} from '../../constants';
import { RaisedHandIndicator } from './RaisedHandIndicator';
interface IProps extends WithTranslation {
/**
* Type of trigger for the participant actions.
*/
actionsTrigger?: ActionTrigger;
/**
* Media state for audio.
*/
audioMediaState?: MediaState;
/**
* React children.
*/
children?: ReactNode;
/**
* Whether or not to disable the moderator indicator.
*/
disableModeratorIndicator?: boolean;
/**
* The name of the participant. Used for showing lobby names.
*/
displayName?: string;
/**
* Is this item highlighted/raised.
*/
isHighlighted?: boolean;
/**
* Whether or not the participant is a moderator.
*/
isModerator?: boolean;
/**
* True if the participant is local.
*/
local?: boolean;
/**
* Callback for when the mouse leaves this component.
*/
onLeave?: (e?: React.MouseEvent) => void;
/**
* Opens a drawer with participant actions.
*/
openDrawerForParticipant?: Function;
/**
* If an overflow drawer can be opened.
*/
overflowDrawer?: boolean;
/**
* The ID of the participant.
*/
participantID: string;
/**
* True if the participant have raised hand.
*/
raisedHand?: boolean;
/**
* Media state for video.
*/
videoMediaState?: MediaState;
/**
* The translated "you" text.
*/
youText?: string;
}
const useStyles = makeStyles()(theme => {
return {
nameContainer: {
display: 'flex',
flex: 1,
overflow: 'hidden'
},
name: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
moderatorLabel: {
...theme.typography.labelBold,
color: theme.palette.text03
},
avatar: {
marginRight: theme.spacing(3)
}
};
});
/**
* A component representing a participant entry in ParticipantPane and Lobby.
*
* @param {IProps} props - The props of the component.
* @returns {ReactNode}
*/
function ParticipantItem({
actionsTrigger = ACTION_TRIGGER.HOVER,
audioMediaState = MEDIA_STATE.NONE,
children,
disableModeratorIndicator,
displayName,
isHighlighted,
isModerator,
local,
onLeave,
openDrawerForParticipant,
overflowDrawer,
participantID,
raisedHand,
t,
videoMediaState = MEDIA_STATE.NONE,
youText
}: IProps) {
const onClick = useCallback(
() => openDrawerForParticipant?.({
participantID,
displayName
}), []);
const { classes } = useStyles();
const icon = (
<Avatar
className = { classes.avatar }
displayName = { displayName }
participantId = { participantID }
size = { 32 } />
);
const text = (
<>
<div className = { classes.nameContainer }>
<div className = { classes.name }>
{displayName}
</div>
{local ? <span>&nbsp;({youText})</span> : null}
</div>
{isModerator && !disableModeratorIndicator && <div className = { classes.moderatorLabel }>
{t('videothumbnail.moderator')}
</div>}
</>
);
const indicators = (
<>
{raisedHand && <RaisedHandIndicator />}
{VideoStateIcons[videoMediaState]}
{AudioStateIcons[audioMediaState]}
</>
);
return (
<ListItem
actions = { children }
hideActions = { local }
icon = { icon }
id = { `participant-item-${participantID}` }
indicators = { indicators }
isHighlighted = { isHighlighted }
onClick = { !local && overflowDrawer ? onClick : undefined }
onMouseLeave = { onLeave }
textChildren = { text }
trigger = { actionsTrigger } />
);
}
export default translate(ParticipantItem);

View File

@@ -0,0 +1,170 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import {
approveParticipantAudio,
approveParticipantDesktop,
approveParticipantVideo,
rejectParticipantAudio,
rejectParticipantDesktop,
rejectParticipantVideo
} from '../../../av-moderation/actions';
import { MEDIA_TYPE } from '../../../base/media/constants';
import Button from '../../../base/ui/components/web/Button';
import { muteRemote } from '../../../video-menu/actions.web';
import { QUICK_ACTION_BUTTON } from '../../constants';
interface IProps {
/**
* The translated ask unmute aria label.
*/
ariaLabel?: boolean;
/**
* The translated "ask unmute" text.
*/
askUnmuteText?: string;
/**
* The type of button to be displayed.
*/
buttonType: string;
/**
* Label for mute participant button.
*/
muteParticipantButtonText?: string;
/**
* The ID of the participant.
*/
participantID: string;
/**
* The name of the participant.
*/
participantName: string;
}
const useStyles = makeStyles()(theme => {
return {
button: {
marginRight: theme.spacing(2)
}
};
});
const ParticipantQuickAction = ({
buttonType,
participantID,
participantName
}: IProps) => {
const { classes: styles } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const askToUnmute = useCallback(() => {
dispatch(approveParticipantAudio(participantID));
}, [ dispatch, participantID ]);
const allowDesktop = useCallback(() => {
dispatch(approveParticipantDesktop(participantID));
}, [ dispatch, participantID ]);
const allowVideo = useCallback(() => {
dispatch(approveParticipantVideo(participantID));
}, [ dispatch, participantID ]);
const muteAudio = useCallback(() => {
dispatch(muteRemote(participantID, MEDIA_TYPE.AUDIO));
dispatch(rejectParticipantAudio(participantID));
}, [ dispatch, participantID ]);
const stopDesktop = useCallback(() => {
dispatch(muteRemote(participantID, MEDIA_TYPE.SCREENSHARE));
dispatch(rejectParticipantDesktop(participantID));
}, [ dispatch, participantID ]);
const stopVideo = useCallback(() => {
dispatch(muteRemote(participantID, MEDIA_TYPE.VIDEO));
dispatch(rejectParticipantVideo(participantID));
}, [ dispatch, participantID ]);
switch (buttonType) {
case QUICK_ACTION_BUTTON.MUTE: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.mute')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.mute') }
onClick = { muteAudio }
size = 'small'
testId = { `mute-audio-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.askUnmute')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.askUnmute') }
onClick = { askToUnmute }
size = 'small'
testId = { `unmute-audio-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.ALLOW_DESKTOP: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.askDesktop')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.allowDesktop') }
onClick = { allowDesktop }
size = 'small'
testId = { `unmute-desktop-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.ALLOW_VIDEO: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.askUnmute')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.allowVideo') }
onClick = { allowVideo }
size = 'small'
testId = { `unmute-video-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.STOP_DESKTOP: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.stopDesktop')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.stopDesktop') }
onClick = { stopDesktop }
size = 'small'
testId = { `mute-desktop-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.STOP_VIDEO: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.mute')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.stopVideo') }
onClick = { stopVideo }
size = 'small'
testId = { `mute-video-${participantID}` } />
);
}
default: {
return null;
}
}
};
export default ParticipantQuickAction;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
const useStyles = makeStyles()(theme => {
return {
badge: {
backgroundColor: theme.palette.ui03,
borderRadius: '100%',
height: '16px',
minWidth: '16px',
color: theme.palette.text01,
...theme.typography.labelBold,
pointerEvents: 'none',
position: 'absolute',
right: '-4px',
top: '-3px',
textAlign: 'center',
padding: '1px'
}
};
});
const ParticipantsCounter = () => {
const { classes } = useStyles();
const participantsCount = useSelector(getParticipantCountForDisplay);
return <span className = { classes.badge }>{participantsCount}</span>;
};
export default ParticipantsCounter;

View File

@@ -0,0 +1,247 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
import { openDialog } from '../../../base/dialog/actions';
import { isMobileBrowser } from '../../../base/environment/utils';
import { IconCloseLarge, IconDotsHorizontal } from '../../../base/icons/svg';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import Button from '../../../base/ui/components/web/Button';
import ClickableIcon from '../../../base/ui/components/web/ClickableIcon';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { findAncestorByClass } from '../../../base/ui/functions.web';
import { isAddBreakoutRoomButtonVisible } from '../../../breakout-rooms/functions';
import MuteEveryoneDialog from '../../../video-menu/components/web/MuteEveryoneDialog';
import { shouldDisplayCurrentVisitorsList } from '../../../visitors/functions';
import { close } from '../../actions.web';
import {
getParticipantsPaneOpen,
isMoreActionsVisible,
isMuteAllVisible
} from '../../functions';
import { AddBreakoutRoomButton } from '../breakout-rooms/components/web/AddBreakoutRoomButton';
import { RoomList } from '../breakout-rooms/components/web/RoomList';
import CurrentVisitorsList from './CurrentVisitorsList';
import { FooterContextMenu } from './FooterContextMenu';
import LobbyParticipants from './LobbyParticipants';
import MeetingParticipants from './MeetingParticipants';
import VisitorsList from './VisitorsList';
/**
* 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,
flexShrink: 0,
position: 'relative',
transition: 'width .16s ease-in-out',
width: '315px',
zIndex: isMobileBrowser() && isChatOpen ? -1 : 0,
display: 'flex',
flexDirection: 'column',
fontWeight: 600,
height: '100%',
[[ '& > *:first-child', '& > *:last-child' ] as any]: {
flexShrink: 0
},
'@media (max-width: 580px)': {
height: '100dvh',
position: 'fixed',
left: 0,
right: 0,
top: 0,
width: '100%'
}
},
container: {
boxSizing: 'border-box',
flex: 1,
overflowY: 'auto',
position: 'relative',
padding: `0 ${participantsPaneTheme.panePadding}px`,
display: 'flex',
flexDirection: 'column',
'&::-webkit-scrollbar': {
display: 'none'
},
// Temporary fix: Limit context menu width to prevent clipping
// TODO: Long-term fix would be to portal context menus outside the scrollable container
'& [class*="contextMenu"]': {
maxWidth: '285px',
'& [class*="contextMenuItem"]': {
whiteSpace: 'normal',
'& span': {
whiteSpace: 'normal',
wordBreak: 'break-word'
}
}
}
},
closeButton: {
alignItems: 'center',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center'
},
header: {
alignItems: 'center',
boxSizing: 'border-box',
display: 'flex',
height: '60px',
padding: `0 ${participantsPaneTheme.panePadding}px`,
justifyContent: 'flex-end'
},
antiCollapse: {
fontSize: 0,
'&:first-child': {
display: 'none'
},
'&:first-child + *': {
marginTop: 0
}
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
padding: `${theme.spacing(4)} ${participantsPaneTheme.panePadding}px`,
'& > *:not(:last-child)': {
marginRight: theme.spacing(3)
}
},
footerMoreContainer: {
position: 'relative'
}
};
});
const ParticipantsPane = () => {
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();
const showCurrentVisitorsList = useSelector(shouldDisplayCurrentVisitorsList);
const showAddRoomButton = useSelector(isAddBreakoutRoomButtonVisible);
const showFooter = useSelector(isLocalParticipantModerator);
const showMuteAllButton = useSelector(isMuteAllVisible);
const showMoreActionsButton = useSelector(isMoreActionsVisible);
const dispatch = useDispatch();
const { t } = useTranslation();
const [ contextOpen, setContextOpen ] = useState(false);
const [ searchString, setSearchString ] = useState('');
const onWindowClickListener = useCallback((e: any) => {
if (contextOpen && !findAncestorByClass(e.target, classes.footerMoreContainer)) {
setContextOpen(false);
}
}, [ contextOpen ]);
useEffect(() => {
window.addEventListener('click', onWindowClickListener);
return () => {
window.removeEventListener('click', onWindowClickListener);
};
}, []);
const onClosePane = useCallback(() => {
dispatch(close());
}, []);
const onDrawerClose = useCallback(() => {
setContextOpen(false);
}, []);
const onMuteAll = useCallback(() => {
dispatch(openDialog(MuteEveryoneDialog));
}, []);
const onToggleContext = useCallback(() => {
setContextOpen(open => !open);
}, []);
if (!paneOpen) {
return null;
}
return (
<div
className = { classes.participantsPane }
id = 'participants-pane'>
<div className = { classes.header }>
<ClickableIcon
accessibilityLabel = { t('participantsPane.close', 'Close') }
icon = { IconCloseLarge }
onClick = { onClosePane } />
</div>
<div className = { classes.container }>
<VisitorsList />
<br className = { classes.antiCollapse } />
<LobbyParticipants />
<br className = { classes.antiCollapse } />
<MeetingParticipants
searchString = { searchString }
setSearchString = { setSearchString } />
{isBreakoutRoomsSupported && <RoomList searchString = { searchString } />}
{showAddRoomButton && <AddBreakoutRoomButton />}
{showCurrentVisitorsList && <CurrentVisitorsList searchString = { searchString } />}
</div>
{showFooter && (
<div className = { classes.footer }>
{showMuteAllButton && (
<Button
accessibilityLabel = { t('participantsPane.actions.muteAll') }
labelKey = { 'participantsPane.actions.muteAll' }
onClick = { onMuteAll }
type = { BUTTON_TYPES.SECONDARY } />
)}
{showMoreActionsButton && (
<div className = { classes.footerMoreContainer }>
<Button
accessibilityLabel = { t('participantsPane.actions.moreModerationActions') }
icon = { IconDotsHorizontal }
id = 'participants-pane-context-menu'
onClick = { onToggleContext }
type = { BUTTON_TYPES.SECONDARY } />
<FooterContextMenu
isOpen = { contextOpen }
onDrawerClose = { onDrawerClose }
onMouseLeave = { onToggleContext } />
</div>
)}
</div>
)}
</div>
);
};
export default ParticipantsPane;

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconUsers } from '../../../base/icons/svg';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import {
close as closeParticipantsPane,
open as openParticipantsPane
} from '../../../participants-pane/actions.web';
import { closeOverflowMenuIfOpen } from '../../../toolbox/actions.web';
import { isParticipantsPaneEnabled } from '../../functions';
import ParticipantsCounter from './ParticipantsCounter';
/**
* The type of the React {@code Component} props of {@link ParticipantsPaneButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether or not the participants pane is open.
*/
_isOpen: boolean;
/**
* Whether participants feature is enabled or not.
*/
_isParticipantsPaneEnabled: boolean;
/**
* Participants count.
*/
_participantsCount: number;
}
/**
* Implementation of a button for accessing participants pane.
*/
class ParticipantsPaneButton extends AbstractButton<IProps> {
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.closeParticipantsPane';
override icon = IconUsers;
override label = 'toolbar.participants';
override tooltip = 'toolbar.participants';
override toggledTooltip = 'toolbar.closeParticipantsPane';
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._isOpen;
}
/**
* Handles clicking the button, and toggles the participants pane.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, _isOpen } = this.props;
dispatch(closeOverflowMenuIfOpen());
if (_isOpen) {
dispatch(closeParticipantsPane());
} else {
dispatch(openParticipantsPane());
}
}
/**
* Override the _getAccessibilityLabel method to incorporate the dynamic participant count.
*
* @override
* @returns {string}
*/
override _getAccessibilityLabel() {
const { t, _participantsCount, _isOpen } = this.props;
if (_isOpen) {
return t('toolbar.accessibilityLabel.closeParticipantsPane');
}
return t('toolbar.accessibilityLabel.participants', {
participantsCount: _participantsCount
});
}
/**
* Overrides AbstractButton's {@link Component#render()}.
*
* @override
* @protected
* @returns {React$Node}
*/
override render() {
const { _isParticipantsPaneEnabled } = this.props;
if (!_isParticipantsPaneEnabled) {
return null;
}
return (
<div
className = 'toolbar-button-with-badge'>
{ super.render() }
<ParticipantsCounter />
</div>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
const { isOpen } = state['features/participants-pane'];
return {
_isOpen: isOpen,
_isParticipantsPaneEnabled: isParticipantsPaneEnabled(state),
_participantsCount: getParticipantCountForDisplay(state)
};
}
export default translate(connect(mapStateToProps)(ParticipantsPaneButton));

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconRaiseHand } from '../../../base/icons/svg';
const useStyles = makeStyles()(theme => {
return {
indicator: {
backgroundColor: theme.palette.warning02,
borderRadius: `${Number(theme.shape.borderRadius) / 2}px`,
height: '24px',
width: '24px'
}
};
});
export const RaisedHandIndicator = () => {
const { classes: styles, theme } = useStyles();
return (
<div className = { styles.indicator }>
<Icon
color = { theme.palette.icon04 }
size = { 16 }
src = { IconRaiseHand } />
</div>
);
};

View File

@@ -0,0 +1,80 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { approveRequest, denyRequest } from '../../../visitors/actions';
import { IPromotionRequest } from '../../../visitors/types';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import ParticipantItem from './ParticipantItem';
interface IProps {
/**
* Promotion request reference.
*/
request: IPromotionRequest;
}
const useStyles = makeStyles()(theme => {
return {
button: {
marginRight: theme.spacing(2)
},
moreButton: {
paddingRight: '6px',
paddingLeft: '6px',
marginRight: theme.spacing(2)
},
contextMenu: {
position: 'fixed',
top: 'auto',
marginRight: '8px'
}
};
});
export const VisitorsItem = ({
request: r
}: IProps) => {
const { from, nick } = r;
const { t } = useTranslation();
const { classes: styles } = useStyles();
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(approveRequest(r)), [ dispatch, r ]);
const reject = useCallback(() => dispatch(denyRequest(r)), [ dispatch, r ]);
return (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.PERMANENT }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { nick }
participantID = { from }
raisedHand = { true }
videoMediaState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }>
{<>
<Button
accessibilityLabel = { `${t('participantsPane.actions.reject')} ${r.nick}` }
className = { styles.button }
labelKey = 'participantsPane.actions.reject'
onClick = { reject }
size = 'small'
testId = { `reject-${from}` }
type = { BUTTON_TYPES.DESTRUCTIVE } />
<Button
accessibilityLabel = { `${t('participantsPane.actions.admit')} ${r.nick}` }
className = { styles.button }
labelKey = 'participantsPane.actions.admit'
onClick = { admit }
size = 'small'
testId = { `admit-${from}` } />
</>
}
</ParticipantItem>
);
};

View File

@@ -0,0 +1,139 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { admitMultiple, goLive } from '../../../visitors/actions';
import {
getPromotionRequests,
getVisitorsCount,
getVisitorsInQueueCount,
isVisitorsLive,
shouldDisplayCurrentVisitorsList
} from '../../../visitors/functions';
import { VisitorsItem } from './VisitorsItem';
const useStyles = makeStyles()(theme => {
return {
container: {
margin: `${theme.spacing(3)} 0`
},
headingW: {
color: theme.palette.warning02
},
drawerActions: {
listStyleType: 'none',
margin: 0,
padding: 0
},
drawerItem: {
alignItems: 'center',
color: theme.palette.text01,
display: 'flex',
padding: '12px 16px',
...theme.typography.bodyShortRegularLarge,
'&:first-child': {
marginTop: '15px'
},
'&:hover': {
cursor: 'pointer',
background: theme.palette.action02
}
},
icon: {
marginRight: 16
},
headingContainer: {
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between'
},
heading: {
...theme.typography.bodyShortBold,
color: theme.palette.text02
},
link: {
...theme.typography.labelBold,
color: theme.palette.link01,
cursor: 'pointer'
}
};
});
/**
* Component used to display a list of visitors waiting for approval to join the main meeting.
*
* @returns {ReactNode}
*/
export default function VisitorsList() {
const requests = useSelector(getPromotionRequests);
const visitorsCount = useSelector(getVisitorsCount);
const visitorsInQueueCount = useSelector(getVisitorsInQueueCount);
const isLive = useSelector(isVisitorsLive);
const showVisitorsInQueue = visitorsInQueueCount > 0 && isLive === false;
const showCurrentVisitorsList = useSelector(shouldDisplayCurrentVisitorsList);
const { t } = useTranslation();
const { classes, cx } = useStyles();
const dispatch = useDispatch();
const admitAll = useCallback(() => {
dispatch(admitMultiple(requests));
}, [ dispatch, requests ]);
const goLiveCb = useCallback(() => {
dispatch(goLive());
}, [ dispatch ]);
if (visitorsCount <= 0 && !showVisitorsInQueue) {
return null;
}
if (showCurrentVisitorsList && requests.length <= 0 && !showVisitorsInQueue) {
return null;
}
return (
<>
<div className = { classes.headingContainer }>
<div
className = { cx(classes.heading, classes.headingW) }
id = 'visitor-list-header' >
{ !showCurrentVisitorsList && t('participantsPane.headings.visitors', { count: visitorsCount })}
{ requests.length > 0
&& t(`participantsPane.headings.${showCurrentVisitorsList ? 'viewerRequests' : 'visitorRequests'}`, { count: requests.length }) }
{ showVisitorsInQueue
&& t('participantsPane.headings.visitorInQueue', { count: visitorsInQueueCount }) }
</div>
{
requests.length > 1 && !showVisitorsInQueue // Go live button is with higher priority
&& <div
className = { classes.link }
onClick = { admitAll }>{ t('participantsPane.actions.admitAll') }</div>
}
{
showVisitorsInQueue
&& <div
className = { classes.link }
onClick = { goLiveCb }>{ t('participantsPane.actions.goLive') }</div>
}
</div>
{ requests.length > 0
&& <div
className = { classes.container }
id = 'visitor-list'>
{
requests.map(r => (
<VisitorsItem
key = { r.from }
request = { r } />)
)
}
</div>
}
</>
);
}