This commit is contained in:
14
react/features/participants-pane/actionTypes.ts
Normal file
14
react/features/participants-pane/actionTypes.ts
Normal 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';
|
||||
12
react/features/participants-pane/actions.any.ts
Normal file
12
react/features/participants-pane/actions.any.ts
Normal 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
|
||||
};
|
||||
};
|
||||
105
react/features/participants-pane/actions.native.ts
Normal file
105
react/features/participants-pane/actions.native.ts
Normal 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
|
||||
};
|
||||
};
|
||||
14
react/features/participants-pane/actions.web.ts
Normal file
14
react/features/participants-pane/actions.web.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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 } />
|
||||
);
|
||||
};
|
||||
@@ -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 } />
|
||||
);
|
||||
};
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
}
|
||||
</>);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 } />
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 } />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
293
react/features/participants-pane/components/native/styles.ts
Normal file
293
react/features/participants-pane/components/native/styles.ts
Normal 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]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
|
||||
import { normalizeAccents } from '../../../base/util/strings.web';
|
||||
import { subscribeVisitorsList } from '../../../visitors/actions';
|
||||
import {
|
||||
getVisitorsCount,
|
||||
getVisitorsList,
|
||||
isVisitorsListEnabled,
|
||||
isVisitorsListSubscribed,
|
||||
shouldDisplayCurrentVisitorsList
|
||||
} from '../../../visitors/functions';
|
||||
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
|
||||
|
||||
import ParticipantItem from './ParticipantItem';
|
||||
|
||||
/**
|
||||
* Props for the {@code CurrentVisitorsList} component.
|
||||
*/
|
||||
interface IProps {
|
||||
searchString: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
marginTop: theme.spacing(3),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
flexGrow: 1
|
||||
},
|
||||
heading: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: `${theme.spacing(1)} 0`,
|
||||
...theme.typography.bodyShortBold,
|
||||
color: theme.palette.text02,
|
||||
flexShrink: 0
|
||||
},
|
||||
arrowContainer: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
marginLeft: theme.spacing(2),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 'none'
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
minHeight: '200px',
|
||||
maxHeight: '100%'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the visitors list inside the participants pane.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React$Element<any>} The component.
|
||||
*/
|
||||
export default function CurrentVisitorsList({ searchString }: IProps) {
|
||||
const visitorsCount = useSelector(getVisitorsCount);
|
||||
const visitors = useSelector(getVisitorsList);
|
||||
const featureEnabled = useSelector(isVisitorsListEnabled);
|
||||
const shouldDisplayList = useSelector(shouldDisplayCurrentVisitorsList);
|
||||
const { defaultRemoteDisplayName } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const [ collapsed, setCollapsed ] = useState(true);
|
||||
const isSubscribed = useSelector(isVisitorsListSubscribed);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed(c => {
|
||||
const newCollapsed = !c;
|
||||
|
||||
if (featureEnabled && !newCollapsed && !isSubscribed) {
|
||||
dispatch(subscribeVisitorsList());
|
||||
}
|
||||
|
||||
return newCollapsed;
|
||||
});
|
||||
}, [ dispatch, isSubscribed, featureEnabled ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (featureEnabled && searchString) {
|
||||
setCollapsed(false);
|
||||
if (!isSubscribed) {
|
||||
dispatch(subscribeVisitorsList());
|
||||
}
|
||||
}
|
||||
}, [ searchString, dispatch, isSubscribed, featureEnabled ]);
|
||||
|
||||
if (!shouldDisplayList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filtered = visitors.filter(v => {
|
||||
const displayName = v.name || defaultRemoteDisplayName || 'Fellow Jitster';
|
||||
|
||||
return normalizeAccents(displayName).toLowerCase().includes(normalizeAccents(searchString).toLowerCase());
|
||||
});
|
||||
|
||||
// ListItem height is 56px including padding so the item size
|
||||
// for virtualization needs to match it exactly to avoid clipping.
|
||||
const itemSize = 56;
|
||||
|
||||
const Row = ({ index, style }: { index: number; style: any; }) => {
|
||||
const v = filtered[index];
|
||||
|
||||
return (
|
||||
<div style = { style }>
|
||||
<ParticipantItem
|
||||
actionsTrigger = { ACTION_TRIGGER.HOVER }
|
||||
audioMediaState = { MEDIA_STATE.NONE }
|
||||
displayName = { v.name || defaultRemoteDisplayName || 'Fellow Jitster' }
|
||||
participantID = { v.id }
|
||||
videoMediaState = { MEDIA_STATE.NONE } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
overflowX: 'hidden' as const,
|
||||
overflowY: 'auto' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<div
|
||||
className = { classes.heading }
|
||||
onClick = { toggleCollapsed }>
|
||||
<span>{ t('participantsPane.headings.visitorsList', { count: visitorsCount }) }</span>
|
||||
<span className = { classes.arrowContainer }>
|
||||
<Icon
|
||||
size = { 14 }
|
||||
src = { collapsed ? IconArrowDown : IconArrowUp } />
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className = { classes.listContainer }>
|
||||
<AutoSizer>
|
||||
{ ({ height, width }) => (
|
||||
<FixedSizeList
|
||||
height = { Math.max(height, 200) }
|
||||
itemCount = { filtered.length }
|
||||
itemSize = { itemSize }
|
||||
style = { styles }
|
||||
width = { width }>
|
||||
{ Row }
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import {
|
||||
requestDisableAudioModeration,
|
||||
requestDisableDesktopModeration,
|
||||
requestDisableVideoModeration,
|
||||
requestEnableAudioModeration,
|
||||
requestEnableDesktopModeration,
|
||||
requestEnableVideoModeration
|
||||
} from '../../../av-moderation/actions';
|
||||
import { MEDIA_TYPE } from '../../../av-moderation/constants';
|
||||
import {
|
||||
isEnabled as isAvModerationEnabled,
|
||||
isSupported as isAvModerationSupported
|
||||
} from '../../../av-moderation/functions';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCloseLarge,
|
||||
IconDotsHorizontal,
|
||||
IconScreenshare,
|
||||
IconVideoOff
|
||||
} from '../../../base/icons/svg';
|
||||
import { getRaiseHandsQueue } from '../../../base/participants/functions';
|
||||
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
|
||||
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
|
||||
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
|
||||
import { openSettingsDialog } from '../../../settings/actions.web';
|
||||
import { SETTINGS_TABS } from '../../../settings/constants';
|
||||
import { shouldShowModeratorSettings } from '../../../settings/functions.web';
|
||||
import LowerHandButton from '../../../video-menu/components/web/LowerHandButton';
|
||||
import MuteEveryonesDesktopDialog from '../../../video-menu/components/web/MuteEveryonesDesktopDialog';
|
||||
import MuteEveryonesVideoDialog from '../../../video-menu/components/web/MuteEveryonesVideoDialog';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
contextMenu: {
|
||||
bottom: 'auto',
|
||||
margin: '0',
|
||||
right: 0,
|
||||
top: '-8px',
|
||||
transform: 'translateY(-100%)',
|
||||
width: '283px'
|
||||
},
|
||||
|
||||
text: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text02,
|
||||
padding: '10px 16px',
|
||||
height: '40px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box'
|
||||
},
|
||||
|
||||
indentedLabel: {
|
||||
'& > span': {
|
||||
marginLeft: '36px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Whether the menu is open.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Drawer close callback.
|
||||
*/
|
||||
onDrawerClose: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback for the mouse leaving this item.
|
||||
*/
|
||||
onMouseLeave?: (e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const FooterContextMenu = ({ isOpen, onDrawerClose, onMouseLeave }: IProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const isModerationSupported = useSelector((state: IReduxState) => isAvModerationSupported()(state));
|
||||
const raisedHandsQueue = useSelector(getRaiseHandsQueue);
|
||||
const isModeratorSettingsTabEnabled = useSelector(shouldShowModeratorSettings);
|
||||
const isAudioModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
|
||||
const isDesktopModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.DESKTOP));
|
||||
const isVideoModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.VIDEO));
|
||||
const isBreakoutRoom = useSelector(isInBreakoutRoom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const disableAudioModeration = useCallback(() => dispatch(requestDisableAudioModeration()), [ dispatch ]);
|
||||
const disableDesktopModeration = useCallback(() => dispatch(requestDisableDesktopModeration()), [ dispatch ]);
|
||||
const disableVideoModeration = useCallback(() => dispatch(requestDisableVideoModeration()), [ dispatch ]);
|
||||
const enableAudioModeration = useCallback(() => dispatch(requestEnableAudioModeration()), [ dispatch ]);
|
||||
const enableDesktopModeration = useCallback(() => dispatch(requestEnableDesktopModeration()), [ dispatch ]);
|
||||
const enableVideoModeration = useCallback(() => dispatch(requestEnableVideoModeration()), [ dispatch ]);
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
const muteAllVideo = useCallback(
|
||||
() => dispatch(openDialog(MuteEveryonesVideoDialog)), [ dispatch ]);
|
||||
|
||||
const muteAllDesktop = useCallback(
|
||||
() => dispatch(openDialog(MuteEveryonesDesktopDialog)), [ dispatch ]);
|
||||
|
||||
const openModeratorSettings = () => dispatch(openSettingsDialog(SETTINGS_TABS.MODERATOR));
|
||||
|
||||
const actions = [
|
||||
{
|
||||
accessibilityLabel: t('participantsPane.actions.audioModeration'),
|
||||
className: isAudioModerationEnabled ? classes.indentedLabel : '',
|
||||
id: isAudioModerationEnabled
|
||||
? 'participants-pane-context-menu-stop-audio-moderation'
|
||||
: 'participants-pane-context-menu-start-audio-moderation',
|
||||
icon: isAudioModerationEnabled ? IconCloseLarge : IconCheck,
|
||||
onClick: isAudioModerationEnabled ? disableAudioModeration : enableAudioModeration,
|
||||
text: t('participantsPane.actions.audioModeration')
|
||||
}, {
|
||||
accessibilityLabel: t('participantsPane.actions.videoModeration'),
|
||||
className: isVideoModerationEnabled ? classes.indentedLabel : '',
|
||||
id: isVideoModerationEnabled
|
||||
? 'participants-pane-context-menu-stop-video-moderation'
|
||||
: 'participants-pane-context-menu-start-video-moderation',
|
||||
icon: isVideoModerationEnabled ? IconCloseLarge : IconCheck,
|
||||
onClick: isVideoModerationEnabled ? disableVideoModeration : enableVideoModeration,
|
||||
text: t('participantsPane.actions.videoModeration')
|
||||
}, {
|
||||
accessibilityLabel: t('participantsPane.actions.desktopModeration'),
|
||||
className: isDesktopModerationEnabled ? classes.indentedLabel : '',
|
||||
id: isDesktopModerationEnabled
|
||||
? 'participants-pane-context-menu-stop-desktop-moderation'
|
||||
: 'participants-pane-context-menu-start-desktop-moderation',
|
||||
icon: isDesktopModerationEnabled ? IconCloseLarge : IconCheck,
|
||||
onClick: isDesktopModerationEnabled ? disableDesktopModeration : enableDesktopModeration,
|
||||
text: t('participantsPane.actions.desktopModeration')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
activateFocusTrap = { true }
|
||||
className = { classes.contextMenu }
|
||||
hidden = { !isOpen }
|
||||
isDrawerOpen = { isOpen }
|
||||
onDrawerClose = { onDrawerClose }
|
||||
onMouseLeave = { onMouseLeave }>
|
||||
<ContextMenuItemGroup
|
||||
actions = { [
|
||||
{
|
||||
accessibilityLabel: t('participantsPane.actions.stopEveryonesVideo'),
|
||||
id: 'participants-pane-context-menu-stop-video',
|
||||
icon: IconVideoOff,
|
||||
onClick: muteAllVideo,
|
||||
text: t('participantsPane.actions.stopEveryonesVideo')
|
||||
},
|
||||
{
|
||||
accessibilityLabel: t('participantsPane.actions.stopEveryonesDesktop'),
|
||||
id: 'participants-pane-context-menu-stop-desktop',
|
||||
icon: IconScreenshare,
|
||||
onClick: muteAllDesktop,
|
||||
text: t('participantsPane.actions.stopEveryonesDesktop')
|
||||
}
|
||||
] } />
|
||||
{raisedHandsQueue.length !== 0 && <LowerHandButton />}
|
||||
{!isBreakoutRoom && isModerationSupported && (
|
||||
<ContextMenuItemGroup actions = { actions }>
|
||||
<div className = { classes.text }>
|
||||
<span>{t('participantsPane.actions.allow')}</span>
|
||||
</div>
|
||||
</ContextMenuItemGroup>
|
||||
)}
|
||||
{isModeratorSettingsTabEnabled && (
|
||||
<ContextMenuItemGroup
|
||||
actions = { [ {
|
||||
accessibilityLabel: t('participantsPane.actions.moreModerationControls'),
|
||||
id: 'participants-pane-open-moderation-control-settings',
|
||||
icon: IconDotsHorizontal,
|
||||
onClick: openModeratorSettings,
|
||||
text: t('participantsPane.actions.moreModerationControls')
|
||||
} ] } />
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { IconAddUser } from '../../../base/icons/svg';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { beginAddPeople } from '../../../invite/actions';
|
||||
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
|
||||
|
||||
const INVITE_BUTTON_KEY = 'invite';
|
||||
|
||||
export const InviteButton = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const notifyMode = useSelector(
|
||||
(state: IReduxState) => state['features/toolbox'].buttonsWithNotifyClick?.get(INVITE_BUTTON_KEY));
|
||||
|
||||
const onInvite = useCallback(() => {
|
||||
if (notifyMode) {
|
||||
APP.API.notifyToolbarButtonClicked(
|
||||
INVITE_BUTTON_KEY, notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY
|
||||
);
|
||||
}
|
||||
|
||||
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendAnalytics(createToolbarEvent(INVITE_BUTTON_KEY));
|
||||
dispatch(beginAddPeople());
|
||||
}, [ dispatch, notifyMode ]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { t('participantsPane.actions.invite') }
|
||||
fullWidth = { true }
|
||||
icon = { IconAddUser }
|
||||
labelKey = { 'participantsPane.actions.invite' }
|
||||
onClick = { onInvite }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconDotsHorizontal, IconMessage, IconUserDeleted } from '../../../base/icons/svg';
|
||||
import { hasRaisedHand } from '../../../base/participants/functions';
|
||||
import { IParticipant } from '../../../base/participants/types';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
|
||||
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { showLobbyChatButton } from '../../../lobby/functions';
|
||||
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
|
||||
import { useLobbyActions } from '../../hooks';
|
||||
|
||||
import ParticipantItem from './ParticipantItem';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Callback used to open a drawer with admit/reject actions.
|
||||
*/
|
||||
openDrawerForParticipant: Function;
|
||||
|
||||
/**
|
||||
* If an overflow drawer should be displayed.
|
||||
*/
|
||||
overflowDrawer: boolean;
|
||||
|
||||
/**
|
||||
* Participant reference.
|
||||
*/
|
||||
participant: IParticipant;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
button: {
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
moreButton: {
|
||||
paddingRight: '6px',
|
||||
paddingLeft: '6px',
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
contextMenu: {
|
||||
position: 'fixed',
|
||||
top: 'auto',
|
||||
marginRight: '8px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const LobbyParticipantItem = ({
|
||||
overflowDrawer,
|
||||
participant: p,
|
||||
openDrawerForParticipant
|
||||
}: IProps) => {
|
||||
const { id } = p;
|
||||
const [ admit, reject, chat ] = useLobbyActions({ participantID: id });
|
||||
const { t } = useTranslation();
|
||||
const [ isOpen, setIsOpen ] = useState(false);
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
const showChat = useSelector(showLobbyChatButton(p));
|
||||
|
||||
const moreButtonRef = useRef();
|
||||
|
||||
const openContextMenu = useCallback(() => setIsOpen(true), []);
|
||||
const closeContextMenu = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
const renderAdmitButton = () => (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.admit')} ${p.name}` }
|
||||
className = { styles.button }
|
||||
labelKey = { 'participantsPane.actions.admit' }
|
||||
onClick = { admit }
|
||||
size = 'small'
|
||||
testId = { `admit-${id}` } />);
|
||||
|
||||
return (
|
||||
<ParticipantItem
|
||||
actionsTrigger = { ACTION_TRIGGER.PERMANENT }
|
||||
audioMediaState = { MEDIA_STATE.NONE }
|
||||
displayName = { p.name }
|
||||
local = { p.local }
|
||||
openDrawerForParticipant = { openDrawerForParticipant }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participantID = { id }
|
||||
raisedHand = { hasRaisedHand(p) }
|
||||
videoMediaState = { MEDIA_STATE.NONE }
|
||||
youText = { t('chat.you') }>
|
||||
|
||||
{showChat ? <>
|
||||
{renderAdmitButton()}
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.moreModerationActions')} ${p.name}` }
|
||||
className = { styles.moreButton }
|
||||
icon = { IconDotsHorizontal }
|
||||
onClick = { openContextMenu }
|
||||
ref = { moreButtonRef }
|
||||
size = 'small' />
|
||||
<ContextMenu
|
||||
className = { styles.contextMenu }
|
||||
hidden = { !isOpen }
|
||||
offsetTarget = { moreButtonRef.current }
|
||||
onMouseLeave = { closeContextMenu }>
|
||||
<ContextMenuItemGroup
|
||||
actions = { [ {
|
||||
accessibilityLabel: `${t('lobby.chat')} ${p.name}`,
|
||||
onClick: chat,
|
||||
testId: `lobby-chat-${id}`,
|
||||
icon: IconMessage,
|
||||
text: t('lobby.chat')
|
||||
} ] } />
|
||||
<ContextMenuItemGroup
|
||||
actions = { [ {
|
||||
accessibilityLabel: `${t('participantsPane.actions.reject')} ${p.name}`,
|
||||
onClick: reject,
|
||||
testId: `reject-${id}`,
|
||||
icon: IconUserDeleted,
|
||||
text: t('participantsPane.actions.reject')
|
||||
} ] } />
|
||||
</ContextMenu>
|
||||
</> : <>
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.reject')} ${p.name}` }
|
||||
className = { styles.button }
|
||||
labelKey = { 'participantsPane.actions.reject' }
|
||||
onClick = { reject }
|
||||
size = 'small'
|
||||
testId = { `reject-${id}` }
|
||||
type = { BUTTON_TYPES.DESTRUCTIVE } />
|
||||
{renderAdmitButton()}
|
||||
</>
|
||||
}
|
||||
</ParticipantItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IParticipant } from '../../../base/participants/types';
|
||||
|
||||
import { LobbyParticipantItem } from './LobbyParticipantItem';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Opens a drawer with actions for a knocking participant.
|
||||
*/
|
||||
openDrawerForParticipant: Function;
|
||||
|
||||
/**
|
||||
* If a drawer with actions should be displayed.
|
||||
*/
|
||||
overflowDrawer: boolean;
|
||||
|
||||
/**
|
||||
* List with the knocking participants.
|
||||
*/
|
||||
participants: IParticipant[];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
margin: `${theme.spacing(3)} 0`
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component used to display a list of knocking participants.
|
||||
*
|
||||
* @param {Object} props - The props of the component.
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
function LobbyParticipantItems({ openDrawerForParticipant, overflowDrawer, participants }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { classes.container }
|
||||
id = 'lobby-list'>
|
||||
{participants.map(p => (
|
||||
<LobbyParticipantItem
|
||||
key = { p.id }
|
||||
openDrawerForParticipant = { openDrawerForParticipant }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participant = { p } />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoize the component in order to avoid rerender on drawer open/close.
|
||||
export default React.memo<IProps>(LobbyParticipantItems);
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCheck, IconCloseLarge } from '../../../base/icons/svg';
|
||||
import { admitMultiple } from '../../../lobby/actions.web';
|
||||
import { getKnockingParticipants, getLobbyEnabled } from '../../../lobby/functions';
|
||||
import Drawer from '../../../toolbox/components/web/Drawer';
|
||||
import JitsiPortal from '../../../toolbox/components/web/JitsiPortal';
|
||||
import { showOverflowDrawer } from '../../../toolbox/functions.web';
|
||||
import { useLobbyActions, useParticipantDrawer } from '../../hooks';
|
||||
|
||||
import LobbyParticipantItems from './LobbyParticipantItems';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
drawerActions: {
|
||||
listStyleType: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
drawerItem: {
|
||||
alignItems: 'center',
|
||||
color: theme.palette.text01,
|
||||
display: 'flex',
|
||||
padding: '12px 16px',
|
||||
...theme.typography.bodyShortRegularLarge,
|
||||
|
||||
'&:first-child': {
|
||||
marginTop: '15px'
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
background: theme.palette.action02
|
||||
}
|
||||
},
|
||||
icon: {
|
||||
marginRight: 16
|
||||
},
|
||||
headingContainer: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
heading: {
|
||||
...theme.typography.bodyShortBold,
|
||||
color: theme.palette.text02
|
||||
},
|
||||
link: {
|
||||
...theme.typography.labelBold,
|
||||
color: theme.palette.link01,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component used to display a list of participants waiting in the lobby.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
export default function LobbyParticipants() {
|
||||
const lobbyEnabled = useSelector(getLobbyEnabled);
|
||||
const participants = useSelector(getKnockingParticipants);
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const admitAll = useCallback(() => {
|
||||
dispatch(admitMultiple(participants));
|
||||
}, [ dispatch, participants ]);
|
||||
const overflowDrawer = useSelector(showOverflowDrawer);
|
||||
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
|
||||
const [ admit, reject ] = useLobbyActions(drawerParticipant, closeDrawer);
|
||||
|
||||
if (!lobbyEnabled || !participants.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className = { classes.headingContainer }>
|
||||
<div className = { classes.heading }>
|
||||
{t('participantsPane.headings.lobby', { count: participants.length })}
|
||||
</div>
|
||||
{
|
||||
participants.length > 1
|
||||
&& <div
|
||||
className = { classes.link }
|
||||
onClick = { admitAll }>{t('participantsPane.actions.admitAll')}</div>
|
||||
}
|
||||
</div>
|
||||
<LobbyParticipantItems
|
||||
openDrawerForParticipant = { openDrawerForParticipant }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participants = { participants } />
|
||||
<JitsiPortal>
|
||||
<Drawer
|
||||
isOpen = { Boolean(drawerParticipant && overflowDrawer) }
|
||||
onClose = { closeDrawer }>
|
||||
<ul className = { classes.drawerActions }>
|
||||
<li className = { classes.drawerItem }>
|
||||
<Avatar
|
||||
className = { classes.icon }
|
||||
participantId = { drawerParticipant?.participantID }
|
||||
size = { 20 } />
|
||||
<span>{ drawerParticipant?.displayName }</span>
|
||||
</li>
|
||||
<li
|
||||
className = { classes.drawerItem }
|
||||
onClick = { admit }>
|
||||
<Icon
|
||||
className = { classes.icon }
|
||||
size = { 20 }
|
||||
src = { IconCheck } />
|
||||
<span>{ t('participantsPane.actions.admit') }</span>
|
||||
</li>
|
||||
<li
|
||||
className = { classes.drawerItem }
|
||||
onClick = { reject }>
|
||||
<Icon
|
||||
className = { classes.icon }
|
||||
size = { 20 }
|
||||
src = { IconCloseLarge } />
|
||||
<span>{ t('participantsPane.actions.reject')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Drawer>
|
||||
</JitsiPortal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getParticipantByIdOrUndefined
|
||||
} from '../../../base/participants/functions';
|
||||
import { IParticipant } from '../../../base/participants/types';
|
||||
import FakeParticipantContextMenu from '../../../video-menu/components/web/FakeParticipantContextMenu';
|
||||
import ParticipantContextMenu from '../../../video-menu/components/web/ParticipantContextMenu';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Shared video local participant owner.
|
||||
*/
|
||||
_localVideoOwner: boolean;
|
||||
|
||||
/**
|
||||
* Participant reference.
|
||||
*/
|
||||
_participant?: IParticipant;
|
||||
|
||||
/**
|
||||
* Closes a drawer if open.
|
||||
*/
|
||||
closeDrawer: () => void;
|
||||
|
||||
/**
|
||||
* The dispatch function from redux.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The participant for which the drawer is open.
|
||||
* It contains the displayName & participantID.
|
||||
*/
|
||||
drawerParticipant: {
|
||||
displayName: string;
|
||||
participantID: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget?: HTMLElement;
|
||||
|
||||
/**
|
||||
* Callback for the mouse entering the component.
|
||||
*/
|
||||
onEnter: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback for the mouse leaving the component.
|
||||
*/
|
||||
onLeave: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback for making a selection in the menu.
|
||||
*/
|
||||
onSelect: (e?: React.MouseEvent | boolean) => void;
|
||||
|
||||
/**
|
||||
* The ID of the participant.
|
||||
*/
|
||||
participantID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the MeetingParticipantContextMenu component.
|
||||
*/
|
||||
class MeetingParticipantContextMenu extends Component<IProps> {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_localVideoOwner,
|
||||
_participant,
|
||||
closeDrawer,
|
||||
drawerParticipant,
|
||||
offsetTarget,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onSelect
|
||||
} = this.props;
|
||||
|
||||
if (!_participant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const props = {
|
||||
closeDrawer,
|
||||
drawerParticipant,
|
||||
offsetTarget,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onSelect,
|
||||
participant: _participant,
|
||||
thumbnailMenu: false
|
||||
};
|
||||
|
||||
if (_participant?.fakeParticipant) {
|
||||
return (
|
||||
<FakeParticipantContextMenu
|
||||
{ ...props }
|
||||
localVideoOwner = { _localVideoOwner } />
|
||||
);
|
||||
}
|
||||
|
||||
return <ParticipantContextMenu { ...props } />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The own props of the component.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const { participantID, overflowDrawer, drawerParticipant } = ownProps;
|
||||
const { ownerId } = state['features/shared-video'];
|
||||
const localParticipantId = getLocalParticipant(state)?.id;
|
||||
|
||||
const participant = getParticipantByIdOrUndefined(state,
|
||||
overflowDrawer ? drawerParticipant?.participantID : participantID);
|
||||
|
||||
return {
|
||||
_localVideoOwner: Boolean(ownerId === localParticipantId),
|
||||
_participant: participant
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu));
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../../../base/media/constants';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getParticipantByIdOrUndefined,
|
||||
getParticipantDisplayName,
|
||||
hasRaisedHand,
|
||||
isParticipantModerator
|
||||
} from '../../../base/participants/functions';
|
||||
import { IParticipant } from '../../../base/participants/types';
|
||||
import {
|
||||
getLocalAudioTrack,
|
||||
getTrackByMediaTypeAndParticipant,
|
||||
isParticipantAudioMuted,
|
||||
isParticipantVideoMuted
|
||||
} from '../../../base/tracks/functions.web';
|
||||
import { ITrack } from '../../../base/tracks/types';
|
||||
import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../../constants';
|
||||
import {
|
||||
getParticipantAudioMediaState,
|
||||
getParticipantVideoMediaState,
|
||||
getQuickActionButtonType,
|
||||
participantMatchesSearch
|
||||
} from '../../functions';
|
||||
|
||||
import ParticipantActionEllipsis from './ParticipantActionEllipsis';
|
||||
import ParticipantItem from './ParticipantItem';
|
||||
import ParticipantQuickAction from './ParticipantQuickAction';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Media state for audio.
|
||||
*/
|
||||
_audioMediaState: MediaState;
|
||||
|
||||
/**
|
||||
* The audio track related to the participant.
|
||||
*/
|
||||
_audioTrack?: ITrack;
|
||||
|
||||
/**
|
||||
* Whether or not to disable the moderator indicator.
|
||||
*/
|
||||
_disableModeratorIndicator: boolean;
|
||||
|
||||
/**
|
||||
* The display name of the participant.
|
||||
*/
|
||||
_displayName: string;
|
||||
|
||||
/**
|
||||
* Whether or not moderation is supported.
|
||||
*/
|
||||
_isModerationSupported: boolean;
|
||||
|
||||
/**
|
||||
* True if the participant is the local participant.
|
||||
*/
|
||||
_local: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the local participant is moderator.
|
||||
*/
|
||||
_localModerator: boolean;
|
||||
|
||||
/**
|
||||
* Shared video local participant owner.
|
||||
*/
|
||||
_localVideoOwner: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the participant name matches the search string.
|
||||
*/
|
||||
_matchesSearch: boolean;
|
||||
|
||||
/**
|
||||
* The participant.
|
||||
*/
|
||||
_participant?: IParticipant;
|
||||
|
||||
/**
|
||||
* The participant ID.
|
||||
*
|
||||
* NOTE: This ID may be different from participantID prop in the case when we pass undefined for the local
|
||||
* participant. In this case the local participant ID will be filled through _participantID prop.
|
||||
*/
|
||||
_participantID: string;
|
||||
|
||||
/**
|
||||
* The type of button to be rendered for the quick action.
|
||||
*/
|
||||
_quickActionButtonType: string;
|
||||
|
||||
/**
|
||||
* True if the participant have raised hand.
|
||||
*/
|
||||
_raisedHand: boolean;
|
||||
|
||||
/**
|
||||
* Media state for video.
|
||||
*/
|
||||
_videoMediaState: MediaState;
|
||||
|
||||
/**
|
||||
* The translated ask unmute text for the quick action buttons.
|
||||
*/
|
||||
askUnmuteText: string;
|
||||
|
||||
/**
|
||||
* Is this item highlighted.
|
||||
*/
|
||||
isHighlighted: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the local participant is in a breakout room.
|
||||
*/
|
||||
isInBreakoutRoom: boolean;
|
||||
|
||||
/**
|
||||
* The translated text for the mute participant button.
|
||||
*/
|
||||
muteParticipantButtonText: string;
|
||||
|
||||
/**
|
||||
* Callback for the activation of this item's context menu.
|
||||
*/
|
||||
onContextMenu: () => void;
|
||||
|
||||
/**
|
||||
* Callback for the mouse leaving this item.
|
||||
*/
|
||||
onLeave: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback used to open an actions drawer for a participant.
|
||||
*/
|
||||
openDrawerForParticipant: Function;
|
||||
|
||||
/**
|
||||
* True if an overflow drawer should be displayed.
|
||||
*/
|
||||
overflowDrawer: boolean;
|
||||
|
||||
/**
|
||||
* The aria-label for the ellipsis action.
|
||||
*/
|
||||
participantActionEllipsisLabel: string;
|
||||
|
||||
/**
|
||||
* The ID of the participant.
|
||||
*/
|
||||
participantID?: string;
|
||||
|
||||
/**
|
||||
* The translated "you" text.
|
||||
*/
|
||||
youText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the MeetingParticipantItem component.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function MeetingParticipantItem({
|
||||
_audioMediaState,
|
||||
_audioTrack,
|
||||
_disableModeratorIndicator,
|
||||
_displayName,
|
||||
_local,
|
||||
_localVideoOwner,
|
||||
_matchesSearch,
|
||||
_participant,
|
||||
_participantID,
|
||||
_quickActionButtonType,
|
||||
_raisedHand,
|
||||
_videoMediaState,
|
||||
isHighlighted,
|
||||
isInBreakoutRoom,
|
||||
onContextMenu,
|
||||
onLeave,
|
||||
openDrawerForParticipant,
|
||||
overflowDrawer,
|
||||
participantActionEllipsisLabel,
|
||||
youText
|
||||
}: IProps) {
|
||||
|
||||
const [ hasAudioLevels, setHasAudioLevel ] = useState(false);
|
||||
const [ registeredEvent, setRegisteredEvent ] = useState(false);
|
||||
|
||||
const _updateAudioLevel = useCallback(level => {
|
||||
const audioLevel = typeof level === 'number' && !isNaN(level)
|
||||
? level : 0;
|
||||
|
||||
setHasAudioLevel(audioLevel > 0.009);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (_audioTrack && !registeredEvent) {
|
||||
const { jitsiTrack } = _audioTrack;
|
||||
|
||||
if (jitsiTrack) {
|
||||
jitsiTrack.on(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, _updateAudioLevel);
|
||||
setRegisteredEvent(true);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (_audioTrack && registeredEvent) {
|
||||
const { jitsiTrack } = _audioTrack;
|
||||
|
||||
jitsiTrack?.off(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, _updateAudioLevel);
|
||||
}
|
||||
};
|
||||
}, [ _audioTrack ]);
|
||||
|
||||
if (!_matchesSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioMediaState = _audioMediaState === MEDIA_STATE.UNMUTED && hasAudioLevels
|
||||
? MEDIA_STATE.DOMINANT_SPEAKER : _audioMediaState;
|
||||
|
||||
return (
|
||||
<ParticipantItem
|
||||
actionsTrigger = { ACTION_TRIGGER.HOVER }
|
||||
{
|
||||
...(_participant?.fakeParticipant ? {} : {
|
||||
audioMediaState,
|
||||
videoMediaState: _videoMediaState
|
||||
})
|
||||
}
|
||||
disableModeratorIndicator = { _disableModeratorIndicator }
|
||||
displayName = { _displayName }
|
||||
isHighlighted = { isHighlighted }
|
||||
isModerator = { isParticipantModerator(_participant) }
|
||||
local = { _local }
|
||||
onLeave = { onLeave }
|
||||
openDrawerForParticipant = { openDrawerForParticipant }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participantID = { _participantID }
|
||||
raisedHand = { _raisedHand }
|
||||
youText = { youText }>
|
||||
|
||||
{!overflowDrawer && !_participant?.fakeParticipant
|
||||
&& <>
|
||||
{!isInBreakoutRoom && (
|
||||
<ParticipantQuickAction
|
||||
buttonType = { _quickActionButtonType }
|
||||
participantID = { _participantID }
|
||||
participantName = { _displayName } />
|
||||
)}
|
||||
<ParticipantActionEllipsis
|
||||
accessibilityLabel = { participantActionEllipsisLabel }
|
||||
onClick = { onContextMenu }
|
||||
participantID = { _participantID } />
|
||||
</>
|
||||
}
|
||||
|
||||
{!overflowDrawer && (_localVideoOwner && _participant?.fakeParticipant) && (
|
||||
<ParticipantActionEllipsis
|
||||
accessibilityLabel = { participantActionEllipsisLabel }
|
||||
onClick = { onContextMenu } />
|
||||
)}
|
||||
</ParticipantItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The own props of the component.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const { participantID, searchString } = ownProps;
|
||||
const { ownerId } = state['features/shared-video'];
|
||||
const localParticipantId = getLocalParticipant(state)?.id;
|
||||
const participant = getParticipantByIdOrUndefined(state, participantID);
|
||||
const _displayName = getParticipantDisplayName(state, participant?.id ?? '');
|
||||
const _matchesSearch = participantMatchesSearch(participant, searchString);
|
||||
const _isAudioMuted = isParticipantAudioMuted(participant, state);
|
||||
const _isVideoMuted = isParticipantVideoMuted(participant, state);
|
||||
const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state);
|
||||
const _videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state);
|
||||
const _quickActionButtonType = getQuickActionButtonType(participant, state);
|
||||
|
||||
const tracks = state['features/base/tracks'];
|
||||
const _audioTrack = participantID === localParticipantId
|
||||
? getLocalAudioTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, participantID);
|
||||
|
||||
const { disableModeratorIndicator } = state['features/base/config'];
|
||||
|
||||
return {
|
||||
_audioMediaState,
|
||||
_audioTrack,
|
||||
_disableModeratorIndicator: Boolean(disableModeratorIndicator),
|
||||
_displayName,
|
||||
_local: Boolean(participant?.local),
|
||||
_localVideoOwner: Boolean(ownerId === localParticipantId),
|
||||
_matchesSearch,
|
||||
_participant: participant,
|
||||
_participantID: participant?.id ?? '',
|
||||
_quickActionButtonType,
|
||||
_raisedHand: hasRaisedHand(participant),
|
||||
_videoMediaState
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(MeetingParticipantItem);
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
|
||||
import MeetingParticipantItem from './MeetingParticipantItem';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The translated ask unmute text for the quick action buttons.
|
||||
*/
|
||||
askUnmuteText?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the local participant is in a breakout room.
|
||||
*/
|
||||
isInBreakoutRoom: boolean;
|
||||
|
||||
/**
|
||||
* Callback for the mouse leaving this item.
|
||||
*/
|
||||
lowerMenu: Function;
|
||||
|
||||
/**
|
||||
* The translated text for the mute participant button.
|
||||
*/
|
||||
muteParticipantButtonText?: string;
|
||||
|
||||
/**
|
||||
* Callback used to open an actions drawer for a participant.
|
||||
*/
|
||||
openDrawerForParticipant: Function;
|
||||
|
||||
/**
|
||||
* True if an overflow drawer should be displayed.
|
||||
*/
|
||||
overflowDrawer?: boolean;
|
||||
|
||||
/**
|
||||
* The aria-label for the ellipsis action.
|
||||
*/
|
||||
participantActionEllipsisLabel: string;
|
||||
|
||||
/**
|
||||
* The meeting participants.
|
||||
*/
|
||||
participantIds: Array<string>;
|
||||
|
||||
/**
|
||||
* The if of the participant for which the context menu should be open.
|
||||
*/
|
||||
raiseContextId?: string;
|
||||
|
||||
/**
|
||||
* Current search string.
|
||||
*/
|
||||
searchString?: string;
|
||||
|
||||
/**
|
||||
* Callback for the activation of this item's context menu.
|
||||
*/
|
||||
toggleMenu: Function;
|
||||
|
||||
/**
|
||||
* The translated "you" text.
|
||||
*/
|
||||
youText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component used to display a list of meeting participants.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
function MeetingParticipantItems({
|
||||
isInBreakoutRoom,
|
||||
lowerMenu,
|
||||
toggleMenu,
|
||||
participantIds,
|
||||
openDrawerForParticipant,
|
||||
overflowDrawer,
|
||||
raiseContextId,
|
||||
participantActionEllipsisLabel,
|
||||
searchString,
|
||||
youText
|
||||
}: IProps) {
|
||||
const renderParticipant = (id: string) => (
|
||||
<MeetingParticipantItem
|
||||
isHighlighted = { raiseContextId === id }
|
||||
isInBreakoutRoom = { isInBreakoutRoom }
|
||||
key = { id }
|
||||
onContextMenu = { toggleMenu(id) }
|
||||
onLeave = { lowerMenu }
|
||||
openDrawerForParticipant = { openDrawerForParticipant }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participantActionEllipsisLabel = { participantActionEllipsisLabel }
|
||||
participantID = { id }
|
||||
searchString = { searchString }
|
||||
youText = { youText } />
|
||||
);
|
||||
|
||||
return (<>
|
||||
{participantIds.map(renderParticipant)}
|
||||
</>);
|
||||
}
|
||||
|
||||
// Memoize the component in order to avoid rerender on drawer open/close.
|
||||
export default React.memo<IProps>(MeetingParticipantItems);
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
|
||||
import { getParticipantById, isScreenShareParticipant } from '../../../base/participants/functions';
|
||||
import Input from '../../../base/ui/components/web/Input';
|
||||
import useContextMenu from '../../../base/ui/hooks/useContextMenu.web';
|
||||
import { normalizeAccents } from '../../../base/util/strings.web';
|
||||
import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
|
||||
import { isButtonEnabled, showOverflowDrawer } from '../../../toolbox/functions.web';
|
||||
import { iAmVisitor } from '../../../visitors/functions';
|
||||
import { getSortedParticipantIds, isCurrentRoomRenamable, shouldRenderInviteButton } from '../../functions';
|
||||
import { useParticipantDrawer } from '../../hooks';
|
||||
import RenameButton from '../breakout-rooms/components/web/RenameButton';
|
||||
|
||||
import { InviteButton } from './InviteButton';
|
||||
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
|
||||
import MeetingParticipantItems from './MeetingParticipantItems';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
headingW: {
|
||||
color: theme.palette.warning02
|
||||
},
|
||||
heading: {
|
||||
color: theme.palette.text02,
|
||||
...theme.typography.bodyShortBold,
|
||||
marginBottom: theme.spacing(3),
|
||||
|
||||
[`@media(max-width: ${participantsPaneTheme.MD_BREAKPOINT})`]: {
|
||||
...theme.typography.bodyShortBoldLarge
|
||||
}
|
||||
},
|
||||
|
||||
search: {
|
||||
margin: `${theme.spacing(3)} 0`,
|
||||
|
||||
'& input': {
|
||||
textAlign: 'center',
|
||||
paddingRight: '16px'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
interface IProps {
|
||||
currentRoom?: {
|
||||
jid: string;
|
||||
name: string;
|
||||
};
|
||||
overflowDrawer?: boolean;
|
||||
participantsCount?: number;
|
||||
searchString: string;
|
||||
setSearchString: (newValue: string) => void;
|
||||
showInviteButton?: boolean;
|
||||
sortedParticipantIds?: Array<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the MeetingParticipantList component.
|
||||
* NOTE: This component is not using useSelector on purpose. The child components MeetingParticipantItem
|
||||
* and MeetingParticipantContextMenu are using connect. Having those mixed leads to problems.
|
||||
* When this one was using useSelector and the other two were not -the other two were re-rendered before this one was
|
||||
* re-rendered, so when participant is leaving, we first re-render the item and menu components,
|
||||
* throwing errors (closing the page) before removing those components for the participant that left.
|
||||
*
|
||||
* @returns {ReactNode} - The component.
|
||||
*/
|
||||
function MeetingParticipants({
|
||||
currentRoom,
|
||||
overflowDrawer,
|
||||
participantsCount,
|
||||
searchString,
|
||||
setSearchString,
|
||||
showInviteButton,
|
||||
sortedParticipantIds = []
|
||||
}: IProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [ lowerMenu, , toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu<string>();
|
||||
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
|
||||
|
||||
// FIXME:
|
||||
// It seems that useTranslation is not very scalable. Unmount 500 components that have the useTranslation hook is
|
||||
// taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
|
||||
// solution!!!
|
||||
// One potential proper fix would be to use react-window component in order to lower the number of components
|
||||
// mounted.
|
||||
const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
|
||||
const youText = t('chat.you');
|
||||
const isBreakoutRoom = useSelector(isInBreakoutRoom);
|
||||
const _isCurrentRoomRenamable = useSelector(isCurrentRoomRenamable);
|
||||
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-level = { 1 }
|
||||
className = 'sr-only'
|
||||
role = 'heading'>
|
||||
{ t('participantsPane.title') }
|
||||
</span>
|
||||
<div className = { styles.heading }>
|
||||
{currentRoom?.name
|
||||
? `${currentRoom.name} (${participantsCount})`
|
||||
: t('participantsPane.headings.participantsList', { count: participantsCount })}
|
||||
{ currentRoom?.name && _isCurrentRoomRenamable
|
||||
&& <RenameButton
|
||||
breakoutRoomJid = { currentRoom?.jid }
|
||||
name = { currentRoom?.name } /> }
|
||||
</div>
|
||||
{showInviteButton && <InviteButton />}
|
||||
<Input
|
||||
accessibilityLabel = { t('participantsPane.search') }
|
||||
className = { styles.search }
|
||||
clearable = { true }
|
||||
hiddenDescription = { t('participantsPane.searchDescription') }
|
||||
id = 'participants-search-input'
|
||||
onChange = { setSearchString }
|
||||
placeholder = { t('participantsPane.search') }
|
||||
value = { searchString } />
|
||||
<div>
|
||||
<MeetingParticipantItems
|
||||
isInBreakoutRoom = { isBreakoutRoom }
|
||||
lowerMenu = { lowerMenu }
|
||||
openDrawerForParticipant = { openDrawerForParticipant }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participantActionEllipsisLabel = { participantActionEllipsisLabel }
|
||||
participantIds = { sortedParticipantIds }
|
||||
raiseContextId = { raiseContext.entity }
|
||||
searchString = { normalizeAccents(searchString) }
|
||||
toggleMenu = { toggleMenu }
|
||||
youText = { youText } />
|
||||
</div>
|
||||
<MeetingParticipantContextMenu
|
||||
closeDrawer = { closeDrawer }
|
||||
drawerParticipant = { drawerParticipant }
|
||||
offsetTarget = { raiseContext?.offsetTarget }
|
||||
onEnter = { menuEnter }
|
||||
onLeave = { menuLeave }
|
||||
onSelect = { lowerMenu }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
participantID = { raiseContext?.entity } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The own props of the component.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
let sortedParticipantIds: any = getSortedParticipantIds(state);
|
||||
const _iAmVisitor = iAmVisitor(state);
|
||||
|
||||
// Filter out the virtual screenshare participants since we do not want them to be displayed as separate
|
||||
// participants in the participants pane.
|
||||
// Filter local participant when in visitor mode
|
||||
sortedParticipantIds = sortedParticipantIds.filter((id: any) => {
|
||||
const participant = getParticipantById(state, id);
|
||||
|
||||
if (_iAmVisitor && participant?.local) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isScreenShareParticipant(participant);
|
||||
});
|
||||
|
||||
const participantsCount = sortedParticipantIds.length;
|
||||
const showInviteButton = shouldRenderInviteButton(state) && isButtonEnabled('invite', state);
|
||||
const overflowDrawer = showOverflowDrawer(state);
|
||||
const currentRoomId = getCurrentRoomId(state);
|
||||
const currentRoom = getBreakoutRooms(state)[currentRoomId];
|
||||
|
||||
return {
|
||||
currentRoom,
|
||||
overflowDrawer,
|
||||
participantsCount,
|
||||
showInviteButton,
|
||||
sortedParticipantIds
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(MeetingParticipants);
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { IconDotsHorizontal } from '../../../base/icons/svg';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Label used for accessibility.
|
||||
*/
|
||||
accessibilityLabel: string;
|
||||
|
||||
/**
|
||||
* Click handler function.
|
||||
*/
|
||||
onClick: () => void;
|
||||
|
||||
participantID?: string;
|
||||
}
|
||||
|
||||
const ParticipantActionEllipsis = ({ accessibilityLabel, onClick, participantID }: IProps) => (
|
||||
<Button
|
||||
accessibilityLabel = { accessibilityLabel }
|
||||
icon = { IconDotsHorizontal }
|
||||
onClick = { onClick }
|
||||
size = 'small'
|
||||
testId = { participantID ? `participant-more-options-${participantID}` : undefined } />
|
||||
);
|
||||
|
||||
export default ParticipantActionEllipsis;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import ListItem from '../../../base/ui/components/web/ListItem';
|
||||
import {
|
||||
ACTION_TRIGGER,
|
||||
type ActionTrigger,
|
||||
AudioStateIcons,
|
||||
MEDIA_STATE,
|
||||
MediaState,
|
||||
VideoStateIcons
|
||||
} from '../../constants';
|
||||
|
||||
import { RaisedHandIndicator } from './RaisedHandIndicator';
|
||||
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Type of trigger for the participant actions.
|
||||
*/
|
||||
actionsTrigger?: ActionTrigger;
|
||||
|
||||
/**
|
||||
* Media state for audio.
|
||||
*/
|
||||
audioMediaState?: MediaState;
|
||||
|
||||
/**
|
||||
* React children.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
|
||||
/**
|
||||
* Whether or not to disable the moderator indicator.
|
||||
*/
|
||||
disableModeratorIndicator?: boolean;
|
||||
|
||||
/**
|
||||
* The name of the participant. Used for showing lobby names.
|
||||
*/
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* Is this item highlighted/raised.
|
||||
*/
|
||||
isHighlighted?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the participant is a moderator.
|
||||
*/
|
||||
isModerator?: boolean;
|
||||
|
||||
/**
|
||||
* True if the participant is local.
|
||||
*/
|
||||
local?: boolean;
|
||||
|
||||
/**
|
||||
* Callback for when the mouse leaves this component.
|
||||
*/
|
||||
onLeave?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* Opens a drawer with participant actions.
|
||||
*/
|
||||
openDrawerForParticipant?: Function;
|
||||
|
||||
/**
|
||||
* If an overflow drawer can be opened.
|
||||
*/
|
||||
overflowDrawer?: boolean;
|
||||
|
||||
/**
|
||||
* The ID of the participant.
|
||||
*/
|
||||
participantID: string;
|
||||
|
||||
/**
|
||||
* True if the participant have raised hand.
|
||||
*/
|
||||
raisedHand?: boolean;
|
||||
|
||||
/**
|
||||
* Media state for video.
|
||||
*/
|
||||
videoMediaState?: MediaState;
|
||||
|
||||
/**
|
||||
* The translated "you" text.
|
||||
*/
|
||||
youText?: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
nameContainer: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
|
||||
name: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
moderatorLabel: {
|
||||
...theme.typography.labelBold,
|
||||
color: theme.palette.text03
|
||||
},
|
||||
|
||||
avatar: {
|
||||
marginRight: theme.spacing(3)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* A component representing a participant entry in ParticipantPane and Lobby.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
function ParticipantItem({
|
||||
actionsTrigger = ACTION_TRIGGER.HOVER,
|
||||
audioMediaState = MEDIA_STATE.NONE,
|
||||
children,
|
||||
disableModeratorIndicator,
|
||||
displayName,
|
||||
isHighlighted,
|
||||
isModerator,
|
||||
local,
|
||||
onLeave,
|
||||
openDrawerForParticipant,
|
||||
overflowDrawer,
|
||||
participantID,
|
||||
raisedHand,
|
||||
t,
|
||||
videoMediaState = MEDIA_STATE.NONE,
|
||||
youText
|
||||
}: IProps) {
|
||||
const onClick = useCallback(
|
||||
() => openDrawerForParticipant?.({
|
||||
participantID,
|
||||
displayName
|
||||
}), []);
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
const icon = (
|
||||
<Avatar
|
||||
className = { classes.avatar }
|
||||
displayName = { displayName }
|
||||
participantId = { participantID }
|
||||
size = { 32 } />
|
||||
);
|
||||
|
||||
const text = (
|
||||
<>
|
||||
<div className = { classes.nameContainer }>
|
||||
<div className = { classes.name }>
|
||||
{displayName}
|
||||
</div>
|
||||
{local ? <span> ({youText})</span> : null}
|
||||
</div>
|
||||
{isModerator && !disableModeratorIndicator && <div className = { classes.moderatorLabel }>
|
||||
{t('videothumbnail.moderator')}
|
||||
</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
const indicators = (
|
||||
<>
|
||||
{raisedHand && <RaisedHandIndicator />}
|
||||
{VideoStateIcons[videoMediaState]}
|
||||
{AudioStateIcons[audioMediaState]}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
actions = { children }
|
||||
hideActions = { local }
|
||||
icon = { icon }
|
||||
id = { `participant-item-${participantID}` }
|
||||
indicators = { indicators }
|
||||
isHighlighted = { isHighlighted }
|
||||
onClick = { !local && overflowDrawer ? onClick : undefined }
|
||||
onMouseLeave = { onLeave }
|
||||
textChildren = { text }
|
||||
trigger = { actionsTrigger } />
|
||||
);
|
||||
}
|
||||
|
||||
export default translate(ParticipantItem);
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import {
|
||||
approveParticipantAudio,
|
||||
approveParticipantDesktop,
|
||||
approveParticipantVideo,
|
||||
rejectParticipantAudio,
|
||||
rejectParticipantDesktop,
|
||||
rejectParticipantVideo
|
||||
} from '../../../av-moderation/actions';
|
||||
import { MEDIA_TYPE } from '../../../base/media/constants';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { muteRemote } from '../../../video-menu/actions.web';
|
||||
import { QUICK_ACTION_BUTTON } from '../../constants';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The translated ask unmute aria label.
|
||||
*/
|
||||
ariaLabel?: boolean;
|
||||
|
||||
/**
|
||||
* The translated "ask unmute" text.
|
||||
*/
|
||||
askUnmuteText?: string;
|
||||
|
||||
/**
|
||||
* The type of button to be displayed.
|
||||
*/
|
||||
buttonType: string;
|
||||
|
||||
/**
|
||||
* Label for mute participant button.
|
||||
*/
|
||||
muteParticipantButtonText?: string;
|
||||
|
||||
/**
|
||||
* The ID of the participant.
|
||||
*/
|
||||
participantID: string;
|
||||
|
||||
/**
|
||||
* The name of the participant.
|
||||
*/
|
||||
participantName: string;
|
||||
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
button: {
|
||||
marginRight: theme.spacing(2)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ParticipantQuickAction = ({
|
||||
buttonType,
|
||||
participantID,
|
||||
participantName
|
||||
}: IProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const askToUnmute = useCallback(() => {
|
||||
dispatch(approveParticipantAudio(participantID));
|
||||
}, [ dispatch, participantID ]);
|
||||
|
||||
const allowDesktop = useCallback(() => {
|
||||
dispatch(approveParticipantDesktop(participantID));
|
||||
}, [ dispatch, participantID ]);
|
||||
|
||||
const allowVideo = useCallback(() => {
|
||||
dispatch(approveParticipantVideo(participantID));
|
||||
}, [ dispatch, participantID ]);
|
||||
|
||||
const muteAudio = useCallback(() => {
|
||||
dispatch(muteRemote(participantID, MEDIA_TYPE.AUDIO));
|
||||
dispatch(rejectParticipantAudio(participantID));
|
||||
}, [ dispatch, participantID ]);
|
||||
|
||||
const stopDesktop = useCallback(() => {
|
||||
dispatch(muteRemote(participantID, MEDIA_TYPE.SCREENSHARE));
|
||||
dispatch(rejectParticipantDesktop(participantID));
|
||||
}, [ dispatch, participantID ]);
|
||||
|
||||
const stopVideo = useCallback(() => {
|
||||
dispatch(muteRemote(participantID, MEDIA_TYPE.VIDEO));
|
||||
dispatch(rejectParticipantVideo(participantID));
|
||||
}, [ dispatch, participantID ]);
|
||||
|
||||
switch (buttonType) {
|
||||
case QUICK_ACTION_BUTTON.MUTE: {
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.mute')} ${participantName}` }
|
||||
className = { styles.button }
|
||||
label = { t('participantsPane.actions.mute') }
|
||||
onClick = { muteAudio }
|
||||
size = 'small'
|
||||
testId = { `mute-audio-${participantID}` } />
|
||||
);
|
||||
}
|
||||
case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.askUnmute')} ${participantName}` }
|
||||
className = { styles.button }
|
||||
label = { t('participantsPane.actions.askUnmute') }
|
||||
onClick = { askToUnmute }
|
||||
size = 'small'
|
||||
testId = { `unmute-audio-${participantID}` } />
|
||||
);
|
||||
}
|
||||
case QUICK_ACTION_BUTTON.ALLOW_DESKTOP: {
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.askDesktop')} ${participantName}` }
|
||||
className = { styles.button }
|
||||
label = { t('participantsPane.actions.allowDesktop') }
|
||||
onClick = { allowDesktop }
|
||||
size = 'small'
|
||||
testId = { `unmute-desktop-${participantID}` } />
|
||||
);
|
||||
}
|
||||
case QUICK_ACTION_BUTTON.ALLOW_VIDEO: {
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.askUnmute')} ${participantName}` }
|
||||
className = { styles.button }
|
||||
label = { t('participantsPane.actions.allowVideo') }
|
||||
onClick = { allowVideo }
|
||||
size = 'small'
|
||||
testId = { `unmute-video-${participantID}` } />
|
||||
);
|
||||
}
|
||||
case QUICK_ACTION_BUTTON.STOP_DESKTOP: {
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.stopDesktop')} ${participantName}` }
|
||||
className = { styles.button }
|
||||
label = { t('participantsPane.actions.stopDesktop') }
|
||||
onClick = { stopDesktop }
|
||||
size = 'small'
|
||||
testId = { `mute-desktop-${participantID}` } />
|
||||
);
|
||||
}
|
||||
case QUICK_ACTION_BUTTON.STOP_VIDEO: {
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.mute')} ${participantName}` }
|
||||
className = { styles.button }
|
||||
label = { t('participantsPane.actions.stopVideo') }
|
||||
onClick = { stopVideo }
|
||||
size = 'small'
|
||||
testId = { `mute-video-${participantID}` } />
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ParticipantQuickAction;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
badge: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: '100%',
|
||||
height: '16px',
|
||||
minWidth: '16px',
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.labelBold,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: '-4px',
|
||||
top: '-3px',
|
||||
textAlign: 'center',
|
||||
padding: '1px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ParticipantsCounter = () => {
|
||||
const { classes } = useStyles();
|
||||
const participantsCount = useSelector(getParticipantCountForDisplay);
|
||||
|
||||
return <span className = { classes.badge }>{participantsCount}</span>;
|
||||
};
|
||||
|
||||
export default ParticipantsCounter;
|
||||
@@ -0,0 +1,247 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { isMobileBrowser } from '../../../base/environment/utils';
|
||||
import { IconCloseLarge, IconDotsHorizontal } from '../../../base/icons/svg';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants/functions';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import ClickableIcon from '../../../base/ui/components/web/ClickableIcon';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { findAncestorByClass } from '../../../base/ui/functions.web';
|
||||
import { isAddBreakoutRoomButtonVisible } from '../../../breakout-rooms/functions';
|
||||
import MuteEveryoneDialog from '../../../video-menu/components/web/MuteEveryoneDialog';
|
||||
import { shouldDisplayCurrentVisitorsList } from '../../../visitors/functions';
|
||||
import { close } from '../../actions.web';
|
||||
import {
|
||||
getParticipantsPaneOpen,
|
||||
isMoreActionsVisible,
|
||||
isMuteAllVisible
|
||||
} from '../../functions';
|
||||
import { AddBreakoutRoomButton } from '../breakout-rooms/components/web/AddBreakoutRoomButton';
|
||||
import { RoomList } from '../breakout-rooms/components/web/RoomList';
|
||||
|
||||
import CurrentVisitorsList from './CurrentVisitorsList';
|
||||
import { FooterContextMenu } from './FooterContextMenu';
|
||||
import LobbyParticipants from './LobbyParticipants';
|
||||
import MeetingParticipants from './MeetingParticipants';
|
||||
import VisitorsList from './VisitorsList';
|
||||
|
||||
/**
|
||||
* Interface representing the properties used for styles.
|
||||
*
|
||||
* @property {boolean} [isMobileBrowser] - Indicates whether the application is being accessed from a mobile browser.
|
||||
* @property {boolean} [isChatOpen] - Specifies whether the chat panel is currently open.
|
||||
*/
|
||||
interface IStylesProps {
|
||||
isChatOpen?: boolean;
|
||||
}
|
||||
const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
|
||||
return {
|
||||
participantsPane: {
|
||||
backgroundColor: theme.palette.ui01,
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
transition: 'width .16s ease-in-out',
|
||||
width: '315px',
|
||||
zIndex: isMobileBrowser() && isChatOpen ? -1 : 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontWeight: 600,
|
||||
height: '100%',
|
||||
|
||||
[[ '& > *:first-child', '& > *:last-child' ] as any]: {
|
||||
flexShrink: 0
|
||||
},
|
||||
|
||||
'@media (max-width: 580px)': {
|
||||
height: '100dvh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: '100%'
|
||||
}
|
||||
},
|
||||
|
||||
container: {
|
||||
boxSizing: 'border-box',
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
position: 'relative',
|
||||
padding: `0 ${participantsPaneTheme.panePadding}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none'
|
||||
},
|
||||
|
||||
// Temporary fix: Limit context menu width to prevent clipping
|
||||
// TODO: Long-term fix would be to portal context menus outside the scrollable container
|
||||
'& [class*="contextMenu"]': {
|
||||
maxWidth: '285px',
|
||||
|
||||
'& [class*="contextMenuItem"]': {
|
||||
whiteSpace: 'normal',
|
||||
|
||||
'& span': {
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
closeButton: {
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
height: '60px',
|
||||
padding: `0 ${participantsPaneTheme.panePadding}px`,
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
|
||||
antiCollapse: {
|
||||
fontSize: 0,
|
||||
|
||||
'&:first-child': {
|
||||
display: 'none'
|
||||
},
|
||||
|
||||
'&:first-child + *': {
|
||||
marginTop: 0
|
||||
}
|
||||
},
|
||||
|
||||
footer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: `${theme.spacing(4)} ${participantsPaneTheme.panePadding}px`,
|
||||
|
||||
'& > *:not(:last-child)': {
|
||||
marginRight: theme.spacing(3)
|
||||
}
|
||||
},
|
||||
|
||||
footerMoreContainer: {
|
||||
position: 'relative'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const ParticipantsPane = () => {
|
||||
const isChatOpen = useSelector((state: IReduxState) => state['features/chat'].isOpen);
|
||||
const { classes } = useStyles({ isChatOpen });
|
||||
const paneOpen = useSelector(getParticipantsPaneOpen);
|
||||
const isBreakoutRoomsSupported = useSelector((state: IReduxState) => state['features/base/conference'])
|
||||
.conference?.getBreakoutRooms()?.isSupported();
|
||||
const showCurrentVisitorsList = useSelector(shouldDisplayCurrentVisitorsList);
|
||||
const showAddRoomButton = useSelector(isAddBreakoutRoomButtonVisible);
|
||||
const showFooter = useSelector(isLocalParticipantModerator);
|
||||
const showMuteAllButton = useSelector(isMuteAllVisible);
|
||||
const showMoreActionsButton = useSelector(isMoreActionsVisible);
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [ contextOpen, setContextOpen ] = useState(false);
|
||||
const [ searchString, setSearchString ] = useState('');
|
||||
|
||||
const onWindowClickListener = useCallback((e: any) => {
|
||||
if (contextOpen && !findAncestorByClass(e.target, classes.footerMoreContainer)) {
|
||||
setContextOpen(false);
|
||||
}
|
||||
}, [ contextOpen ]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('click', onWindowClickListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', onWindowClickListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onClosePane = useCallback(() => {
|
||||
dispatch(close());
|
||||
}, []);
|
||||
|
||||
const onDrawerClose = useCallback(() => {
|
||||
setContextOpen(false);
|
||||
}, []);
|
||||
|
||||
const onMuteAll = useCallback(() => {
|
||||
dispatch(openDialog(MuteEveryoneDialog));
|
||||
}, []);
|
||||
|
||||
const onToggleContext = useCallback(() => {
|
||||
setContextOpen(open => !open);
|
||||
}, []);
|
||||
|
||||
if (!paneOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { classes.participantsPane }
|
||||
id = 'participants-pane'>
|
||||
<div className = { classes.header }>
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('participantsPane.close', 'Close') }
|
||||
icon = { IconCloseLarge }
|
||||
onClick = { onClosePane } />
|
||||
</div>
|
||||
<div className = { classes.container }>
|
||||
<VisitorsList />
|
||||
<br className = { classes.antiCollapse } />
|
||||
<LobbyParticipants />
|
||||
<br className = { classes.antiCollapse } />
|
||||
<MeetingParticipants
|
||||
searchString = { searchString }
|
||||
setSearchString = { setSearchString } />
|
||||
{isBreakoutRoomsSupported && <RoomList searchString = { searchString } />}
|
||||
{showAddRoomButton && <AddBreakoutRoomButton />}
|
||||
{showCurrentVisitorsList && <CurrentVisitorsList searchString = { searchString } />}
|
||||
</div>
|
||||
{showFooter && (
|
||||
<div className = { classes.footer }>
|
||||
{showMuteAllButton && (
|
||||
<Button
|
||||
accessibilityLabel = { t('participantsPane.actions.muteAll') }
|
||||
labelKey = { 'participantsPane.actions.muteAll' }
|
||||
onClick = { onMuteAll }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
)}
|
||||
{showMoreActionsButton && (
|
||||
<div className = { classes.footerMoreContainer }>
|
||||
<Button
|
||||
accessibilityLabel = { t('participantsPane.actions.moreModerationActions') }
|
||||
icon = { IconDotsHorizontal }
|
||||
id = 'participants-pane-context-menu'
|
||||
onClick = { onToggleContext }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<FooterContextMenu
|
||||
isOpen = { contextOpen }
|
||||
onDrawerClose = { onDrawerClose }
|
||||
onMouseLeave = { onToggleContext } />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ParticipantsPane;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconUsers } from '../../../base/icons/svg';
|
||||
import { getParticipantCountForDisplay } from '../../../base/participants/functions';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import {
|
||||
close as closeParticipantsPane,
|
||||
open as openParticipantsPane
|
||||
} from '../../../participants-pane/actions.web';
|
||||
import { closeOverflowMenuIfOpen } from '../../../toolbox/actions.web';
|
||||
import { isParticipantsPaneEnabled } from '../../functions';
|
||||
|
||||
import ParticipantsCounter from './ParticipantsCounter';
|
||||
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link ParticipantsPaneButton}.
|
||||
*/
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* Whether or not the participants pane is open.
|
||||
*/
|
||||
_isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Whether participants feature is enabled or not.
|
||||
*/
|
||||
_isParticipantsPaneEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Participants count.
|
||||
*/
|
||||
_participantsCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of a button for accessing participants pane.
|
||||
*/
|
||||
class ParticipantsPaneButton extends AbstractButton<IProps> {
|
||||
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.closeParticipantsPane';
|
||||
override icon = IconUsers;
|
||||
override label = 'toolbar.participants';
|
||||
override tooltip = 'toolbar.participants';
|
||||
override toggledTooltip = 'toolbar.closeParticipantsPane';
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in toggled state or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return this.props._isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking the button, and toggles the participants pane.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch, _isOpen } = this.props;
|
||||
|
||||
dispatch(closeOverflowMenuIfOpen());
|
||||
if (_isOpen) {
|
||||
dispatch(closeParticipantsPane());
|
||||
} else {
|
||||
dispatch(openParticipantsPane());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Override the _getAccessibilityLabel method to incorporate the dynamic participant count.
|
||||
*
|
||||
* @override
|
||||
* @returns {string}
|
||||
*/
|
||||
override _getAccessibilityLabel() {
|
||||
const { t, _participantsCount, _isOpen } = this.props;
|
||||
|
||||
if (_isOpen) {
|
||||
return t('toolbar.accessibilityLabel.closeParticipantsPane');
|
||||
}
|
||||
|
||||
return t('toolbar.accessibilityLabel.participants', {
|
||||
participantsCount: _participantsCount
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides AbstractButton's {@link Component#render()}.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
override render() {
|
||||
const { _isParticipantsPaneEnabled } = this.props;
|
||||
|
||||
if (!_isParticipantsPaneEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = 'toolbar-button-with-badge'>
|
||||
{ super.render() }
|
||||
<ParticipantsCounter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { isOpen } = state['features/participants-pane'];
|
||||
|
||||
return {
|
||||
_isOpen: isOpen,
|
||||
_isParticipantsPaneEnabled: isParticipantsPaneEnabled(state),
|
||||
_participantsCount: getParticipantCountForDisplay(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(ParticipantsPaneButton));
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconRaiseHand } from '../../../base/icons/svg';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
indicator: {
|
||||
backgroundColor: theme.palette.warning02,
|
||||
borderRadius: `${Number(theme.shape.borderRadius) / 2}px`,
|
||||
height: '24px',
|
||||
width: '24px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const RaisedHandIndicator = () => {
|
||||
const { classes: styles, theme } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { styles.indicator }>
|
||||
<Icon
|
||||
color = { theme.palette.icon04 }
|
||||
size = { 16 }
|
||||
src = { IconRaiseHand } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { approveRequest, denyRequest } from '../../../visitors/actions';
|
||||
import { IPromotionRequest } from '../../../visitors/types';
|
||||
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
|
||||
|
||||
import ParticipantItem from './ParticipantItem';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Promotion request reference.
|
||||
*/
|
||||
request: IPromotionRequest;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
button: {
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
moreButton: {
|
||||
paddingRight: '6px',
|
||||
paddingLeft: '6px',
|
||||
marginRight: theme.spacing(2)
|
||||
},
|
||||
contextMenu: {
|
||||
position: 'fixed',
|
||||
top: 'auto',
|
||||
marginRight: '8px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const VisitorsItem = ({
|
||||
request: r
|
||||
}: IProps) => {
|
||||
const { from, nick } = r;
|
||||
const { t } = useTranslation();
|
||||
const { classes: styles } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const admit = useCallback(() => dispatch(approveRequest(r)), [ dispatch, r ]);
|
||||
const reject = useCallback(() => dispatch(denyRequest(r)), [ dispatch, r ]);
|
||||
|
||||
return (
|
||||
<ParticipantItem
|
||||
actionsTrigger = { ACTION_TRIGGER.PERMANENT }
|
||||
audioMediaState = { MEDIA_STATE.NONE }
|
||||
displayName = { nick }
|
||||
participantID = { from }
|
||||
raisedHand = { true }
|
||||
videoMediaState = { MEDIA_STATE.NONE }
|
||||
youText = { t('chat.you') }>
|
||||
|
||||
{<>
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.reject')} ${r.nick}` }
|
||||
className = { styles.button }
|
||||
labelKey = 'participantsPane.actions.reject'
|
||||
onClick = { reject }
|
||||
size = 'small'
|
||||
testId = { `reject-${from}` }
|
||||
type = { BUTTON_TYPES.DESTRUCTIVE } />
|
||||
<Button
|
||||
accessibilityLabel = { `${t('participantsPane.actions.admit')} ${r.nick}` }
|
||||
className = { styles.button }
|
||||
labelKey = 'participantsPane.actions.admit'
|
||||
onClick = { admit }
|
||||
size = 'small'
|
||||
testId = { `admit-${from}` } />
|
||||
</>
|
||||
}
|
||||
</ParticipantItem>
|
||||
);
|
||||
};
|
||||
139
react/features/participants-pane/components/web/VisitorsList.tsx
Normal file
139
react/features/participants-pane/components/web/VisitorsList.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { admitMultiple, goLive } from '../../../visitors/actions';
|
||||
import {
|
||||
getPromotionRequests,
|
||||
getVisitorsCount,
|
||||
getVisitorsInQueueCount,
|
||||
isVisitorsLive,
|
||||
shouldDisplayCurrentVisitorsList
|
||||
} from '../../../visitors/functions';
|
||||
|
||||
import { VisitorsItem } from './VisitorsItem';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
margin: `${theme.spacing(3)} 0`
|
||||
},
|
||||
headingW: {
|
||||
color: theme.palette.warning02
|
||||
},
|
||||
drawerActions: {
|
||||
listStyleType: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
drawerItem: {
|
||||
alignItems: 'center',
|
||||
color: theme.palette.text01,
|
||||
display: 'flex',
|
||||
padding: '12px 16px',
|
||||
...theme.typography.bodyShortRegularLarge,
|
||||
|
||||
'&:first-child': {
|
||||
marginTop: '15px'
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
background: theme.palette.action02
|
||||
}
|
||||
},
|
||||
icon: {
|
||||
marginRight: 16
|
||||
},
|
||||
headingContainer: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
heading: {
|
||||
...theme.typography.bodyShortBold,
|
||||
color: theme.palette.text02
|
||||
},
|
||||
link: {
|
||||
...theme.typography.labelBold,
|
||||
color: theme.palette.link01,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component used to display a list of visitors waiting for approval to join the main meeting.
|
||||
*
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
export default function VisitorsList() {
|
||||
const requests = useSelector(getPromotionRequests);
|
||||
const visitorsCount = useSelector(getVisitorsCount);
|
||||
const visitorsInQueueCount = useSelector(getVisitorsInQueueCount);
|
||||
const isLive = useSelector(isVisitorsLive);
|
||||
const showVisitorsInQueue = visitorsInQueueCount > 0 && isLive === false;
|
||||
const showCurrentVisitorsList = useSelector(shouldDisplayCurrentVisitorsList);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { classes, cx } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const admitAll = useCallback(() => {
|
||||
dispatch(admitMultiple(requests));
|
||||
}, [ dispatch, requests ]);
|
||||
|
||||
const goLiveCb = useCallback(() => {
|
||||
dispatch(goLive());
|
||||
}, [ dispatch ]);
|
||||
|
||||
if (visitorsCount <= 0 && !showVisitorsInQueue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showCurrentVisitorsList && requests.length <= 0 && !showVisitorsInQueue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className = { classes.headingContainer }>
|
||||
<div
|
||||
className = { cx(classes.heading, classes.headingW) }
|
||||
id = 'visitor-list-header' >
|
||||
{ !showCurrentVisitorsList && t('participantsPane.headings.visitors', { count: visitorsCount })}
|
||||
{ requests.length > 0
|
||||
&& t(`participantsPane.headings.${showCurrentVisitorsList ? 'viewerRequests' : 'visitorRequests'}`, { count: requests.length }) }
|
||||
{ showVisitorsInQueue
|
||||
&& t('participantsPane.headings.visitorInQueue', { count: visitorsInQueueCount }) }
|
||||
</div>
|
||||
{
|
||||
requests.length > 1 && !showVisitorsInQueue // Go live button is with higher priority
|
||||
&& <div
|
||||
className = { classes.link }
|
||||
onClick = { admitAll }>{ t('participantsPane.actions.admitAll') }</div>
|
||||
}
|
||||
{
|
||||
showVisitorsInQueue
|
||||
&& <div
|
||||
className = { classes.link }
|
||||
onClick = { goLiveCb }>{ t('participantsPane.actions.goLive') }</div>
|
||||
}
|
||||
</div>
|
||||
{ requests.length > 0
|
||||
&& <div
|
||||
className = { classes.container }
|
||||
id = 'visitor-list'>
|
||||
{
|
||||
requests.map(r => (
|
||||
<VisitorsItem
|
||||
key = { r.from }
|
||||
request = { r } />)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
react/features/participants-pane/constants.tsx
Normal file
123
react/features/participants-pane/constants.tsx
Normal 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;
|
||||
327
react/features/participants-pane/functions.ts
Normal file
327
react/features/participants-pane/functions.ts
Normal 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;
|
||||
}
|
||||
81
react/features/participants-pane/hooks.web.ts
Normal file
81
react/features/participants-pane/hooks.web.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
react/features/participants-pane/middleware.ts
Normal file
28
react/features/participants-pane/middleware.ts
Normal 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);
|
||||
});
|
||||
54
react/features/participants-pane/reducer.ts
Normal file
54
react/features/participants-pane/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
);
|
||||
36
react/features/participants-pane/types.ts
Normal file
36
react/features/participants-pane/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user