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,14 @@
/**
* Action type to signal the closing of the participants pane.
*/
export const PARTICIPANTS_PANE_CLOSE = 'PARTICIPANTS_PANE_CLOSE';
/**
* Action type to signal the opening of the participants pane.
*/
export const PARTICIPANTS_PANE_OPEN = 'PARTICIPANTS_PANE_OPEN';
/**
* Action type to set the volume of the participant.
*/
export const SET_VOLUME = 'SET_VOLUME';

View File

@@ -0,0 +1,12 @@
import { PARTICIPANTS_PANE_CLOSE } from './actionTypes';
/**
* Action to close the participants pane.
*
* @returns {Object}
*/
export const close = () => {
return {
type: PARTICIPANTS_PANE_CLOSE
};
};

View File

@@ -0,0 +1,105 @@
/* eslint-disable lines-around-comment */
import { IStore } from '../app/types';
import { openSheet } from '../base/dialog/actions';
import { navigate }
from '../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import ConnectionStatusComponent
from '../video-menu/components/native/ConnectionStatusComponent';
// @ts-ignore
import LocalVideoMenu from '../video-menu/components/native/LocalVideoMenu';
// @ts-ignore
import RemoteVideoMenu from '../video-menu/components/native/RemoteVideoMenu';
// @ts-ignore
import SharedVideoMenu from '../video-menu/components/native/SharedVideoMenu';
import { PARTICIPANTS_PANE_OPEN, SET_VOLUME } from './actionTypes';
import RoomParticipantMenu from './components/native/RoomParticipantMenu';
export * from './actions.any';
/**
* Displays the connection status for the local meeting participant.
*
* @param {string} participantID - The selected meeting participant id.
* @returns {Function}
*/
export function showConnectionStatus(participantID: string) {
return openSheet(ConnectionStatusComponent, { participantID });
}
/**
* Displays the context menu for the selected meeting participant.
*
* @param {string} participantId - The ID of the selected meeting participant.
* @param {boolean} local - Whether the participant is local or not.
* @returns {Function}
*/
export function showContextMenuDetails(participantId: string, local = false) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const { remoteVideoMenu } = getState()['features/base/config'];
if (local) {
dispatch(openSheet(LocalVideoMenu));
} else if (!remoteVideoMenu?.disabled) {
dispatch(openSheet(RemoteVideoMenu, { participantId }));
}
};
}
/**
* Displays the shared video menu.
*
* @param {string} participantId - The ID of the selected meeting participant.
* @returns {Function}
*/
export function showSharedVideoMenu(participantId: string) {
return openSheet(SharedVideoMenu, { participantId });
}
/**
* Sets the volume.
*
* @param {string} participantId - The participant ID associated with the audio.
* @param {string} volume - The volume level.
* @returns {{
* type: SET_VOLUME,
* participantId: string,
* volume: number
* }}
*/
export function setVolume(participantId: string, volume: number) {
return {
type: SET_VOLUME,
participantId,
volume
};
}
/**
* Displays the breakout room participant menu.
*
* @param {Object} room - The room the participant is in.
* @param {string} participantJid - The jid of the participant.
* @param {string} participantName - The display name of the participant.
* @returns {Function}
*/
export function showRoomParticipantMenu(room: Object, participantJid: string, participantName: string) {
// @ts-ignore
return openSheet(RoomParticipantMenu, { room,
participantJid,
participantName });
}
/**
* Action to open the participants pane.
*
* @returns {Object}
*/
export const open = () => {
navigate(screen.conference.participants);
return {
type: PARTICIPANTS_PANE_OPEN
};
};

View File

@@ -0,0 +1,14 @@
import { PARTICIPANTS_PANE_OPEN } from './actionTypes';
export * from './actions.any';
/**
* Action to open the participants pane.
*
* @returns {Object}
*/
export const open = () => {
return {
type: PARTICIPANTS_PANE_OPEN
};
};

View File

@@ -0,0 +1,36 @@
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 { createBreakoutRoom } from '../../../../../breakout-rooms/actions';
const useStyles = makeStyles()(theme => {
return {
button: {
marginTop: theme.spacing(3)
}
};
});
export const AddBreakoutRoomButton = () => {
const { classes } = useStyles();
const { t } = useTranslation();
const dispatch = useDispatch();
const onAdd = useCallback(() =>
dispatch(createBreakoutRoom())
, [ dispatch ]);
return (
<Button
accessibilityLabel = { t('breakoutRooms.actions.add') }
className = { classes.button }
fullWidth = { true }
labelKey = { 'breakoutRooms.actions.add' }
onClick = { onAdd }
type = { BUTTON_TYPES.SECONDARY } />
);
};

View File

@@ -0,0 +1,30 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import Button from '../../../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../../../base/ui/constants.web';
import { autoAssignToBreakoutRooms } from '../../../../../breakout-rooms/actions';
interface IProps {
className?: string;
}
export const AutoAssignButton = ({ className }: IProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const onAutoAssign = useCallback(() => {
dispatch(autoAssignToBreakoutRooms());
}, [ dispatch ]);
return (
<Button
accessibilityLabel = { t('breakoutRooms.actions.autoAssign') }
className = { className }
fullWidth = { true }
labelKey = { 'breakoutRooms.actions.autoAssign' }
onClick = { onAutoAssign }
type = { BUTTON_TYPES.TERTIARY } />
);
};

View File

@@ -0,0 +1,47 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import Dialog from '../../../../../base/ui/components/web/Dialog';
import Input from '../../../../../base/ui/components/web/Input';
import { renameBreakoutRoom } from '../../../../../breakout-rooms/actions';
import { IBreakoutRoomNamePromptProps as IProps } from '../../../../types';
/**
* Implements a React {@code Component} for displaying a dialog with an field
* for setting a breakout room's name.
*
* @param {IProps} props - The props of the component.
* @returns {JSX.Element}
*/
export default function BreakoutRoomNamePrompt({ breakoutRoomJid, initialRoomName }: IProps) {
const [ roomName, setRoomName ] = useState(initialRoomName?.trim());
const { t } = useTranslation();
const okDisabled = !roomName;
const dispatch = useDispatch();
const onBreakoutRoomNameChange = useCallback((newRoomName: string) => {
setRoomName(newRoomName);
}, [ setRoomName ]);
const onSubmit = useCallback(() => {
dispatch(renameBreakoutRoom(breakoutRoomJid, roomName?.trim()));
}, [ breakoutRoomJid, dispatch, roomName ]);
return (<Dialog
ok = {{
disabled: okDisabled,
translationKey: 'dialog.Ok'
}}
onSubmit = { onSubmit }
titleKey = 'dialog.renameBreakoutRoomTitle'>
<Input
autoFocus = { true }
className = 'dialog-bottom-margin'
id = 'breakout-rooms-name-input'
label = { t('dialog.renameBreakoutRoomLabel') }
name = 'breakoutRoomName'
onChange = { onBreakoutRoomNameChange }
type = 'text'
value = { roomName } />
</Dialog>);
}

View File

@@ -0,0 +1,195 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
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 { isLocalParticipantModerator } from '../../../../../base/participants/functions';
import ListItem from '../../../../../base/ui/components/web/ListItem';
import { IRoom } from '../../../../../breakout-rooms/types';
import { showOverflowDrawer } from '../../../../../toolbox/functions.web';
import { ACTION_TRIGGER } from '../../../../constants';
import { participantMatchesSearch } from '../../../../functions';
import ParticipantActionEllipsis from '../../../web/ParticipantActionEllipsis';
import ParticipantItem from '../../../web/ParticipantItem';
interface IProps {
/**
* Type of trigger for the breakout room actions.
*/
actionsTrigger?: string;
/**
* React children.
*/
children: React.ReactNode;
/**
* Is this item highlighted/raised.
*/
isHighlighted?: boolean;
/**
* Callback for when the mouse leaves this component.
*/
onLeave?: (e?: React.MouseEvent) => void;
/**
* Callback to raise menu. Used to raise menu on mobile long press.
*/
onRaiseMenu: Function;
/**
* The raise context for the participant menu.
*/
participantContextEntity?: {
jid: string;
participantName: string;
room: IRoom;
};
/**
* Callback to raise participant context menu.
*/
raiseParticipantContextMenu: Function;
/**
* Room reference.
*/
room: {
id: string;
name: string;
participants: {
[jid: string]: {
displayName: string;
jid: string;
};
};
};
/**
* Participants search string.
*/
searchString: string;
/**
* Toggles the room participant context menu.
*/
toggleParticipantMenu: Function;
}
const useStyles = makeStyles()(theme => {
return {
container: {
boxShadow: 'none'
},
roomName: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...theme.typography.bodyLongBold
},
arrowContainer: {
backgroundColor: theme.palette.ui03,
width: '24px',
height: '24px',
borderRadius: '6px',
marginRight: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none'
}
};
});
export const CollapsibleRoom = ({
actionsTrigger = ACTION_TRIGGER.HOVER,
children,
isHighlighted,
onRaiseMenu,
onLeave,
participantContextEntity,
raiseParticipantContextMenu,
room,
searchString,
toggleParticipantMenu
}: IProps) => {
const { t } = useTranslation();
const { classes: styles, cx } = useStyles();
const [ collapsed, setCollapsed ] = useState(false);
const toggleCollapsed = useCallback(() => {
setCollapsed(!collapsed);
}, [ collapsed ]);
const raiseMenu = useCallback(target => {
onRaiseMenu(target);
}, [ onRaiseMenu ]);
const { defaultRemoteDisplayName } = useSelector((state: IReduxState) => state['features/base/config']);
const overflowDrawer: boolean = useSelector(showOverflowDrawer);
const moderator = useSelector(isLocalParticipantModerator);
const arrow = (<button
aria-label = { collapsed ? t('breakoutRooms.hideParticipantList', 'Hide participant list')
: t('breakoutRooms.showParticipantList', 'Show participant list')
}
className = { styles.arrowContainer }>
<Icon
size = { 14 }
src = { collapsed ? IconArrowDown : IconArrowUp } />
</button>);
const roomName = (<span className = { styles.roomName }>
{`${room.name || t('breakoutRooms.mainRoom')} (${Object.keys(room?.participants
|| {}).length})`}
</span>);
const raiseParticipantMenu = useCallback(({ participantID, displayName }) => moderator
&& raiseParticipantContextMenu({
room,
jid: participantID,
participantName: displayName
}), [ room, moderator ]);
return (<>
<ListItem
actions = { children }
className = { cx(styles.container, 'breakout-room-container') }
defaultName = { `${room.name || t('breakoutRooms.mainRoom')} (${Object.keys(room?.participants
|| {}).length})` }
icon = { arrow }
isHighlighted = { isHighlighted }
onClick = { toggleCollapsed }
onLongPress = { raiseMenu }
onMouseLeave = { onLeave }
testId = { room.id }
textChildren = { roomName }
trigger = { actionsTrigger } />
{!collapsed && room?.participants
&& Object.values(room?.participants || {}).map(p =>
participantMatchesSearch(p, searchString) && (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
displayName = { p.displayName || defaultRemoteDisplayName }
isHighlighted = { participantContextEntity?.jid === p.jid }
key = { p.jid }
local = { false }
openDrawerForParticipant = { raiseParticipantMenu }
overflowDrawer = { overflowDrawer }
participantID = { p.jid }>
{!overflowDrawer && moderator && (
<ParticipantActionEllipsis
accessibilityLabel = { t('breakoutRoom.more') }
onClick = { toggleParticipantMenu({ room,
jid: p.jid,
participantName: p.displayName }) } />
)}
</ParticipantItem>
))
}
</>);
};

View File

@@ -0,0 +1,52 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { createBreakoutRoomsEvent } from '../../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../../analytics/functions';
import Button from '../../../../../base/ui/components/web/Button';
import { moveToRoom } from '../../../../../breakout-rooms/actions';
interface IProps {
/**
* The room to join.
*/
room: {
id: string;
jid: string;
};
}
const useStyles = makeStyles()(theme => {
return {
button: {
marginRight: theme.spacing(2)
}
};
});
const JoinActionButton = ({ room }: IProps) => {
const { classes: styles } = useStyles();
const { t } = useTranslation();
const dispatch = useDispatch();
const onJoinRoom = useCallback(e => {
e.stopPropagation();
sendAnalytics(createBreakoutRoomsEvent('join'));
dispatch(moveToRoom(room.jid));
}, [ dispatch, room ]);
return (
<Button
accessibilityLabel = { t('breakoutRooms.actions.join') }
className = { styles.button }
labelKey = { 'breakoutRooms.actions.join' }
onClick = { onJoinRoom }
size = 'small'
testId = { `join-room-${room.id}` } />
);
};
export default JoinActionButton;

View File

@@ -0,0 +1,33 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { createBreakoutRoomsEvent } from '../../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../../analytics/functions';
import Button from '../../../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../../../base/ui/constants.web';
import { moveToRoom } from '../../../../../breakout-rooms/actions';
interface IProps {
className?: string;
}
export const LeaveButton = ({ className }: IProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const onLeave = useCallback(() => {
sendAnalytics(createBreakoutRoomsEvent('leave'));
dispatch(moveToRoom());
}, [ dispatch ]);
return (
<Button
accessibilityLabel = { t('breakoutRooms.actions.leaveBreakoutRoom') }
className = { className }
fullWidth = { true }
labelKey = { 'breakoutRooms.actions.leaveBreakoutRoom' }
onClick = { onLeave }
type = { BUTTON_TYPES.DESTRUCTIVE } />
);
};

View File

@@ -0,0 +1,53 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { openDialog } from '../../../../../base/dialog/actions';
import { IconEdit } from '../../../../../base/icons/svg';
import BreakoutRoomNamePrompt from './BreakoutRoomNamePrompt';
const useStyles = makeStyles()(_theme => {
return {
container: {
position: 'absolute',
cursor: 'pointer',
marginTop: 2,
marginLeft: 5
}
};
});
interface IProps {
breakoutRoomJid: string;
name?: string;
}
/**
* Implements the rename button component which is displayed only for renaming a breakout room which is joined by the
* user.
*
* @param {IProps} props - The props of the component.
* @returns {JSX.Element}
*/
export default function RenameButton({ breakoutRoomJid, name }: IProps) {
const dispatch = useDispatch();
const { classes, cx } = useStyles();
const onRename = useCallback(() => {
dispatch(openDialog(BreakoutRoomNamePrompt, {
breakoutRoomJid,
initialRoomName: name
}));
}, [ dispatch, breakoutRoomJid, name ]);
return (
<span
className = { cx('jitsi-icon jitsi-icon-default', classes.container) }
onClick = { onRename }>
<IconEdit
height = { 16 }
key = { 1 }
width = { 16 } />
</span>
);
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { IconDotsHorizontal } from '../../../../../base/icons/svg';
import Button from '../../../../../base/ui/components/web/Button';
interface IProps {
/**
* Click handler function.
*/
onClick: () => void;
}
const RoomActionEllipsis = ({ onClick }: IProps) => {
const { t } = useTranslation();
return (
<Button
accessibilityLabel = { t('breakoutRooms.actions.more') }
icon = { IconDotsHorizontal }
onClick = { onClick }
size = 'small' />
);
};
export default RoomActionEllipsis;

View File

@@ -0,0 +1,122 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { createBreakoutRoomsEvent } from '../../../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../../../analytics/functions';
import { openDialog } from '../../../../../base/dialog/actions';
import { IconCloseLarge, IconEdit, IconRingGroup } from '../../../../../base/icons/svg';
import { isLocalParticipantModerator } from '../../../../../base/participants/functions';
import ContextMenu from '../../../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../../../base/ui/components/web/ContextMenuItemGroup';
import { closeBreakoutRoom, moveToRoom, removeBreakoutRoom } from '../../../../../breakout-rooms/actions';
import { IRoom } from '../../../../../breakout-rooms/types';
import { showOverflowDrawer } from '../../../../../toolbox/functions.web';
import { isBreakoutRoomRenameAllowed } from '../../../../functions';
import BreakoutRoomNamePrompt from './BreakoutRoomNamePrompt';
interface IProps {
/**
* Room reference.
*/
entity?: IRoom;
/**
* Target elements against which positioning calculations are made.
*/
offsetTarget?: HTMLElement | null;
/**
* 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;
}
export const RoomContextMenu = ({
entity: room,
offsetTarget,
onEnter,
onLeave,
onSelect
}: IProps) => {
const dispatch = useDispatch();
const { t } = useTranslation();
const isLocalModerator = useSelector(isLocalParticipantModerator);
const _isBreakoutRoomRenameAllowed = useSelector(isBreakoutRoomRenameAllowed);
const _overflowDrawer = useSelector(showOverflowDrawer);
const onJoinRoom = useCallback(() => {
sendAnalytics(createBreakoutRoomsEvent('join'));
dispatch(moveToRoom(room?.jid));
}, [ dispatch, room ]);
const onRemoveBreakoutRoom = useCallback(() => {
dispatch(removeBreakoutRoom(room?.jid ?? ''));
}, [ dispatch, room ]);
const onRenameBreakoutRoom = useCallback(() => {
dispatch(openDialog(BreakoutRoomNamePrompt, {
breakoutRoomJid: room?.jid,
initialRoomName: room?.name
}));
}, [ dispatch, room ]);
const onCloseBreakoutRoom = useCallback(() => {
dispatch(closeBreakoutRoom(room?.id ?? ''));
}, [ dispatch, room ]);
const isRoomEmpty = !(room?.participants && Object.keys(room.participants).length > 0);
const actions = [
_overflowDrawer ? {
accessibilityLabel: t('breakoutRooms.actions.join'),
icon: IconRingGroup,
onClick: onJoinRoom,
text: t('breakoutRooms.actions.join')
} : null,
!room?.isMainRoom && _isBreakoutRoomRenameAllowed ? {
accessibilityLabel: t('breakoutRooms.actions.rename'),
icon: IconEdit,
id: `rename-room-${room?.id}`,
onClick: onRenameBreakoutRoom,
text: t('breakoutRooms.actions.rename')
} : null,
!room?.isMainRoom && isLocalModerator ? {
accessibilityLabel: isRoomEmpty ? t('breakoutRooms.actions.remove') : t('breakoutRooms.actions.close'),
icon: IconCloseLarge,
id: isRoomEmpty ? `remove-room-${room?.id}` : `close-room-${room?.id}`,
onClick: isRoomEmpty ? onRemoveBreakoutRoom : onCloseBreakoutRoom,
text: isRoomEmpty ? t('breakoutRooms.actions.remove') : t('breakoutRooms.actions.close')
} : null
].filter(Boolean);
const lowerMenu = useCallback(() => onSelect(true), []);
return (
<ContextMenu
activateFocusTrap = { true }
entity = { room }
isDrawerOpen = { Boolean(room) }
offsetTarget = { offsetTarget }
onClick = { lowerMenu }
onDrawerClose = { onSelect }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{/* @ts-ignore */}
<ContextMenuItemGroup actions = { actions } />
</ContextMenu>
);
};

View File

@@ -0,0 +1,110 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { isMobileBrowser } from '../../../../../base/environment/utils';
import { isLocalParticipantModerator } from '../../../../../base/participants/functions';
import { equals } from '../../../../../base/redux/functions';
import useContextMenu from '../../../../../base/ui/hooks/useContextMenu.web';
import {
getBreakoutRooms,
getBreakoutRoomsConfig,
getCurrentRoomId,
isAutoAssignParticipantsVisible,
isInBreakoutRoom
} from '../../../../../breakout-rooms/functions';
import { IRoom } from '../../../../../breakout-rooms/types';
import { AutoAssignButton } from './AutoAssignButton';
import { CollapsibleRoom } from './CollapsibleRoom';
import JoinActionButton from './JoinQuickActionButton';
import { LeaveButton } from './LeaveButton';
import RoomActionEllipsis from './RoomActionEllipsis';
import { RoomContextMenu } from './RoomContextMenu';
import { RoomParticipantContextMenu } from './RoomParticipantContextMenu';
interface IProps {
/**
* Participants search string.
*/
searchString: string;
}
const useStyles = makeStyles()(theme => {
return {
topMargin: {
marginTop: theme.spacing(3)
}
};
});
export const RoomList = ({ searchString }: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const currentRoomId = useSelector(getCurrentRoomId);
const rooms = Object.values(useSelector(getBreakoutRooms, equals))
.filter((room: IRoom) => room.id !== currentRoomId)
.sort((p1?: IRoom, p2?: IRoom) => (p1?.name || '').localeCompare(p2?.name || ''));
const inBreakoutRoom = useSelector(isInBreakoutRoom);
const isLocalModerator = useSelector(isLocalParticipantModerator);
const showAutoAssign = useSelector(isAutoAssignParticipantsVisible);
const { hideJoinRoomButton } = useSelector(getBreakoutRoomsConfig);
const [ lowerMenu, raiseMenu, toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu<IRoom>();
const [ lowerParticipantMenu, raiseParticipantMenu, toggleParticipantMenu,
participantMenuEnter, participantMenuLeave, raiseParticipantContext ] = useContextMenu<{
jid: string;
participantName: string;
room: IRoom;
}>();
const onRaiseMenu = useCallback(room => (target: HTMLElement) => raiseMenu(room, target), [ raiseMenu ]);
// close the menu when the room vanishes
useEffect(() => {
if (raiseContext.entity && !rooms.some(r => r.id === raiseContext.entity?.id)) {
lowerMenu();
}
}, [ raiseContext, rooms, lowerMenu ]);
return (
<>
{inBreakoutRoom && <LeaveButton className = { classes.topMargin } />}
{showAutoAssign && <AutoAssignButton className = { classes.topMargin } />}
<div
aria-label = { t('breakoutRooms.breakoutList', 'breakout list') }
className = { classes.topMargin }
id = 'breakout-rooms-list'
role = 'list'>
{rooms.map(room => (
<React.Fragment key = { room.id }>
<CollapsibleRoom
isHighlighted = { true }
onRaiseMenu = { onRaiseMenu(room) }
participantContextEntity = { raiseParticipantContext.entity }
raiseParticipantContextMenu = { raiseParticipantMenu }
room = { room }
searchString = { searchString }
toggleParticipantMenu = { toggleParticipantMenu }>
{!isMobileBrowser() && <>
{!hideJoinRoomButton && <JoinActionButton room = { room } />}
{isLocalModerator && !room.isMainRoom
&& <RoomActionEllipsis onClick = { toggleMenu(room) } />}
</>}
</CollapsibleRoom>
</React.Fragment>
))}
</div>
<RoomContextMenu
onEnter = { menuEnter }
onLeave = { menuLeave }
onSelect = { lowerMenu }
{ ...raiseContext } />
<RoomParticipantContextMenu
onEnter = { participantMenuEnter }
onLeave = { participantMenuLeave }
onSelect = { lowerParticipantMenu }
{ ...raiseParticipantContext } />
</>
);
};

View File

@@ -0,0 +1,135 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Avatar from '../../../../../base/avatar/components/Avatar';
import { isLocalParticipantModerator } from '../../../../../base/participants/functions';
import ContextMenu from '../../../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../../../base/ui/components/web/ContextMenuItemGroup';
import { getBreakoutRooms } from '../../../../../breakout-rooms/functions';
import { getParticipantMenuButtonsWithNotifyClick, showOverflowDrawer } from '../../../../../toolbox/functions.web';
import { NOTIFY_CLICK_MODE } from '../../../../../toolbox/types';
import SendToRoomButton from '../../../../../video-menu/components/web/SendToRoomButton';
import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../../../../video-menu/constants';
import { AVATAR_SIZE } from '../../../../constants';
interface IProps {
/**
* Room and participant jid reference.
*/
entity?: {
jid: string;
participantName: string;
room: any;
};
/**
* Target elements against which positioning calculations are made.
*/
offsetTarget?: HTMLElement | null;
/**
* Callback for the mouse entering the component.
*/
onEnter: () => void;
/**
* Callback for the mouse leaving the component.
*/
onLeave: () => void;
/**
* Callback for making a selection in the menu.
*/
onSelect: (force?: any) => void;
}
const useStyles = makeStyles()(theme => {
return {
text: {
color: theme.palette.text02,
padding: '10px 16px',
height: '40px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box'
}
};
});
export const RoomParticipantContextMenu = ({
entity,
offsetTarget,
onEnter,
onLeave,
onSelect
}: IProps) => {
const { classes: styles } = useStyles();
const { t } = useTranslation();
const isLocalModerator = useSelector(isLocalParticipantModerator);
const lowerMenu = useCallback(() => onSelect(true), [ onSelect ]);
const rooms: Object = useSelector(getBreakoutRooms);
const overflowDrawer = useSelector(showOverflowDrawer);
const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick);
const notifyClick = useCallback(
(buttonKey: string, participantId?: string) => {
const notifyMode = buttonsWithNotifyClick?.get(buttonKey);
if (!notifyMode) {
return;
}
APP.API.notifyParticipantMenuButtonClicked(
buttonKey,
participantId,
notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY
);
}, [ buttonsWithNotifyClick ]);
const breakoutRoomsButtons = useMemo(() => Object.values(rooms || {}).map((room: any) => {
if (room.id !== entity?.room?.id) {
return (<SendToRoomButton
key = { room.id }
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.SEND_PARTICIPANT_TO_ROOM, entity?.jid) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.SEND_PARTICIPANT_TO_ROOM) }
onClick = { lowerMenu }
participantID = { entity?.jid ?? '' }
room = { room } />);
}
return null;
})
.filter(Boolean), [ entity, rooms, buttonsWithNotifyClick ]);
return isLocalModerator ? (
<ContextMenu
entity = { entity }
isDrawerOpen = { Boolean(entity) }
offsetTarget = { offsetTarget }
onClick = { lowerMenu }
onDrawerClose = { onSelect }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{overflowDrawer && entity?.jid && <ContextMenuItemGroup
actions = { [ {
accessibilityLabel: entity?.participantName,
customIcon: <Avatar
displayName = { entity?.participantName }
size = { AVATAR_SIZE } />,
text: entity?.participantName
} ] } />}
<ContextMenuItemGroup>
<div className = { styles.text }>
{t('breakoutRooms.actions.sendToBreakoutRoom')}
</div>
{breakoutRoomsButtons}
</ContextMenuItemGroup>
</ContextMenu>
) : null;
};

View File

@@ -0,0 +1,54 @@
import React, { useCallback, useState } from 'react';
import { GestureResponderEvent, Text, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import styles from '../../../breakout-rooms/components/native/styles';
interface IProps {
/**
* The children to be displayed within this list.
*/
children: React.ReactNode;
/**
* Callback to invoke when the {@code CollapsibleList} is long pressed.
*/
onLongPress?: (e?: GestureResponderEvent) => void;
/**
* Collapsible list title.
*/
title: Object;
}
const CollapsibleList = ({ children, onLongPress, title }: IProps) => {
const [ collapsed, setCollapsed ] = useState(false);
const _toggleCollapsed = useCallback(() => {
setCollapsed(!collapsed);
}, [ collapsed ]);
return (
<View>
<TouchableOpacity
onLongPress = { onLongPress }
onPress = { _toggleCollapsed }
style = { styles.collapsibleList as ViewStyle }>
<TouchableOpacity
onPress = { _toggleCollapsed }
style = { styles.arrowIcon as ViewStyle }>
<Icon
size = { 18 }
src = { collapsed ? IconArrowDown : IconArrowUp } />
</TouchableOpacity>
<Text style = { styles.listTile as TextStyle }>
{ title }
</Text>
</TouchableOpacity>
{ !collapsed && children }
</View>
);
};
export default CollapsibleList;

View File

@@ -0,0 +1,67 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TouchableOpacity, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import Avatar from '../../../base/avatar/components/Avatar';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import Icon from '../../../base/icons/components/Icon';
import { IconCloseLarge } from '../../../base/icons/svg';
import { IParticipant } from '../../../base/participants/types';
import { setKnockingParticipantApproval } from '../../../lobby/actions.native';
import { getKnockingParticipantsById } from '../../../lobby/functions';
import styles from './styles';
interface IProps {
/**
* Participant reference.
*/
participant: IParticipant;
}
const ContextMenuLobbyParticipantReject = ({ participant: p }: IProps) => {
const dispatch = useDispatch();
const knockParticipantsIDArr = useSelector(getKnockingParticipantsById);
const knockParticipantIsAvailable = knockParticipantsIDArr.find(knockPartId => knockPartId === p.id);
const displayName = p.name;
const reject = useCallback(() => {
dispatch(setKnockingParticipantApproval(p.id, false));
},
[ dispatch ]);
const { t } = useTranslation();
// eslint-disable-next-line react/no-multi-comp
const renderMenuHeader = () => (
<View
style = { styles.contextMenuItemSectionAvatar as ViewStyle }>
<Avatar
participantId = { p.id }
size = { 24 } />
<Text style = { styles.contextMenuItemName }>
{ displayName }
</Text>
</View>
);
return (
<BottomSheet
addScrollViewPadding = { false }
/* eslint-disable-next-line react/jsx-no-bind */
renderHeader = { renderMenuHeader }
showSlidingView = { Boolean(knockParticipantIsAvailable) }>
<TouchableOpacity
onPress = { reject }
style = { styles.contextMenuItem as ViewStyle }>
<Icon
size = { 24 }
src = { IconCloseLarge } />
<Text style = { styles.contextMenuItemText }>{ t('participantsPane.actions.reject') }</Text>
</TouchableOpacity>
</BottomSheet>
);
};
export default ContextMenuLobbyParticipantReject;

View File

@@ -0,0 +1,124 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TouchableOpacity, View, ViewStyle } from 'react-native';
import { Divider, Text } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import {
requestDisableAudioModeration,
requestDisableVideoModeration,
requestEnableAudioModeration,
requestEnableVideoModeration
} from '../../../av-moderation/actions';
import { MEDIA_TYPE } from '../../../av-moderation/constants';
import {
isEnabled as isAvModerationEnabled,
isSupported as isAvModerationSupported
} from '../../../av-moderation/functions';
import { getCurrentConference } from '../../../base/conference/functions';
import { hideSheet, openDialog } from '../../../base/dialog/actions';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import Icon from '../../../base/icons/components/Icon';
import { IconCheck, IconRaiseHand, IconVideoOff } from '../../../base/icons/svg';
import { raiseHand } from '../../../base/participants/actions';
import { getRaiseHandsQueue, isLocalParticipantModerator } from '../../../base/participants/functions';
import { LOWER_HAND_MESSAGE } from '../../../base/tracks/constants';
import MuteEveryonesVideoDialog
from '../../../video-menu/components/native/MuteEveryonesVideoDialog';
import styles from './styles';
export const ContextMenuMore = () => {
const dispatch = useDispatch();
const muteAllVideo = useCallback(() => {
dispatch(openDialog(MuteEveryonesVideoDialog));
dispatch(hideSheet());
}, [ dispatch ]);
const conference = useSelector(getCurrentConference);
const raisedHandsQueue = useSelector(getRaiseHandsQueue);
const moderator = useSelector(isLocalParticipantModerator);
const lowerAllHands = useCallback(() => {
dispatch(raiseHand(false));
conference?.sendEndpointMessage('', { name: LOWER_HAND_MESSAGE });
dispatch(hideSheet());
}, [ dispatch ]);
const { t } = useTranslation();
const isModerationSupported = useSelector((state: IReduxState) => isAvModerationSupported()(state));
const isAudioModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
const isVideoModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.VIDEO));
const disableAudioModeration = useCallback(() => dispatch(requestDisableAudioModeration()), [ dispatch ]);
const disableVideoModeration = useCallback(() => dispatch(requestDisableVideoModeration()), [ dispatch ]);
const enableAudioModeration = useCallback(() => dispatch(requestEnableAudioModeration()), [ dispatch ]);
const enableVideoModeration = useCallback(() => dispatch(requestEnableVideoModeration()), [ dispatch ]);
return (
<BottomSheet
addScrollViewPadding = { false }
showSlidingView = { true }>
<TouchableOpacity
onPress = { muteAllVideo }
style = { styles.contextMenuItem as ViewStyle }>
<Icon
size = { 24 }
src = { IconVideoOff } />
<Text style = { styles.contextMenuItemText }>{t('participantsPane.actions.stopEveryonesVideo')}</Text>
</TouchableOpacity>
{ moderator && raisedHandsQueue.length !== 0 && <TouchableOpacity
onPress = { lowerAllHands }
style = { styles.contextMenuItem as ViewStyle }>
<Icon
size = { 24 }
src = { IconRaiseHand } />
<Text style = { styles.contextMenuItemText }>{t('participantsPane.actions.lowerAllHands')}</Text>
</TouchableOpacity> }
{isModerationSupported && <>
{/* @ts-ignore */}
<Divider style = { styles.divider } />
<View style = { styles.contextMenuItem as ViewStyle }>
<Text style = { styles.contextMenuItemText }>{t('participantsPane.actions.allow')}</Text>
</View>
{isAudioModerationEnabled
? <TouchableOpacity
onPress = { disableAudioModeration }
style = { styles.contextMenuItem as ViewStyle }>
<Text style = { styles.contextMenuItemTextNoIcon }>
{t('participantsPane.actions.audioModeration')}
</Text>
</TouchableOpacity>
: <TouchableOpacity
onPress = { enableAudioModeration }
style = { styles.contextMenuItem as ViewStyle }>
<Icon
size = { 24 }
src = { IconCheck } />
<Text style = { styles.contextMenuItemText }>
{t('participantsPane.actions.audioModeration')}
</Text>
</TouchableOpacity> }
{isVideoModerationEnabled
? <TouchableOpacity
onPress = { disableVideoModeration }
style = { styles.contextMenuItem as ViewStyle }>
<Text style = { styles.contextMenuItemTextNoIcon }>
{t('participantsPane.actions.videoModeration')}
</Text>
</TouchableOpacity>
: <TouchableOpacity
onPress = { enableVideoModeration }
style = { styles.contextMenuItem as ViewStyle }>
<Icon
size = { 24 }
src = { IconCheck } />
<Text style = { styles.contextMenuItemText }>
{t('participantsPane.actions.videoModeration')}
</Text>
</TouchableOpacity>}
</>}
</BottomSheet>
);
};

View File

@@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { IParticipant } from '../../../base/participants/types';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { setKnockingParticipantApproval } from '../../../lobby/actions.native';
import ParticipantItem from './ParticipantItem';
import styles from './styles';
interface IProps {
/**
* Participant reference.
*/
participant: IParticipant;
}
export const LobbyParticipantItem = ({ participant: p }: IProps) => {
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true)), [ dispatch, p.id ]);
const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false)), [ dispatch, p.id ]);
return (
<ParticipantItem
displayName = { p.name ?? '' }
isKnockingParticipant = { true }
key = { p.id }
participantID = { p.id } >
<Button
accessibilityLabel = 'participantsPane.actions.reject'
labelKey = 'participantsPane.actions.reject'
onClick = { reject }
style = { styles.buttonReject }
type = { BUTTON_TYPES.DESTRUCTIVE } />
<Button
accessibilityLabel = 'participantsPane.actions.admit'
labelKey = 'participantsPane.actions.admit'
onClick = { admit }
style = { styles.buttonAdmit }
type = { BUTTON_TYPES.PRIMARY } />
</ParticipantItem>
);
};

View File

@@ -0,0 +1,57 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_MODES, BUTTON_TYPES } from '../../../base/ui/constants.native';
import { admitMultiple } from '../../../lobby/actions.native';
import { getKnockingParticipants, getLobbyEnabled } from '../../../lobby/functions';
import { LobbyParticipantItem } from './LobbyParticipantItem';
import styles from './styles';
const LobbyParticipantList = () => {
const dispatch = useDispatch();
const lobbyEnabled = useSelector(getLobbyEnabled);
const participants = useSelector(getKnockingParticipants);
const admitAll = useCallback(() =>
dispatch(admitMultiple(participants)),
[ dispatch, participants ]);
const { t } = useTranslation();
const title = t('participantsPane.headings.waitingLobby',
{ count: participants.length });
if (!lobbyEnabled || !participants.length) {
return null;
}
return (
<>
<View style = { styles.listDetails as ViewStyle } >
<Text style = { styles.lobbyListDescription as TextStyle }>
{ title }
</Text>
{
participants.length > 1 && (
<Button
accessibilityLabel = 'participantsPane.actions.admitAll'
labelKey = 'participantsPane.actions.admitAll'
mode = { BUTTON_MODES.TEXT }
onClick = { admitAll }
type = { BUTTON_TYPES.PRIMARY } />
)
}
</View>
{
participants.map(p => (
<LobbyParticipantItem
key = { p.id }
participant = { p } />)
)
}
</>
);
};
export default LobbyParticipantList;

View File

@@ -0,0 +1,195 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import {
getLocalParticipant,
getParticipantById,
getParticipantDisplayName,
hasRaisedHand,
isParticipantModerator
} from '../../../base/participants/functions';
import { FakeParticipant, IParticipant } from '../../../base/participants/types';
import {
isParticipantAudioMuted,
isParticipantVideoMuted
} from '../../../base/tracks/functions.native';
import { showContextMenuDetails, showSharedVideoMenu } from '../../actions.native';
import type { MediaState } from '../../constants';
import { getParticipantAudioMediaState, getParticipantVideoMediaState } from '../../functions';
import ParticipantItem from './ParticipantItem';
interface IProps {
/**
* Media state for audio.
*/
_audioMediaState: MediaState;
/**
* Whether or not to disable the moderator indicator.
*/
_disableModeratorIndicator?: boolean;
/**
* The display name of the participant.
*/
_displayName: string;
/**
* The type of fake participant.
*/
_fakeParticipant: FakeParticipant;
/**
* Whether or not the user is a moderator.
*/
_isModerator: boolean;
/**
* True if the participant is the local participant.
*/
_local: boolean;
/**
* Shared video local participant owner.
*/
_localVideoOwner: boolean;
/**
* The participant ID.
*/
_participantID: string;
/**
* True if the participant have raised hand.
*/
_raisedHand: boolean;
/**
* Media state for video.
*/
_videoMediaState: MediaState;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The participant.
*/
participant?: IParticipant;
}
/**
* Implements the MeetingParticipantItem component.
*/
class MeetingParticipantItem extends PureComponent<IProps> {
/**
* Creates new MeetingParticipantItem instance.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onPress = this._onPress.bind(this);
}
/**
* Handles MeetingParticipantItem press events.
*
* @returns {void}
*/
_onPress() {
const {
_fakeParticipant,
_local,
_localVideoOwner,
_participantID,
dispatch
} = this.props;
if (_fakeParticipant && _localVideoOwner) {
dispatch(showSharedVideoMenu(_participantID));
} else if (!_fakeParticipant) {
dispatch(showContextMenuDetails(_participantID, _local));
} // else no-op
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_audioMediaState,
_disableModeratorIndicator,
_displayName,
_isModerator,
_local,
_participantID,
_raisedHand,
_videoMediaState
} = this.props;
return (
<ParticipantItem
audioMediaState = { _audioMediaState }
disableModeratorIndicator = { _disableModeratorIndicator }
displayName = { _displayName }
isModerator = { _isModerator }
local = { _local }
onPress = { this._onPress }
participantID = { _participantID }
raisedHand = { _raisedHand }
videoMediaState = { _videoMediaState } />
);
}
}
/**
* 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 { participant } = ownProps;
const { ownerId } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state)?.id;
const _isAudioMuted = isParticipantAudioMuted(participant, state);
const _isVideoMuted = isParticipantVideoMuted(participant, state);
const audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state);
const videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state);
const { disableModeratorIndicator } = state['features/base/config'];
const raisedHand = hasRaisedHand(participant?.local
? participant
: getParticipantById(state, participant?.id)
);
return {
_audioMediaState: audioMediaState,
_disableModeratorIndicator: disableModeratorIndicator,
_displayName: getParticipantDisplayName(state, participant?.id),
_fakeParticipant: participant?.fakeParticipant,
_isAudioMuted,
_isModerator: isParticipantModerator(participant),
_local: Boolean(participant?.local),
_localVideoOwner: Boolean(ownerId === localParticipantId),
_participantID: participant?.id,
_raisedHand: raisedHand,
_videoMediaState: videoMediaState
};
}
export default translate(connect(mapStateToProps)(MeetingParticipantItem));

View File

@@ -0,0 +1,121 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Text, TextStyle, View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconAddUser } from '../../../base/icons/svg';
import {
addPeopleFeatureControl,
getLocalParticipant,
getParticipantCountWithFake,
getRemoteParticipants,
setShareDialogVisiblity
} from '../../../base/participants/functions';
import Button from '../../../base/ui/components/native/Button';
import Input from '../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import {
getBreakoutRooms,
getCurrentRoomId
} from '../../../breakout-rooms/functions';
import { doInvitePeople } from '../../../invite/actions.native';
import { getInviteOthersControl } from '../../../share-room/functions';
import { iAmVisitor } from '../../../visitors/functions';
import { participantMatchesSearch, shouldRenderInviteButton } from '../../functions';
import MeetingParticipantItem from './MeetingParticipantItem';
import styles from './styles';
const MeetingParticipantList = () => {
const currentRoomId = useSelector(getCurrentRoomId);
const currentRoom = useSelector(getBreakoutRooms)[currentRoomId];
const dispatch = useDispatch();
const inviteOthersControl = useSelector(getInviteOthersControl);
const isAddPeopleFeatureEnabled = useSelector(addPeopleFeatureControl);
const keyExtractor
= useCallback((e: undefined, i: number) => i.toString(), []);
const localParticipant = useSelector(getLocalParticipant);
const _iAmVisitor = useSelector(iAmVisitor);
const onInvite = useCallback(() => {
setShareDialogVisiblity(isAddPeopleFeatureEnabled, dispatch);
dispatch(doInvitePeople());
}, [ dispatch ]);
const [ searchString, setSearchString ] = useState('');
const onSearchStringChange = useCallback((text: string) =>
setSearchString(text), []);
const participantsCount = useSelector(getParticipantCountWithFake);
const remoteParticipants = useSelector(getRemoteParticipants);
const renderParticipant = ({ item/* , index, separators */ }: any) => {
const participant = item === localParticipant?.id
? localParticipant : remoteParticipants.get(item);
if (participantMatchesSearch(participant, searchString)) {
return (
<MeetingParticipantItem
key = { item }
participant = { participant } />
);
}
return null;
};
const showInviteButton = useSelector(shouldRenderInviteButton);
const sortedRemoteParticipants = useSelector(
(state: IReduxState) => state['features/filmstrip'].remoteParticipants);
const { t } = useTranslation();
const title = currentRoom?.name
? `${currentRoom.name} (${participantsCount})`
: t('participantsPane.headings.participantsList',
{ count: participantsCount });
const { color, shareDialogVisible } = inviteOthersControl;
return (
<View style = { styles.meetingListContainer }>
<Text
style = { styles.meetingListDescription as TextStyle }>
{ title }
</Text>
{
showInviteButton
&& <Button
accessibilityLabel = 'participantsPane.actions.invite'
disabled = { shareDialogVisible }
// eslint-disable-next-line react/jsx-no-bind, no-confusing-arrow
icon = { () => (
<Icon
color = { color }
size = { 20 }
src = { IconAddUser } />
) }
labelKey = 'participantsPane.actions.invite'
onClick = { onInvite }
style = { styles.inviteButton }
type = { BUTTON_TYPES.PRIMARY } />
}
<Input
clearable = { true }
customStyles = {{
container: styles.inputContainer,
input: styles.centerInput }}
onChange = { onSearchStringChange }
placeholder = { t('participantsPane.search') }
value = { searchString } />
<FlatList
data = { _iAmVisitor
? [ ...sortedRemoteParticipants ]
: [ localParticipant?.id, ...sortedRemoteParticipants ] as Array<any>
}
keyExtractor = { keyExtractor }
/* eslint-disable react/jsx-no-bind */
renderItem = { renderParticipant }
windowSize = { 2 } />
</View>
);
};
export default MeetingParticipantList;

View File

@@ -0,0 +1,145 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
GestureResponderEvent,
StyleProp,
TextStyle,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { Text } from 'react-native-paper';
import Avatar from '../../../base/avatar/components/Avatar';
import { AudioStateIcons, MEDIA_STATE, type MediaState, VideoStateIcons } from '../../constants';
import { RaisedHandIndicator } from './RaisedHandIndicator';
import styles from './styles';
interface IProps {
/**
* Media state for audio.
*/
audioMediaState?: MediaState;
/**
* React children.
*/
children?: React.ReactNode;
/**
* Whether or not to disable the moderator indicator.
*/
disableModeratorIndicator?: boolean;
/**
* The name of the participant. Used for showing lobby names.
*/
displayName: string;
/**
* Is the participant waiting?
*/
isKnockingParticipant?: boolean;
/**
* Whether or not the user is a moderator.
*/
isModerator?: boolean;
/**
* True if the participant is local.
*/
local?: boolean;
/**
* Callback to be invoked on pressing the participant item.
*/
onPress?: (e?: GestureResponderEvent) => void;
/**
* The ID of the participant.
*/
participantID: string;
/**
* True if the participant have raised hand.
*/
raisedHand?: boolean;
/**
* Media state for video.
*/
videoMediaState?: MediaState;
}
/**
* Participant item.
*
* @returns {React$Element<any>}
*/
function ParticipantItem({
children,
displayName,
disableModeratorIndicator,
isKnockingParticipant = false,
isModerator,
local,
onPress,
participantID,
raisedHand,
audioMediaState = MEDIA_STATE.NONE,
videoMediaState = MEDIA_STATE.NONE
}: IProps) {
const { t } = useTranslation();
const participantNameContainerStyles
= isKnockingParticipant ? styles.lobbyParticipantNameContainer : styles.participantNameContainer;
return (
<View style = { styles.participantContainer as StyleProp<ViewStyle> } >
<TouchableOpacity
onPress = { onPress }
style = { styles.participantContent as StyleProp<ViewStyle> }>
<Avatar
displayName = { displayName }
participantId = { participantID }
size = { 32 } />
<View
style = { [
styles.participantDetailsContainer,
raisedHand && styles.participantDetailsContainerRaisedHand
] as StyleProp<ViewStyle> }>
<View style = { participantNameContainerStyles as StyleProp<ViewStyle> }>
<Text
numberOfLines = { 1 }
style = { styles.participantName as StyleProp<TextStyle> }>
{ displayName }
{ local && ` (${t('chat.you')})` }
</Text>
</View>
{
isModerator && !disableModeratorIndicator
&& <Text style = { styles.moderatorLabel as StyleProp<TextStyle> }>
{ t('videothumbnail.moderator') }
</Text>
}
</View>
{
!isKnockingParticipant
&& <>
{ raisedHand && <RaisedHandIndicator /> }
<View style = { styles.participantStatesContainer as StyleProp<ViewStyle> }>
<View style = { styles.participantStateVideo }>{ VideoStateIcons[videoMediaState] }</View>
<View>{ AudioStateIcons[audioMediaState] }</View>
</View>
</>
}
</TouchableOpacity>
{ !local && children }
</View>
);
}
export default ParticipantItem;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { Text } from 'react-native';
import { useSelector } from 'react-redux';
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
import styles from './styles';
const ParticipantsCounter = () => {
const participantsCount = useSelector(getParticipantCountForDisplay);
return <Text style = { styles.participantsBadge }>{participantsCount}</Text>;
};
export default ParticipantsCounter;

View File

@@ -0,0 +1,50 @@
import React, { useCallback } from 'react';
import { FlatList } from 'react-native';
import { useSelector } from 'react-redux';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import LobbyParticipantList from './LobbyParticipantList';
import MeetingParticipantList from './MeetingParticipantList';
import ParticipantsPaneFooter from './ParticipantsPaneFooter';
import VisitorsList from './VisitorsList';
import styles from './styles';
/**
* Participants pane.
*
* @returns {React$Element<any>}
*/
const ParticipantsPane = () => {
const isLocalModerator = useSelector(isLocalParticipantModerator);
const keyExtractor
= useCallback((e: undefined, i: number) => i.toString(), []);
const renderListHeaderComponent = () => (
<>
<VisitorsList />
<LobbyParticipantList />
<MeetingParticipantList />
</>
);
return (
<JitsiScreen
footerComponent = { isLocalModerator ? ParticipantsPaneFooter : undefined }
style = { styles.participantsPaneContainer }>
{ /* Fixes warning regarding nested lists */ }
<FlatList
// eslint-disable-next-line react/jsx-no-bind
ListHeaderComponent = { renderListHeaderComponent }
data = { [] as ReadonlyArray<undefined> }
keyExtractor = { keyExtractor }
renderItem = { null }
windowSize = { 2 } />
</JitsiScreen>
);
};
export default ParticipantsPane;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { View, ViewStyle } from 'react-native';
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 { navigate }
from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import ParticipantsCounter from './ParticipantsConter';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link ParticipantsPaneButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Participants count.
*/
_participantsCount: number;
}
/**
* Implements an {@link AbstractButton} to open the participants panel.
*/
class ParticipantsPaneButton extends AbstractButton<IProps> {
override icon = IconUsers;
override label = 'toolbar.participants';
/**
* Handles clicking / pressing the button, and opens the participants panel.
*
* @private
* @returns {void}
*/
override _handleClick() {
return navigate(screen.conference.participants);
}
/**
* Override the _getAccessibilityLabel method to incorporate the dynamic participant count.
*
* @override
* @returns {string}
*/
_getAccessibilityLabel() {
const { t, _participantsCount } = this.props;
return t('toolbar.accessibilityLabel.participants', {
participantsCount: _participantsCount
});
}
/**
* Overrides AbstractButton's {@link Component#render()}.
*
* @override
* @protected
* @returns {React.ReactElement}
*/
override render() {
return (
<View style = { styles.participantsButtonBadge as ViewStyle }>
{ super.render() }
<ParticipantsCounter />
</View>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
return {
_participantsCount: getParticipantCountForDisplay(state)
};
}
export default translate(connect(mapStateToProps)(ParticipantsPaneButton));

View File

@@ -0,0 +1,95 @@
/* eslint-disable lines-around-comment */
import React, { useCallback } from 'react';
import { View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { openDialog, openSheet } from '../../../base/dialog/actions';
import {
BREAKOUT_ROOMS_BUTTON_ENABLED
} from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconDotsHorizontal, IconRingGroup } from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import IconButton from '../../../base/ui/components/native/IconButton';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import {
navigate
} from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
// @ts-ignore
import MuteEveryoneDialog from '../../../video-menu/components/native/MuteEveryoneDialog';
import { isMoreActionsVisible, isMuteAllVisible } from '../../functions';
import { ContextMenuMore } from './ContextMenuMore';
import styles from './styles';
/**
* Implements the participants pane footer component.
*
* @returns { JSX.Element} - The participants pane footer component.
*/
const ParticipantsPaneFooter = (): JSX.Element => {
const dispatch = useDispatch();
const isBreakoutRoomsSupported = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getBreakoutRooms()?.isSupported()
);
const isBreakoutRoomsEnabled = useSelector((state: IReduxState) =>
getFeatureFlag(state, BREAKOUT_ROOMS_BUTTON_ENABLED, true)
);
const openMoreMenu = useCallback(() => dispatch(openSheet(ContextMenuMore)), [ dispatch ]);
const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)),
[ dispatch ]);
const showMoreActions = useSelector(isMoreActionsVisible);
const showMuteAll = useSelector(isMuteAllVisible);
return (
<View style = { styles.participantsPaneFooterContainer as ViewStyle }>
{
isBreakoutRoomsSupported
&& isBreakoutRoomsEnabled
&& <Button
accessibilityLabel = 'participantsPane.actions.breakoutRooms'
// eslint-disable-next-line react/jsx-no-bind, no-confusing-arrow
icon = { () => (
<Icon
color = { BaseTheme.palette.icon04 }
size = { 20 }
src = { IconRingGroup } />
) }
labelKey = 'participantsPane.actions.breakoutRooms'
// eslint-disable-next-line react/jsx-no-bind, no-confusing-arrow
onClick = { () => navigate(screen.conference.breakoutRooms) }
style = { styles.breakoutRoomsButton }
type = { BUTTON_TYPES.SECONDARY } />
}
<View style = { styles.participantsPaneFooter as ViewStyle }>
{
showMuteAll && (
<Button
accessibilityLabel = 'participantsPane.actions.muteAll'
labelKey = 'participantsPane.actions.muteAll'
onClick = { muteAll }
type = { BUTTON_TYPES.SECONDARY } />
)
}
{
showMoreActions && (
<IconButton
onPress = { openMoreMenu }
src = { IconDotsHorizontal }
style = { styles.moreButton }
type = { BUTTON_TYPES.SECONDARY } />
)
}
</View>
</View>
);
};
export default ParticipantsPaneFooter;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { StyleProp, View, ViewStyle } from 'react-native';
import Icon from '../../../base/icons/components/Icon';
import { IconRaiseHand } from '../../../base/icons/svg';
import styles from './styles';
export const RaisedHandIndicator = () => (
<View style = { styles.raisedHandIndicator as StyleProp<ViewStyle> }>
<Icon
size = { 16 }
src = { IconRaiseHand }
style = { styles.raisedHandIcon } />
</View>
);

View File

@@ -0,0 +1,145 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { hideSheet } from '../../../base/dialog/actions';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
import { translate } from '../../../base/i18n/functions';
import { getBreakoutRooms } from '../../../breakout-rooms/functions';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import SendToBreakoutRoom from '../../../video-menu/components/native/SendToBreakoutRoom';
import styles from '../../../video-menu/components/native/styles';
/**
* Size of the rendered avatar in the menu.
*/
const AVATAR_SIZE = 24;
interface IProps extends WithTranslation {
/**
* The list of all breakout rooms.
*/
_rooms: Array<any>;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The jid of the selected participant.
*/
participantJid: string;
/**
* The display name of the selected participant.
*/
participantName: string;
/**
* The room the participant is in.
*/
room: any;
}
/**
* Class to implement a popup menu that opens upon long pressing a thumbnail.
*/
class RoomParticipantMenu extends PureComponent<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onCancel = this._onCancel.bind(this);
this._renderMenuHeader = this._renderMenuHeader.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const { _rooms, participantJid, room, t } = this.props;
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
participantID: participantJid,
styles: bottomSheetStyles.buttons
};
return (
<BottomSheet
renderHeader = { this._renderMenuHeader }
showSlidingView = { true }>
<View style = { styles.contextMenuItem as ViewStyle }>
<Text style = { styles.contextMenuItemText as ViewStyle }>
{t('breakoutRooms.actions.sendToBreakoutRoom')}
</Text>
</View>
{_rooms.map(r => room.id !== r.id && (<SendToBreakoutRoom
key = { r.id }
room = { r }
{ ...buttonProps } />))}
</BottomSheet>
);
}
/**
* Callback to hide the {@code RemoteVideoMenu}.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(hideSheet());
}
/**
* Function to render the menu's header.
*
* @returns {React$Element}
*/
_renderMenuHeader() {
const { participantName } = this.props;
return (
<View
style = { [
bottomSheetStyles.sheet,
styles.participantNameContainer ] as ViewStyle[] }>
<Avatar
displayName = { participantName }
size = { AVATAR_SIZE } />
<Text style = { styles.participantNameLabel as TextStyle }>
{ participantName }
</Text>
</View>
);
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
_rooms: Object.values(getBreakoutRooms(state))
};
}
export default translate(connect(_mapStateToProps)(RoomParticipantMenu));

View File

@@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { approveRequest, denyRequest } from '../../../visitors/actions';
import { IPromotionRequest } from '../../../visitors/types';
import ParticipantItem from './ParticipantItem';
import styles from './styles';
interface IProps {
/**
* Promotion request reference.
*/
request: IPromotionRequest;
}
export const VisitorsItem = ({ request: r }: IProps) => {
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(approveRequest(r)), [ dispatch, r ]);
const reject = useCallback(() => dispatch(denyRequest(r)), [ dispatch, r ]);
const { from, nick } = r;
return (
<ParticipantItem
displayName = { nick ?? '' }
isKnockingParticipant = { true }
key = { from }
participantID = { from } >
<Button
accessibilityLabel = 'participantsPane.actions.reject'
labelKey = 'participantsPane.actions.reject'
onClick = { reject }
style = { styles.buttonReject }
type = { BUTTON_TYPES.DESTRUCTIVE } />
<Button
accessibilityLabel = 'participantsPane.actions.admit'
labelKey = 'participantsPane.actions.admit'
onClick = { admit }
style = { styles.buttonAdmit }
type = { BUTTON_TYPES.PRIMARY } />
</ParticipantItem>
);
};

View File

@@ -0,0 +1,90 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_MODES, BUTTON_TYPES } from '../../../base/ui/constants.native';
import { admitMultiple, goLive } from '../../../visitors/actions';
import {
getPromotionRequests,
getVisitorsCount,
getVisitorsInQueueCount,
isVisitorsLive
} from '../../../visitors/functions';
import { VisitorsItem } from './VisitorsItem';
import styles from './styles';
const VisitorsList = () => {
const visitorsCount = useSelector(getVisitorsCount);
const dispatch = useDispatch();
const requests = useSelector(getPromotionRequests);
const admitAll = useCallback(() => {
dispatch(admitMultiple(requests));
}, [ dispatch, requests ]);
const goLiveCb = useCallback(() => {
dispatch(goLive());
}, [ dispatch ]);
const { t } = useTranslation();
const visitorsInQueueCount = useSelector(getVisitorsInQueueCount);
const isLive = useSelector(isVisitorsLive);
const showVisitorsInQueue = visitorsInQueueCount > 0 && isLive === false;
if (visitorsCount <= 0 && !showVisitorsInQueue) {
return null;
}
let title = t('participantsPane.headings.visitors', { count: visitorsCount });
if (requests.length > 0) {
title += t('participantsPane.headings.visitorRequests', { count: requests.length });
}
if (showVisitorsInQueue) {
title += t('participantsPane.headings.visitorInQueue', { count: visitorsInQueueCount });
}
return (
<>
<View style = { styles.listDetails as ViewStyle } >
<Text style = { styles.visitorsLabel }>
{ title }
</Text>
{
requests.length > 1 && !showVisitorsInQueue && (
<Button
accessibilityLabel = 'participantsPane.actions.admitAll'
labelKey = 'participantsPane.actions.admitAll'
mode = { BUTTON_MODES.TEXT }
onClick = { admitAll }
type = { BUTTON_TYPES.PRIMARY } />
)
}
{
showVisitorsInQueue && (
<Button
accessibilityLabel = 'participantsPane.actions.goLive'
labelKey = 'participantsPane.actions.goLive'
mode = { BUTTON_MODES.TEXT }
onClick = { goLiveCb }
type = { BUTTON_TYPES.PRIMARY } />
)
}
</View>
{
requests.map(r => (
<VisitorsItem
key = { r.from }
request = { r } />)
)
}
</>
);
};
export default VisitorsList;

View File

@@ -0,0 +1,293 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
/**
* The style for participant list description.
*/
const participantListDescription = {
...BaseTheme.typography.heading6,
color: BaseTheme.palette.text01,
fontSize: 15,
fontWeight: 'bold',
marginLeft: BaseTheme.spacing[2],
paddingVertical: BaseTheme.spacing[2],
position: 'relative',
width: '70%'
};
/**
* The style for content.
*/
const flexContent = {
alignItems: 'center',
color: BaseTheme.palette.icon01,
display: 'flex',
flex: 1
};
/**
* The style for the context menu items text.
*/
const contextMenuItemText = {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01
};
/**
* The style of the participants pane buttons.
*/
export const button = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center'
};
/**
* The style of the context menu pane items.
*/
const contextMenuItem = {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
height: BaseTheme.spacing[7],
marginLeft: BaseTheme.spacing[3]
};
const participantNameContainer = {
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
paddingLeft: BaseTheme.spacing[3]
};
/**
* The styles of the native components of the feature {@code participants}.
*/
export default {
participantsBadge: {
backgroundColor: BaseTheme.palette.ui03,
borderRadius: BaseTheme.spacing[2],
borderColor: 'white',
overflow: 'hidden',
height: BaseTheme.spacing[3],
minWidth: BaseTheme.spacing[3],
color: BaseTheme.palette.text01,
...BaseTheme.typography.labelBold,
position: 'absolute',
right: -3,
top: -3,
textAlign: 'center',
paddingHorizontal: 2
},
participantsButtonBadge: {
display: 'flex',
position: 'relative'
},
participantContainer: {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
height: BaseTheme.spacing[9],
paddingLeft: BaseTheme.spacing[3],
paddingRight: BaseTheme.spacing[3],
width: '100%'
},
participantContent: {
alignItems: 'center',
borderBottomColor: BaseTheme.palette.ui02,
borderBottomWidth: 2.4,
display: 'flex',
flexDirection: 'row',
height: '100%',
overflow: 'hidden',
width: '100%'
},
participantDetailsContainer: {
display: 'flex',
flexDirection: 'column',
width: '73%'
},
participantDetailsContainerRaisedHand: {
width: '65%'
},
participantNameContainer: {
...participantNameContainer,
width: '100%'
},
lobbyParticipantNameContainer: {
...participantNameContainer,
width: '40%'
},
participantName: {
color: BaseTheme.palette.text01,
overflow: 'hidden'
},
moderatorLabel: {
color: BaseTheme.palette.text03,
alignSelf: 'flex-start',
paddingLeft: BaseTheme.spacing[3],
paddingTop: BaseTheme.spacing[1]
},
participantStatesContainer: {
display: 'flex',
flexDirection: 'row',
marginLeft: 'auto',
width: '15%'
},
participantStateVideo: {
paddingRight: BaseTheme.spacing[3]
},
raisedHandIndicator: {
backgroundColor: BaseTheme.palette.warning02,
borderRadius: BaseTheme.shape.borderRadius / 2,
height: BaseTheme.spacing[4],
width: BaseTheme.spacing[4],
marginLeft: 'auto',
marginRight: BaseTheme.spacing[2]
},
raisedHandIcon: {
...flexContent,
top: BaseTheme.spacing[1],
color: BaseTheme.palette.uiBackground
},
buttonAdmit: {
position: 'absolute',
right: 16
},
buttonReject: {
position: 'absolute',
right: 112
},
lobbyListDescription: {
...participantListDescription
},
listDetails: {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between'
},
meetingListContainer: {
paddingHorizontal: BaseTheme.spacing[3]
},
meetingListDescription: {
...participantListDescription
},
participantsPaneContainer: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1,
flexDirection: 'column',
paddingVertical: BaseTheme.spacing[2]
},
participantsPaneFooterContainer: {
alignItems: 'center',
bottom: 0,
height: 128,
left: 0,
paddingHorizontal: BaseTheme.spacing[4],
right: 0
},
participantsPaneFooter: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
paddingBottom: BaseTheme.spacing[3],
width: '100%'
},
inviteButton: {
marginLeft: BaseTheme.spacing[3],
marginRight: BaseTheme.spacing[3],
marginVertical: BaseTheme.spacing[2]
},
breakoutRoomsButton: {
marginBottom: BaseTheme.spacing[2],
width: '100%'
},
moreButton: {
marginLeft: BaseTheme.spacing[2]
},
contextMenuItem: {
...contextMenuItem
},
contextMenuItemSection: {
...contextMenuItem
},
contextMenuItemSectionAvatar: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui01,
borderBottomColor: BaseTheme.palette.ui07,
borderBottomWidth: 1,
borderTopLeftRadius: BaseTheme.spacing[3],
borderTopRightRadius: BaseTheme.spacing[3],
flexDirection: 'row',
height: BaseTheme.spacing[7],
paddingLeft: BaseTheme.spacing[3]
},
contextMenuItemText: {
...contextMenuItemText,
marginLeft: BaseTheme.spacing[3]
},
contextMenuItemTextNoIcon: {
...contextMenuItemText,
marginLeft: BaseTheme.spacing[6]
},
contextMenuItemName: {
color: BaseTheme.palette.text04,
flexShrink: 1,
fontSize: BaseTheme.spacing[3],
marginLeft: BaseTheme.spacing[3],
opacity: 0.90
},
divider: {
backgroundColor: BaseTheme.palette.ui07
},
inputContainer: {
marginLeft: BaseTheme.spacing[3],
marginRight: BaseTheme.spacing[3],
marginBottom: BaseTheme.spacing[4]
},
centerInput: {
paddingRight: BaseTheme.spacing[3],
textAlign: 'center'
},
visitorsLabel: {
...BaseTheme.typography.heading6,
color: BaseTheme.palette.warning02,
marginLeft: BaseTheme.spacing[2]
}
};

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>
}
</>
);
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
import Icon from '../base/icons/components/Icon';
import {
IconMic,
IconMicSlash,
IconVideo,
IconVideoOff
} from '../base/icons/svg';
/**
* Reducer key for the feature.
*/
export const REDUCER_KEY = 'features/participants-pane';
export type ActionTrigger = 'Hover' | 'Permanent';
/**
* Enum of possible participant action triggers.
*/
export const ACTION_TRIGGER: { HOVER: ActionTrigger; PERMANENT: ActionTrigger; } = {
HOVER: 'Hover',
PERMANENT: 'Permanent'
};
export type MediaState = 'DominantSpeaker' | 'Muted' | 'ForceMuted' | 'Unmuted' | 'None';
/**
* Enum of possible participant media states.
*/
export const MEDIA_STATE: { [key: string]: MediaState; } = {
DOMINANT_SPEAKER: 'DominantSpeaker',
MUTED: 'Muted',
FORCE_MUTED: 'ForceMuted',
UNMUTED: 'Unmuted',
NONE: 'None'
};
export type QuickActionButtonType
= 'Mute' | 'AskToUnmute' | 'AllowVideo' | 'StopVideo' | 'AllowDesktop' | 'StopDesktop' | 'None';
/**
* Enum of possible participant mute button states.
*/
export const QUICK_ACTION_BUTTON: {
ALLOW_DESKTOP: QuickActionButtonType;
ALLOW_VIDEO: QuickActionButtonType;
ASK_TO_UNMUTE: QuickActionButtonType;
MUTE: QuickActionButtonType;
NONE: QuickActionButtonType;
STOP_DESKTOP: QuickActionButtonType;
STOP_VIDEO: QuickActionButtonType;
} = {
ALLOW_DESKTOP: 'AllowDesktop',
ALLOW_VIDEO: 'AllowVideo',
MUTE: 'Mute',
ASK_TO_UNMUTE: 'AskToUnmute',
NONE: 'None',
STOP_DESKTOP: 'StopDesktop',
STOP_VIDEO: 'StopVideo'
};
/**
* Icon mapping for possible participant audio states.
*/
export const AudioStateIcons = {
[MEDIA_STATE.DOMINANT_SPEAKER]: (
<Icon
className = 'jitsi-icon-dominant-speaker'
size = { 16 }
src = { IconMic } />
),
[MEDIA_STATE.FORCE_MUTED]: (
<Icon
color = '#E04757'
id = 'audioMuted'
size = { 16 }
src = { IconMicSlash } />
),
[MEDIA_STATE.MUTED]: (
<Icon
id = 'audioMuted'
size = { 16 }
src = { IconMicSlash } />
),
[MEDIA_STATE.UNMUTED]: (
<Icon
size = { 16 }
src = { IconMic } />
),
[MEDIA_STATE.NONE]: null
};
/**
* Icon mapping for possible participant video states.
*/
export const VideoStateIcons = {
[MEDIA_STATE.DOMINANT_SPEAKER]: null,
[MEDIA_STATE.FORCE_MUTED]: (
<Icon
color = '#E04757'
id = 'videoMuted'
size = { 16 }
src = { IconVideoOff } />
),
[MEDIA_STATE.MUTED]: (
<Icon
id = 'videoMuted'
size = { 16 }
src = { IconVideoOff } />
),
[MEDIA_STATE.UNMUTED]: (
<Icon
size = { 16 }
src = { IconVideo } />
),
[MEDIA_STATE.NONE]: null
};
/**
* Mobile web context menu avatar size.
*/
export const AVATAR_SIZE = 20;

View File

@@ -0,0 +1,327 @@
import { IReduxState } from '../app/types';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../av-moderation/constants';
import {
isForceMuted,
isSupported
} from '../av-moderation/functions';
import { IStateful } from '../base/app/types';
import theme from '../base/components/themes/participantsPaneTheme.json';
import { getCurrentConference } from '../base/conference/functions';
import { INVITE_ENABLED, PARTICIPANTS_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
import {
getDominantSpeakerParticipant,
getLocalParticipant,
getRaiseHandsQueue,
getRemoteParticipantsSorted,
isLocalParticipantModerator
} from '../base/participants/functions';
import { IParticipant } from '../base/participants/types';
import { toState } from '../base/redux/functions';
import {
isParticipantAudioMuted,
isParticipantScreenShareMuted,
isParticipantVideoMuted
} from '../base/tracks/functions.any';
import { normalizeAccents } from '../base/util/strings';
import { BREAKOUT_ROOMS_RENAME_FEATURE } from '../breakout-rooms/constants';
import { isInBreakoutRoom } from '../breakout-rooms/functions';
import { MEDIA_STATE, QUICK_ACTION_BUTTON, REDUCER_KEY } from './constants';
/**
* Determines the audio media state (the mic icon) for a participant.
*
* @param {IParticipant} participant - The participant.
* @param {boolean} muted - The mute state of the participant.
* @param {IReduxState} state - The redux state.
* @returns {MediaState}
*/
export function getParticipantAudioMediaState(participant: IParticipant | undefined,
muted: Boolean, state: IReduxState) {
const dominantSpeaker = getDominantSpeakerParticipant(state);
if (participant?.isSilent) {
return MEDIA_STATE.NONE;
}
if (muted) {
if (isForceMuted(participant, AVM_MEDIA_TYPE.AUDIO, state)) {
return MEDIA_STATE.FORCE_MUTED;
}
return MEDIA_STATE.MUTED;
}
if (participant === dominantSpeaker) {
return MEDIA_STATE.DOMINANT_SPEAKER;
}
return MEDIA_STATE.UNMUTED;
}
/**
* Determines the video media state (the mic icon) for a participant.
*
* @param {IParticipant} participant - The participant.
* @param {boolean} muted - The mute state of the participant.
* @param {IReduxState} state - The redux state.
* @returns {MediaState}
*/
export function getParticipantVideoMediaState(participant: IParticipant | undefined,
muted: Boolean, state: IReduxState) {
if (muted) {
if (isForceMuted(participant, AVM_MEDIA_TYPE.VIDEO, state)) {
return MEDIA_STATE.FORCE_MUTED;
}
return MEDIA_STATE.MUTED;
}
return MEDIA_STATE.UNMUTED;
}
/**
* Returns this feature's root state.
*
* @param {IReduxState} state - Global state.
* @returns {Object} Feature state.
*/
const getState = (state: IReduxState) => state[REDUCER_KEY];
/**
* Returns the participants pane config.
*
* @param {IStateful} stateful - The redux store, the redux
* {@code getState} function, or the redux state itself.
* @returns {Object}
*/
export const getParticipantsPaneConfig = (stateful: IStateful) => {
const state = toState(stateful);
const { participantsPane = {} } = state['features/base/config'];
return participantsPane;
};
/**
* Is the participants pane open.
*
* @param {IReduxState} state - Global state.
* @returns {boolean} Is the participants pane open.
*/
export const getParticipantsPaneOpen = (state: IReduxState) => Boolean(getState(state)?.isOpen);
/**
* Returns the type of quick action button to be displayed for a participant.
* The button is displayed when hovering a participant from the participant list.
*
* @param {IParticipant} participant - The participant.
* @param {IReduxState} state - The redux state.
* @returns {string} - The type of the quick action button.
*/
export function getQuickActionButtonType(participant: IParticipant | undefined, state: IReduxState) {
if (!isLocalParticipantModerator(state)) {
return QUICK_ACTION_BUTTON.NONE;
}
// Handled only by moderators.
const isAudioMuted = isParticipantAudioMuted(participant, state);
const isScreenShareMuted = isParticipantScreenShareMuted(participant, state);
const isVideoMuted = isParticipantVideoMuted(participant, state);
const isDesktopForceMuted = isForceMuted(participant, AVM_MEDIA_TYPE.DESKTOP, state);
const isVideoForceMuted = isForceMuted(participant, AVM_MEDIA_TYPE.VIDEO, state);
const isParticipantSilent = participant?.isSilent ?? false;
if (!isAudioMuted && !isParticipantSilent) {
return QUICK_ACTION_BUTTON.MUTE;
}
if (!isVideoMuted) {
return QUICK_ACTION_BUTTON.STOP_VIDEO;
}
if (!isScreenShareMuted) {
return QUICK_ACTION_BUTTON.STOP_DESKTOP;
}
if (isSupported()(state) && !isParticipantSilent) {
return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
}
if (isVideoForceMuted) {
return QUICK_ACTION_BUTTON.ALLOW_VIDEO;
}
if (isDesktopForceMuted) {
return QUICK_ACTION_BUTTON.ALLOW_DESKTOP;
}
return QUICK_ACTION_BUTTON.NONE;
}
/**
* Returns true if the invite button should be rendered.
*
* @param {IReduxState} state - Global state.
* @returns {boolean}
*/
export const shouldRenderInviteButton = (state: IReduxState) => {
const { disableInviteFunctions } = toState(state)['features/base/config'];
const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
const inBreakoutRoom = isInBreakoutRoom(state);
return flagEnabled && !disableInviteFunctions && !inBreakoutRoom;
};
/**
* Selector for retrieving ids of participants in the order that they are displayed in the filmstrip (with the
* exception of participants with raised hand). The participants are reordered as follows.
* 1. Dominant speaker.
* 2. Local participant.
* 3. Participants with raised hand.
* 4. Participants with screenshare sorted alphabetically by their display name.
* 5. Shared video participants.
* 6. Recent speakers sorted alphabetically by their display name.
* 7. Rest of the participants sorted alphabetically by their display name.
*
* @param {IStateful} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state features/base/participants.
* @returns {Array<string>}
*/
export function getSortedParticipantIds(stateful: IStateful) {
const id = getLocalParticipant(stateful)?.id;
const remoteParticipants = getRemoteParticipantsSorted(stateful);
const reorderedParticipants = new Set(remoteParticipants);
const raisedHandParticipants = getRaiseHandsQueue(stateful).map(({ id: particId }) => particId);
const remoteRaisedHandParticipants = new Set(raisedHandParticipants || []);
const dominantSpeaker = getDominantSpeakerParticipant(stateful);
for (const participant of remoteRaisedHandParticipants.keys()) {
// Avoid duplicates.
if (reorderedParticipants.has(participant)) {
reorderedParticipants.delete(participant);
}
}
const dominant = [];
const dominantId = dominantSpeaker?.id;
const local = remoteRaisedHandParticipants.has(id ?? '') ? [] : [ id ];
// In case dominat speaker has raised hand, keep the order in the raised hand queue.
// In case they don't have raised hand, goes first in the participants list.
if (dominantId && dominantId !== id && !remoteRaisedHandParticipants.has(dominantId)) {
reorderedParticipants.delete(dominantId);
dominant.push(dominantId);
}
// Move self and participants with raised hand to the top of the list.
return [
...dominant,
...local,
...Array.from(remoteRaisedHandParticipants.keys()),
...Array.from(reorderedParticipants.keys())
];
}
/**
* Checks if a participant matches the search string.
*
* @param {Object} participant - The participant to be checked.
* @param {string} searchString - The participants search string.
* @returns {boolean}
*/
export function participantMatchesSearch(participant: IParticipant | undefined
| { displayName?: string; name?: string; },
searchString: string) {
if (searchString === '') {
return true;
}
const participantName = normalizeAccents(participant?.name || participant?.displayName || '')
.toLowerCase();
const lowerCaseSearchString = searchString.trim().toLowerCase();
return participantName.includes(lowerCaseSearchString);
}
/**
* Returns whether the more actions button is visible.
*
* @param {IReduxState} state - Global state.
* @returns {boolean}
*/
export const isMoreActionsVisible = (state: IReduxState) => {
const isLocalModerator = isLocalParticipantModerator(state);
const inBreakoutRoom = isInBreakoutRoom(state);
const { hideMoreActionsButton } = getParticipantsPaneConfig(state);
return inBreakoutRoom ? false : !hideMoreActionsButton && isLocalModerator;
};
/**
* Returns whether the mute all button is visible.
*
* @param {IReduxState} state - Global state.
* @returns {boolean}
*/
export const isMuteAllVisible = (state: IReduxState) => {
const isLocalModerator = isLocalParticipantModerator(state);
const inBreakoutRoom = isInBreakoutRoom(state);
const { hideMuteAllButton } = getParticipantsPaneConfig(state);
return inBreakoutRoom ? false : !hideMuteAllButton && isLocalModerator;
};
/**
* Returns true if renaming the currently joined breakout room is allowed and false otherwise.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} - True if renaming the currently joined breakout room is allowed and false otherwise.
*/
export function isCurrentRoomRenamable(state: IReduxState) {
return isInBreakoutRoom(state) && isBreakoutRoomRenameAllowed(state);
}
/**
* Returns true if renaming a breakout room is allowed and false otherwise.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} - True if renaming a breakout room is allowed and false otherwise.
*/
export function isBreakoutRoomRenameAllowed(state: IReduxState) {
const isLocalModerator = isLocalParticipantModerator(state);
const conference = getCurrentConference(state);
const isRenameBreakoutRoomsSupported
= conference?.getBreakoutRooms()?.isFeatureSupported(BREAKOUT_ROOMS_RENAME_FEATURE) ?? false;
return isLocalModerator && isRenameBreakoutRoomsSupported;
}
/**
* Returns true if participants is enabled and false otherwise.
*
* @param {IStateful} stateful - The redux store, the redux
* {@code getState} function, or the redux state itself.
* @returns {boolean}
*/
export const isParticipantsPaneEnabled = (stateful: IStateful) => {
const state = toState(stateful);
const { enabled = true } = getParticipantsPaneConfig(state);
return Boolean(getFeatureFlag(state, PARTICIPANTS_ENABLED, true) && enabled);
};
/**
* Returns the width of the participants pane based on its open state.
*
* @param {IReduxState} state - The Redux state object containing the application state.
* @returns {number} - The width of the participants pane in pixels when open, or 0 when closed.
*/
export function getParticipantsPaneWidth(state: IReduxState) {
const { isOpen } = getState(state);
if (isOpen) {
return theme.participantsPaneWidth;
}
return 0;
}

View File

@@ -0,0 +1,81 @@
import { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { handleLobbyChatInitialized } from '../chat/actions.web';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../lobby/actions.web';
import ParticipantsPaneButton from './components/web/ParticipantsPaneButton';
import { isParticipantsPaneEnabled } from './functions';
interface IDrawerParticipant {
displayName?: string;
participantID: string;
}
const participants = {
key: 'participants-pane',
Content: ParticipantsPaneButton,
group: 2
};
/**
* Hook used to create admit/reject lobby actions.
*
* @param {Object} participant - The participant for which the actions are created.
* @param {Function} closeDrawer - Callback for closing the drawer.
* @returns {Array<Function>}
*/
export function useLobbyActions(participant?: IDrawerParticipant | null, closeDrawer?: Function) {
const dispatch = useDispatch();
return [
useCallback(e => {
e.stopPropagation();
dispatch(approveKnockingParticipant(participant?.participantID ?? ''));
closeDrawer?.();
}, [ dispatch, closeDrawer, participant?.participantID ]),
useCallback(() => {
dispatch(rejectKnockingParticipant(participant?.participantID ?? ''));
closeDrawer?.();
}, [ dispatch, closeDrawer, participant?.participantID ]),
useCallback(() => {
dispatch(handleLobbyChatInitialized(participant?.participantID ?? ''));
}, [ dispatch, participant?.participantID ])
];
}
/**
* Hook used to create actions & state for opening a drawer.
*
* @returns {Array<any>}
*/
export function useParticipantDrawer(): [
IDrawerParticipant | null,
() => void,
(p: IDrawerParticipant | null) => void ] {
const [ drawerParticipant, openDrawerForParticipant ] = useState<IDrawerParticipant | null>(null);
const closeDrawer = useCallback(() => {
openDrawerForParticipant(null);
}, []);
return [
drawerParticipant,
closeDrawer,
openDrawerForParticipant
];
}
/**
* A hook that returns the participants pane button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useParticipantPaneButton() {
const participantsPaneEnabled = useSelector(isParticipantsPaneEnabled);
if (participantsPaneEnabled) {
return participants;
}
}

View File

@@ -0,0 +1,28 @@
import { AnyAction } from 'redux';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { PARTICIPANTS_PANE_CLOSE, PARTICIPANTS_PANE_OPEN } from './actionTypes';
/**
* Middleware which intercepts participants pane actions.
*
* @param {IStore} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(() => (next: Function) => (action: AnyAction) => {
switch (action.type) {
case PARTICIPANTS_PANE_OPEN:
if (typeof APP !== 'undefined') {
APP.API.notifyParticipantsPaneToggled(true);
}
break;
case PARTICIPANTS_PANE_CLOSE:
if (typeof APP !== 'undefined') {
APP.API.notifyParticipantsPaneToggled(false);
}
break;
}
return next(action);
});

View File

@@ -0,0 +1,54 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
PARTICIPANTS_PANE_CLOSE,
PARTICIPANTS_PANE_OPEN,
SET_VOLUME
} from './actionTypes';
import { REDUCER_KEY } from './constants';
export interface IParticipantsPaneState {
isOpen: boolean;
participantsVolume: {
[participantId: string]: number;
};
}
const DEFAULT_STATE = {
isOpen: false,
participantsVolume: {}
};
/**
* Listen for actions that mutate the participants pane state.
*/
ReducerRegistry.register(
REDUCER_KEY, (state: IParticipantsPaneState = DEFAULT_STATE, action) => {
switch (action.type) {
case PARTICIPANTS_PANE_CLOSE:
return {
...state,
isOpen: false
};
case PARTICIPANTS_PANE_OPEN:
return {
...state,
isOpen: true
};
case SET_VOLUME:
return {
...state,
participantsVolume: {
...state.participantsVolume,
[action.participantId]: action.volume
}
};
default:
return state;
}
}
);

View File

@@ -0,0 +1,36 @@
/**
* The type of the React {@code Component} props of {@link BreakoutRoomNamePrompt}.
*/
export interface IBreakoutRoomNamePromptProps {
/**
* The jid of the breakout room to rename.
*/
breakoutRoomJid: string;
/**
* The initial breakout room name.
*/
initialRoomName: string;
}
/**
* The available actions for breakout rooms context menu.
*/
export enum BREAKOUT_CONTEXT_MENU_ACTIONS {
/**
* Join breakout room.
*/
JOIN = 1,
/**
* Rename breakout room.
*/
RENAME = 2,
/**
* Remove breakout room.
*/
REMOVE = 3
}