This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 } />
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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> ({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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
139
react/features/participants-pane/components/web/VisitorsList.tsx
Normal file
139
react/features/participants-pane/components/web/VisitorsList.tsx
Normal 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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user