diff --git a/css/_participants-pane.scss b/css/_participants-pane.scss new file mode 100644 index 000000000..f9804dd69 --- /dev/null +++ b/css/_participants-pane.scss @@ -0,0 +1,51 @@ +.participants_pane { + background-color: $participantsPaneBgColor; + flex-shrink: 0; + overflow: hidden; + position: relative; + transition: width .16s ease-in-out; + width: 315px; + z-index: $zindex0; + + &--closed { + width: 0; + } +} + +.participants_pane-content { + display: flex; + flex-direction: column; + font-weight: 600; + height: 100%; + width: 315px; + + & > *:first-child, + & > *:last-child { + flex-shrink: 0; + } +} + +.participant-avatar { + margin: 8px 16px 8px 0; +} + +@media (max-width: 375px) { + .participants_pane { + height: 100vh; + height: -webkit-fill-available; + left: 0; + position: fixed; + right: 0; + top: 0; + width: auto; + + &--closed { + display: none; + width: auto; + } + } + + .participants_pane-content { + width: 100%; + } +} \ No newline at end of file diff --git a/css/_variables.scss b/css/_variables.scss index 409b8eae0..12e334e8d 100644 --- a/css/_variables.scss +++ b/css/_variables.scss @@ -30,6 +30,7 @@ $defaultSideBarFontColor: #44A5FF; $defaultSemiDarkColor: #ACACAC; $defaultDarkColor: #2b3d5c; $defaultWarningColor: rgb(215, 121, 118); +$participantsPaneBgColor: #141414; $presence-available: rgb(110, 176, 5); $presence-away: rgb(250, 201, 20); $presence-busy: rgb(233, 0, 27); diff --git a/css/_videolayout_default.scss b/css/_videolayout_default.scss index 51aecc6c3..dcc74bdf4 100644 --- a/css/_videolayout_default.scss +++ b/css/_videolayout_default.scss @@ -1,5 +1,13 @@ #videoconference_page { min-height: 100%; + position: relative; + transform: translate3d(0, 0, 0); + width: 100%; +} + +#layout_wrapper { + display: flex; + height: 100%; } #videospace { diff --git a/css/components/_input-slider.scss b/css/components/_input-slider.scss index 2f28a88e2..57c518fc7 100644 --- a/css/components/_input-slider.scss +++ b/css/components/_input-slider.scss @@ -1,3 +1,5 @@ +$rangeInputThumbSize: 14; + /** * Disable the default webkit styles for range inputs (sliders). */ diff --git a/css/filmstrip/_tile_view.scss b/css/filmstrip/_tile_view.scss index a382faa24..4469deba7 100644 --- a/css/filmstrip/_tile_view.scss +++ b/css/filmstrip/_tile_view.scss @@ -16,7 +16,7 @@ display: flex; flex-direction: column; height: 100%; - width: 100vw; + width: 100%; } .filmstrip__videos .videocontainer { @@ -50,10 +50,6 @@ &.shift-right { margin-left: $sidebarWidth; width: calc(100% - #{$sidebarWidth}); - - #filmstripRemoteVideos { - width: calc(100vw - #{$sidebarWidth}); - } } } } diff --git a/css/main.scss b/css/main.scss index b0e6f64ec..97a0daf44 100644 --- a/css/main.scss +++ b/css/main.scss @@ -104,5 +104,6 @@ $flagsImagePath: "../images/"; @import 'responsive'; @import 'connection-status'; @import 'drawer'; +@import 'participants-pane'; /* Modules END */ diff --git a/lang/main.json b/lang/main.json index 45b20efd7..32f176b01 100644 --- a/lang/main.json +++ b/lang/main.json @@ -418,6 +418,7 @@ "showSpeakerStats": "Show speaker stats", "toggleChat": "Open or close the chat", "toggleFilmstrip": "Show or hide video thumbnails", + "toggleParticipantsPane": "Show or hide the participants pane", "toggleScreensharing": "Switch between camera and screen sharing", "toggleShortcuts": "Show or hide keyboard shortcuts", "videoMute": "Start or stop your camera" @@ -527,6 +528,16 @@ "oldElectronClientDescription2": "latest build", "oldElectronClientDescription3": " now!" }, + "participantsPane": { + "headings": { + "lobby": "Lobby ({{count}})", + "participantsList": "Meeting participants ({{count}})" + }, + "actions": { + "muteAll": "Mute all", + "stopVideo": "Stop video" + } + }, "passwordSetRemotely": "Set by another participant", "passwordDigitsOnly": "Up to {{number}} digits", "poweredby": "powered by", @@ -745,6 +756,7 @@ "muteEveryoneElse": "Mute everyone else", "muteEveryonesVideo": "Disable everyone's camera", "muteEveryoneElsesVideo": "Disable everyone else's camera", + "participants": "Participants", "pip": "Toggle Picture-in-Picture mode", "privateMessage": "Send private message", "profile": "Edit your profile", @@ -807,6 +819,7 @@ "noisyAudioInputTitle": "Your microphone appears to be noisy!", "noisyAudioInputDesc": "It sounds like your microphone is making noise, please consider muting or changing the device.", "openChat": "Open chat", + "participants": "Participants", "pip": "Enter Picture-in-Picture mode", "privateMessage": "Send private message", "profile": "Edit your profile", @@ -942,6 +955,7 @@ "header": "Help center" }, "lobby": { + "admit": "Admit", "knockingParticipantList": "Knocking participant list", "allow": "Allow", "backToKnockModeButton": "No password, ask to join instead", diff --git a/modules/UI/videolayout/LargeVideoManager.js b/modules/UI/videolayout/LargeVideoManager.js index 966b10e19..eeac501b3 100644 --- a/modules/UI/videolayout/LargeVideoManager.js +++ b/modules/UI/videolayout/LargeVideoManager.js @@ -19,6 +19,8 @@ import { CHAT_SIZE } from '../../../react/features/chat'; import { updateKnownLargeVideoResolution } from '../../../react/features/large-video/actions'; +import { getParticipantsPaneOpen } from '../../../react/features/participants-pane/functions'; +import theme from '../../../react/features/participants-pane/theme.json'; import { PresenceLabel } from '../../../react/features/presence-status'; import { shouldDisplayTileView } from '../../../react/features/video-layout'; /* eslint-enable no-unused-vars */ @@ -366,7 +368,13 @@ export default class LargeVideoManager { } let widthToUse = this.preferredWidth || window.innerWidth; - const { isOpen } = APP.store.getState()['features/chat']; + const state = APP.store.getState(); + const { isOpen } = state['features/chat']; + const isParticipantsPaneOpen = getParticipantsPaneOpen(state); + + if (isParticipantsPaneOpen) { + widthToUse -= theme.participantsPaneWidth; + } if (isOpen && window.innerWidth > 580) { /** diff --git a/package.json b/package.json index 277b2b64a..4ae2704be 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,10 @@ "i18next": "17.0.6", "i18next-browser-languagedetector": "3.0.1", "i18next-xhr-backend": "3.0.0", - "jQuery-Impromptu": "github:trentrichardson/jQuery-Impromptu#v6.0.0", "jitsi-meet-logger": "github:jitsi/jitsi-meet-logger#v1.0.0", "jquery": "3.5.1", "jquery-i18next": "1.2.1", + "jQuery-Impromptu": "github:trentrichardson/jQuery-Impromptu#v6.0.0", "js-md5": "0.6.1", "jwt-decode": "2.2.0", "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#0dc1540a44131d4c287993d2cfe0ed8e5ae70d4c", diff --git a/react/features/app/reducers.web.js b/react/features/app/reducers.web.js index 989b9c996..85df771cc 100644 --- a/react/features/app/reducers.web.js +++ b/react/features/app/reducers.web.js @@ -6,6 +6,7 @@ import '../feedback/reducer'; import '../local-recording/reducer'; import '../no-audio-signal/reducer'; import '../noise-detection/reducer'; +import '../participants-pane/reducer'; import '../power-monitor/reducer'; import '../prejoin/reducer'; import '../remote-control/reducer'; diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index 6a563b64d..b87f0b8d2 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -20,6 +20,22 @@ import { } from './constants'; import logger from './logger'; +/** + * Returns root conference state. + * + * @param {Object} state - Global state. + * @returns {Object} Conference state. + */ +export const getConferenceState = (state: Object) => state['features/base/conference']; + +/** + * Is the conference joined or not. + * + * @param {Object} state - Global state. + * @returns {boolean} + */ +export const getIsConferenceJoined = (state: Object) => Boolean(getConferenceState(state).conference); + /** * Attach a set of local tracks to a conference. * @@ -123,7 +139,7 @@ export function commonUserLeftHandling( export function forEachConference( stateful: Function | Object, predicate: (Object, URL) => boolean) { - const state = toState(stateful)['features/base/conference']; + const state = getConferenceState(toState(stateful)); for (const v of Object.values(state)) { // Does the value of the base/conference's property look like a @@ -157,7 +173,7 @@ export function getConferenceName(stateful: Function | Object): string { const state = toState(stateful); const { callee } = state['features/base/jwt']; const { callDisplayName } = state['features/base/config']; - const { pendingSubjectChange, room, subject } = state['features/base/conference']; + const { pendingSubjectChange, room, subject } = getConferenceState(state); return pendingSubjectChange || subject @@ -174,7 +190,7 @@ export function getConferenceName(stateful: Function | Object): string { * @returns {string} - The name of the conference formatted for the title. */ export function getConferenceNameForTitle(stateful: Function | Object) { - return safeStartCase(safeDecodeURIComponent(toState(stateful)['features/base/conference'].room)); + return safeStartCase(safeDecodeURIComponent(getConferenceState(toState(stateful)).room)); } /** @@ -186,7 +202,7 @@ export function getConferenceNameForTitle(stateful: Function | Object) { */ export function getConferenceTimestamp(stateful: Function | Object): number { const state = toState(stateful); - const { conferenceTimestamp } = state['features/base/conference']; + const { conferenceTimestamp } = getConferenceState(state); return conferenceTimestamp; } @@ -203,7 +219,7 @@ export function getConferenceTimestamp(stateful: Function | Object): number { */ export function getCurrentConference(stateful: Function | Object) { const { conference, joining, leaving, membersOnly, passwordRequired } - = toState(stateful)['features/base/conference']; + = getConferenceState(toState(stateful)); // There is a precedence if (conference) { @@ -220,7 +236,7 @@ export function getCurrentConference(stateful: Function | Object) { * @returns {string} */ export function getRoomName(state: Object): string { - return state['features/base/conference'].room; + return getConferenceState(state).room; } /** diff --git a/react/features/base/config/constants.js b/react/features/base/config/constants.js index b5165ceb7..b41d3ff0f 100644 --- a/react/features/base/config/constants.js +++ b/react/features/base/config/constants.js @@ -17,7 +17,7 @@ export const TOOLBAR_BUTTONS = [ 'microphone', 'camera', 'closedcaptions', 'desktop', 'embedmeeting', 'fullscreen', 'fodeviceselection', 'hangup', 'profile', 'chat', 'recording', 'livestreaming', 'etherpad', 'sharedvideo', 'shareaudio', 'settings', 'raisehand', - 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts', + 'videoquality', 'filmstrip', 'participants-pane', 'feedback', 'stats', 'shortcuts', 'tileview', 'select-background', 'download', 'help', 'mute-everyone', 'mute-video-everyone', 'security', 'toggle-camera' ]; diff --git a/react/features/base/icons/svg/close-circle.svg b/react/features/base/icons/svg/close-circle.svg new file mode 100644 index 000000000..388e6279b --- /dev/null +++ b/react/features/base/icons/svg/close-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index a8fc84912..adbbf839e 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -27,6 +27,7 @@ export { default as IconChatSend } from './send.svg'; export { default as IconChatUnread } from './chat-unread.svg'; export { default as IconCheck } from './check.svg'; export { default as IconClose } from './close.svg'; +export { default as IconCloseCircle } from './close-circle.svg'; export { default as IconCloseX } from './close-x.svg'; export { default as IconClosedCaption } from './closed_caption.svg'; export { default as IconCloseSmall } from './close-small.svg'; @@ -68,10 +69,13 @@ export { default as IconMenuThumb } from './thumb-menu.svg'; export { default as IconMenuUp } from './menu-up.svg'; export { default as IconMessage } from './message.svg'; export { default as IconMeter } from './meter.svg'; +export { default as IconMicBlockedHollow } from './mic-blocked.svg'; export { default as IconMicDisabled } from './mic-disabled.svg'; +export { default as IconMicDisabledHollow } from './mic-disabled-hollow.svg'; export { default as IconMicrophone } from './microphone.svg'; export { default as IconMicrophoneEmpty } from './microphone-empty.svg'; export { default as IconMicrophoneEmptySlash } from './microphone-empty-slash.svg'; +export { default as IconMicrophoneHollow } from './microphone-hollow.svg'; export { default as IconModerator } from './star.svg'; export { default as IconMuteEveryone } from './mute-everyone.svg'; export { default as IconMuteEveryoneElse } from './mute-everyone-else.svg'; @@ -80,11 +84,13 @@ export { default as IconMuteVideoEveryoneElse } from './mute-video-everyone-else export { default as IconNotificationJoin } from './navigate_next.svg'; export { default as IconOpenInNew } from './open_in_new.svg'; export { default as IconOutlook } from './office365.svg'; +export { default as IconParticipants } from './participants.svg'; export { default as IconPhone } from './phone.svg'; export { default as IconPin } from './enlarge.svg'; export { default as IconPlane } from './paper-plane.svg'; export { default as IconPresentation } from './presentation.svg'; export { default as IconRaisedHand } from './raised-hand.svg'; +export { default as IconRaisedHandHollow } from './raised-hand-hollow.svg'; export { default as IconRec } from './rec.svg'; export { default as IconRemoteControlStart } from './play.svg'; export { default as IconRemoteControlStop } from './stop.svg'; @@ -109,6 +115,7 @@ export { default as IconSwitchCamera } from './switch-camera.svg'; export { default as IconTileView } from './tiles-many.svg'; export { default as IconToggleRecording } from './camera-take-picture.svg'; export { default as IconTrash } from './trash.svg'; +export { default as IconVideoOff } from './video-off.svg'; export { default as IconVideoQualityAudioOnly } from './AUD.svg'; export { default as IconVideoQualityHD } from './HD.svg'; export { default as IconVideoQualityLD } from './LD.svg'; diff --git a/react/features/base/icons/svg/message.svg b/react/features/base/icons/svg/message.svg index 2b47ee860..5763290c2 100644 --- a/react/features/base/icons/svg/message.svg +++ b/react/features/base/icons/svg/message.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/react/features/base/icons/svg/mic-blocked.svg b/react/features/base/icons/svg/mic-blocked.svg new file mode 100644 index 000000000..1c5449018 --- /dev/null +++ b/react/features/base/icons/svg/mic-blocked.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/mic-disabled-hollow.svg b/react/features/base/icons/svg/mic-disabled-hollow.svg new file mode 100755 index 000000000..e0fd9e9f3 --- /dev/null +++ b/react/features/base/icons/svg/mic-disabled-hollow.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/microphone-hollow.svg b/react/features/base/icons/svg/microphone-hollow.svg new file mode 100644 index 000000000..7b4574cab --- /dev/null +++ b/react/features/base/icons/svg/microphone-hollow.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/mute-everyone-else.svg b/react/features/base/icons/svg/mute-everyone-else.svg index 70ecdb83f..4c37c9161 100644 --- a/react/features/base/icons/svg/mute-everyone-else.svg +++ b/react/features/base/icons/svg/mute-everyone-else.svg @@ -1,4 +1,4 @@ - + diff --git a/react/features/base/icons/svg/participants.svg b/react/features/base/icons/svg/participants.svg new file mode 100644 index 000000000..ee5d28ce5 --- /dev/null +++ b/react/features/base/icons/svg/participants.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/raised-hand-hollow.svg b/react/features/base/icons/svg/raised-hand-hollow.svg new file mode 100755 index 000000000..b3dd58e7d --- /dev/null +++ b/react/features/base/icons/svg/raised-hand-hollow.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/video-off.svg b/react/features/base/icons/svg/video-off.svg new file mode 100644 index 000000000..a68aa6b7f --- /dev/null +++ b/react/features/base/icons/svg/video-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/responsive-ui/actions.js b/react/features/base/responsive-ui/actions.js index 882a1749e..ed4cc4784 100644 --- a/react/features/base/responsive-ui/actions.js +++ b/react/features/base/responsive-ui/actions.js @@ -3,6 +3,8 @@ import type { Dispatch } from 'redux'; import { CHAT_SIZE } from '../../chat/constants'; +import { getParticipantsPaneOpen } from '../../participants-pane/functions'; +import theme from '../../participants-pane/theme.json'; import { CLIENT_RESIZED, SET_ASPECT_RATIO, SET_REDUCED_UI } from './actionTypes'; import { ASPECT_RATIO_NARROW, ASPECT_RATIO_WIDE } from './constants'; @@ -28,11 +30,18 @@ const REDUCED_UI_THRESHOLD = 300; export function clientResized(clientWidth: number, clientHeight: number) { return (dispatch: Dispatch, getState: Function) => { const state = getState(); - const { isOpen } = state['features/chat']; + const { isOpen: isChatOpen } = state['features/chat']; + const isParticipantsPaneOpen = getParticipantsPaneOpen(state); let availableWidth = clientWidth; - if (isOpen && navigator.product !== 'ReactNative') { - availableWidth -= CHAT_SIZE; + if (navigator.product !== 'ReactNative') { + if (isChatOpen) { + availableWidth -= CHAT_SIZE; + } + + if (isParticipantsPaneOpen) { + availableWidth -= theme.participantsPaneWidth; + } } return dispatch({ diff --git a/react/features/base/tracks/functions.js b/react/features/base/tracks/functions.js index d96e3a23e..ff1135c2e 100644 --- a/react/features/base/tracks/functions.js +++ b/react/features/base/tracks/functions.js @@ -12,6 +12,78 @@ import { import loadEffects from './loadEffects'; import logger from './logger'; +/** + * Returns root tracks state. + * + * @param {Object} state - Global state. + * @returns {Object} Tracks state. + */ +export const getTrackState = state => state['features/base/tracks']; + +/** + * Higher-order function that returns a selector for a specific participant + * and media type. + * + * @param {Object} participant - Participant reference. + * @param {MEDIA_TYPE} mediaType - Media type. + * @returns {Function} Selector. + */ +export const getIsParticipantMediaMuted = (participant, mediaType) => + + /** + * Bound selector. + * + * @param {Object} state - Global state. + * @returns {boolean} Is the media type muted for the participant. + */ + state => { + if (!participant) { + return; + } + + const tracks = getTrackState(state); + + if (participant?.local) { + return isLocalTrackMuted(tracks, mediaType); + } else if (!participant?.isFakeParticipant) { + return isRemoteTrackMuted(tracks, mediaType, participant.id); + } + + return true; + }; + +/** + * Higher-order function that returns a selector for a specific participant. + * + * @param {Object} participant - Participant reference. + * @returns {Function} Selector. + */ +export const getIsParticipantAudioMuted = participant => + + /** + * Bound selector. + * + * @param {Object} state - Global state. + * @returns {boolean} Is audio muted for the participant. + */ + state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.AUDIO)(state); + +/** + * Higher-order function that returns a selector for a specific participant. + * + * @param {Object} participant - Participant reference. + * @returns {Function} Selector. + */ +export const getIsParticipantVideoMuted = participant => + + /** + * Bound selector. + * + * @param {Object} state - Global state. + * @returns {boolean} Is video muted for the participant. + */ + state => getIsParticipantMediaMuted(participant, MEDIA_TYPE.VIDEO)(state); + /** * Creates a local video track for presenter. The constraints are computed based * on the height of the desktop that is being shared. @@ -311,7 +383,7 @@ export function getLocalVideoType(tracks) { * @returns {Object} */ export function getLocalJitsiVideoTrack(state) { - const track = getLocalVideoTrack(state['features/base/tracks']); + const track = getLocalVideoTrack(getTrackState(state)); return track?.jitsiTrack; } @@ -323,7 +395,7 @@ export function getLocalJitsiVideoTrack(state) { * @returns {Object} */ export function getLocalJitsiAudioTrack(state) { - const track = getLocalAudioTrack(state['features/base/tracks']); + const track = getLocalAudioTrack(getTrackState(state)); return track?.jitsiTrack; } @@ -413,7 +485,7 @@ export function isLocalTrackMuted(tracks, mediaType) { * @returns {boolean} */ export function isLocalVideoTrackDesktop(state) { - const videoTrack = getLocalVideoTrack(state['features/base/tracks']); + const videoTrack = getLocalVideoTrack(getTrackState(state)); return videoTrack && videoTrack.videoType === VIDEO_TYPE.DESKTOP; } diff --git a/react/features/conference/components/web/Conference.js b/react/features/conference/components/web/Conference.js index 8aaba811c..958f3f977 100644 --- a/react/features/conference/components/web/Conference.js +++ b/react/features/conference/components/web/Conference.js @@ -14,6 +14,8 @@ import { Filmstrip } from '../../../filmstrip'; import { CalleeInfoContainer } from '../../../invite'; import { LargeVideo } from '../../../large-video'; import { KnockingParticipantList, LobbyScreen } from '../../../lobby'; +import { ParticipantsPane } from '../../../participants-pane/components'; +import { getParticipantsPaneOpen } from '../../../participants-pane/functions'; import { Prejoin, isPrejoinPageVisible } from '../../../prejoin'; import { fullScreenChanged, showToolbox } from '../../../toolbox/actions.web'; import { Toolbox } from '../../../toolbox/components/web'; @@ -72,6 +74,11 @@ type Props = AbstractProps & { */ _isLobbyScreenVisible: boolean, + /** + * If participants pane is visible or not. + */ + _isParticipantsPaneVisible: boolean, + /** * The CSS class to apply to the root of {@link Conference} to modify the * application layout. @@ -179,33 +186,38 @@ class Conference extends AbstractConference { render() { const { _isLobbyScreenVisible, + _isParticipantsPaneVisible, _layoutClassName, _showPrejoin } = this.props; return ( -
- +
+
+ + + +
+ + {!_isParticipantsPaneVisible && } + +
+ + { _showPrejoin || _isLobbyScreenVisible || } + + + { this.renderNotificationsContainer() } + + + + { _showPrejoin && } - -
- - -
- - { _showPrejoin || _isLobbyScreenVisible || } - - - { this.renderNotificationsContainer() } - - - - { _showPrejoin && } +
); } @@ -297,6 +309,7 @@ function _mapStateToProps(state) { ...abstractMapStateToProps(state), _backgroundAlpha: state['features/base/config'].backgroundAlpha, _isLobbyScreenVisible: state['features/base/dialog']?.component === LobbyScreen, + _isParticipantsPaneVisible: getParticipantsPaneOpen(state), _layoutClassName: LAYOUT_CLASSNAMES[getCurrentLayout(state)], _roomName: getConferenceNameForTitle(state), _showPrejoin: isPrejoinPageVisible(state) diff --git a/react/features/filmstrip/subscriber.web.js b/react/features/filmstrip/subscriber.web.js index dc306d417..5a81422cc 100644 --- a/react/features/filmstrip/subscriber.web.js +++ b/react/features/filmstrip/subscriber.web.js @@ -3,6 +3,7 @@ import { StateListenerRegistry, equals } from '../base/redux'; import { clientResized } from '../base/responsive-ui'; import { setFilmstripVisible } from '../filmstrip/actions'; +import { getParticipantsPaneOpen } from '../participants-pane/functions'; import { setOverflowDrawer } from '../toolbox/actions.web'; import { getCurrentLayout, getTileViewGridDimensions, shouldDisplayTileView, LAYOUTS } from '../video-layout'; @@ -92,6 +93,19 @@ StateListenerRegistry.register( store.dispatch(clientResized(innerWidth, innerHeight)); }); +/** + * Listens for changes in the participant pane state to calculate the + * dimensions of the tile view grid and the tiles. + */ +StateListenerRegistry.register( + /* selector */ getParticipantsPaneOpen, + /* listener */ (isOpen, store) => { + const { innerWidth, innerHeight } = window; + + store.dispatch(clientResized(innerWidth, innerHeight)); + }); + + /** * Listens for changes in the client width to determine whether the overflow menu(s) should be displayed as drawers. */ diff --git a/react/features/lobby/components/AbstractKnockingParticipantList.js b/react/features/lobby/components/AbstractKnockingParticipantList.js index 72b6d7b28..b49fa8765 100644 --- a/react/features/lobby/components/AbstractKnockingParticipantList.js +++ b/react/features/lobby/components/AbstractKnockingParticipantList.js @@ -4,6 +4,7 @@ import { PureComponent } from 'react'; import { isLocalParticipantModerator } from '../../base/participants'; import { setKnockingParticipantApproval } from '../actions'; +import { getLobbyState } from '../functions'; export type Props = { @@ -66,7 +67,7 @@ export default class AbstractKnockingParticipantList extends P * @returns {Props} */ export function mapStateToProps(state: Object): $Shape { - const { knockingParticipants, lobbyEnabled } = state['features/lobby']; + const { knockingParticipants, lobbyEnabled } = getLobbyState(state); return { _participants: knockingParticipants, diff --git a/react/features/lobby/functions.js b/react/features/lobby/functions.js index 151c93340..0a4a01ce1 100644 --- a/react/features/lobby/functions.js +++ b/react/features/lobby/functions.js @@ -21,3 +21,14 @@ export function setKnockingParticipantApproval(getState: Function, id: string, a } } } + + +/** + * Selector to return lobby state. + * + * @param {any} state - State object. + * @returns {any} + */ +export function getLobbyState(state: any) { + return state['features/lobby']; +} diff --git a/react/features/participants-pane/actionTypes.js b/react/features/participants-pane/actionTypes.js new file mode 100644 index 000000000..9e63d9ad6 --- /dev/null +++ b/react/features/participants-pane/actionTypes.js @@ -0,0 +1,9 @@ +/** + * 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'; diff --git a/react/features/participants-pane/actions.js b/react/features/participants-pane/actions.js new file mode 100644 index 000000000..e2896f0f9 --- /dev/null +++ b/react/features/participants-pane/actions.js @@ -0,0 +1,26 @@ +import { + PARTICIPANTS_PANE_CLOSE, + PARTICIPANTS_PANE_OPEN +} from './actionTypes'; + +/** + * Action to close the participants pane. + * + * @returns {Object} + */ +export const close = () => { + return { + type: PARTICIPANTS_PANE_CLOSE + }; +}; + +/** + * Action to open the participants pane. + * + * @returns {Object} + */ +export const open = () => { + return { + type: PARTICIPANTS_PANE_OPEN + }; +}; diff --git a/react/features/participants-pane/components/InviteButton.js b/react/features/participants-pane/components/InviteButton.js new file mode 100644 index 000000000..b86c2bc41 --- /dev/null +++ b/react/features/participants-pane/components/InviteButton.js @@ -0,0 +1,32 @@ +// @flow + +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { createToolbarEvent, sendAnalytics } from '../../analytics'; +import { Icon, IconInviteMore } from '../../base/icons'; +import { beginAddPeople } from '../../invite'; + +import { ParticipantInviteButton } from './styled'; + +export const InviteButton = () => { + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const onInvite = useCallback(() => { + sendAnalytics(createToolbarEvent('invite')); + dispatch(beginAddPeople()); + }, [ dispatch ]); + + return ( + + + Invite Someone + + ); +}; diff --git a/react/features/participants-pane/components/LobbyParticipantItem.js b/react/features/participants-pane/components/LobbyParticipantItem.js new file mode 100644 index 000000000..e259dea36 --- /dev/null +++ b/react/features/participants-pane/components/LobbyParticipantItem.js @@ -0,0 +1,44 @@ +// @flow + +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { setKnockingParticipantApproval } from '../../lobby/actions'; +import { ActionTrigger, MediaState } from '../constants'; + +import { ParticipantItem } from './ParticipantItem'; +import { ParticipantActionButton } from './styled'; + +type Props = { + + /** + * Participant reference + */ + participant: Object +}; + +export const LobbyParticipantItem = ({ participant: p }: Props) => { + const dispatch = useDispatch(); + const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true), [ dispatch ])); + const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false), [ dispatch ])); + const { t } = useTranslation(); + + return ( + + + {t('lobby.reject')} + + + {t('lobby.admit')} + + + ); +}; diff --git a/react/features/participants-pane/components/LobbyParticipantList.js b/react/features/participants-pane/components/LobbyParticipantList.js new file mode 100644 index 000000000..02cdb288d --- /dev/null +++ b/react/features/participants-pane/components/LobbyParticipantList.js @@ -0,0 +1,35 @@ +// @flow + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { getLobbyState } from '../../lobby/functions'; + +import { LobbyParticipantItem } from './LobbyParticipantItem'; +import { Heading } from './styled'; + +export const LobbyParticipantList = () => { + const { + lobbyEnabled, + knockingParticipants: participants + } = useSelector(getLobbyState); + const { t } = useTranslation(); + + if (!lobbyEnabled || !participants.length) { + return null; + } + + return ( + <> + {t('participantsPane.headings.lobby', { count: participants.length })} +
+ {participants.map(p => ( + ) + )} +
+ + ); +}; diff --git a/react/features/participants-pane/components/MeetingParticipantContextMenu.js b/react/features/participants-pane/components/MeetingParticipantContextMenu.js new file mode 100644 index 000000000..65b713e0a --- /dev/null +++ b/react/features/participants-pane/components/MeetingParticipantContextMenu.js @@ -0,0 +1,166 @@ +// @flow + +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; + +import { openDialog } from '../../base/dialog'; +import { + IconCloseCircle, + IconCrown, + IconMessage, + IconMuteEveryoneElse, + IconVideoOff +} from '../../base/icons'; +import { isLocalParticipantModerator } from '../../base/participants'; +import { getIsParticipantVideoMuted } from '../../base/tracks'; +import { openChat } from '../../chat/actions'; +import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu'; +import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog'; +import { getComputedOuterHeight } from '../functions'; + +import { + ContextMenu, + ContextMenuIcon, + ContextMenuItem, + ContextMenuItemGroup, + ignoredChildClassName +} from './styled'; + +type Props = { + + /** + * Target elements against which positioning calculations are made + */ + offsetTarget: HTMLElement, + + /** + * Callback for the mouse entering the component + */ + onEnter: Function, + + /** + * Callback for the mouse leaving the component + */ + onLeave: Function, + + /** + * Callback for making a selection in the menu + */ + onSelect: Function, + + /** + * Participant reference + */ + participant: Object +}; + +export const MeetingParticipantContextMenu = ({ + offsetTarget, + onEnter, + onLeave, + onSelect, + participant +}: Props) => { + const dispatch = useDispatch(); + const containerRef = useRef(null); + const isLocalModerator = useSelector(isLocalParticipantModerator); + const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant)); + const [ isHidden, setIsHidden ] = useState(true); + const { t } = useTranslation(); + + useLayoutEffect(() => { + if (participant + && containerRef.current + && offsetTarget?.offsetParent + && offsetTarget.offsetParent instanceof HTMLElement + ) { + const { current: container } = containerRef; + const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget; + const outerHeight = getComputedOuterHeight(container); + + container.style.top = offsetTop + outerHeight > offsetHeight + scrollTop + ? offsetTop - outerHeight + : offsetTop; + + setIsHidden(false); + } else { + setIsHidden(true); + } + }, [ participant, offsetTarget ]); + + const grantModerator = useCallback(() => { + dispatch(openDialog(GrantModeratorDialog, { + participantID: participant.id + })); + }, [ dispatch, participant ]); + + const kick = useCallback(() => { + dispatch(openDialog(KickRemoteParticipantDialog, { + participantID: participant.id + })); + }, [ dispatch, participant ]); + + const muteEveryoneElse = useCallback(() => { + dispatch(openDialog(MuteEveryoneDialog, { + exclude: [ participant.id ] + })); + }, [ dispatch, participant ]); + + const muteVideo = useCallback(() => { + dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { + participantID: participant.id + })); + }, [ dispatch, participant ]); + + const sendPrivateMessage = useCallback(() => { + dispatch(openChat(participant)); + }, [ dispatch, participant ]); + + if (!participant) { + return null; + } + + return ( + + + {isLocalModerator && ( + + + {t('toolbar.accessibilityLabel.muteEveryoneElse')} + + )} + {isLocalModerator && (isParticipantVideoMuted || ( + + + {t('participantsPane.actions.stopVideo')} + + ))} + + + {isLocalModerator && ( + + + {t('toolbar.accessibilityLabel.grantModerator')} + + )} + {isLocalModerator && ( + + + {t('videothumbnail.kick')} + + )} + + + {t('toolbar.accessibilityLabel.privateMessage')} + + + + ); +}; diff --git a/react/features/participants-pane/components/MeetingParticipantItem.js b/react/features/participants-pane/components/MeetingParticipantItem.js new file mode 100644 index 000000000..605f07469 --- /dev/null +++ b/react/features/participants-pane/components/MeetingParticipantItem.js @@ -0,0 +1,55 @@ +// @flow + +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks'; +import { ActionTrigger, MediaState } from '../constants'; + +import { ParticipantItem } from './ParticipantItem'; +import { ParticipantActionEllipsis } from './styled'; + +type Props = { + + /** + * Is this item highlighted + */ + isHighlighted: boolean, + + /** + * Callback for the activation of this item's context menu + */ + onContextMenu: Function, + + /** + * Callback for the mouse leaving this item + */ + onLeave: Function, + + /** + * Participant reference + */ + participant: Object +}; + +export const MeetingParticipantItem = ({ + isHighlighted, + onContextMenu, + onLeave, + participant +}: Props) => { + const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant)); + const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant)); + + return ( + + + + ); +}; diff --git a/react/features/participants-pane/components/MeetingParticipantList.js b/react/features/participants-pane/components/MeetingParticipantList.js new file mode 100644 index 000000000..462402c93 --- /dev/null +++ b/react/features/participants-pane/components/MeetingParticipantList.js @@ -0,0 +1,108 @@ +// @flow + +import _ from 'lodash'; +import React, { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { getParticipants } from '../../base/participants'; +import { findStyledAncestor } from '../functions'; + +import { InviteButton } from './InviteButton'; +import { MeetingParticipantContextMenu } from './MeetingParticipantContextMenu'; +import { MeetingParticipantItem } from './MeetingParticipantItem'; +import { Heading, ParticipantContainer } from './styled'; + +type NullProto = { + [key: string]: any, + __proto__: null +}; + +type RaiseContext = NullProto | { + + /** + * Target elements against which positioning calculations are made + */ + offsetTarget?: HTMLElement, + + /** + * Participant reference + */ + participant?: Object, +}; + +const initialState = Object.freeze(Object.create(null)); + +export const MeetingParticipantList = () => { + const isMouseOverMenu = useRef(false); + const participants = useSelector(getParticipants, _.isEqual); + const [ raiseContext, setRaiseContext ] = useState(initialState); + const { t } = useTranslation(); + + const lowerMenu = useCallback(() => { + /** + * We are tracking mouse movement over the active participant item and + * the context menu. Due to the order of enter/leave events, we need to + * defer checking if the mouse is over the context menu with + * queueMicrotask + */ + window.queueMicrotask(() => { + if (isMouseOverMenu.current) { + return; + } + + if (raiseContext !== initialState) { + setRaiseContext(initialState); + } + }); + }, [ raiseContext ]); + + const raiseMenu = useCallback((participant, target) => { + setRaiseContext({ + participant, + offsetTarget: findStyledAncestor(target, ParticipantContainer) + }); + }, [ raiseContext ]); + + const toggleMenu = useCallback(participant => e => { + const { participant: raisedParticipant } = raiseContext; + + if (raisedParticipant && raisedParticipant === participant) { + lowerMenu(); + } else { + raiseMenu(participant, e.target); + } + }, [ raiseContext ]); + + const menuEnter = useCallback(() => { + isMouseOverMenu.current = true; + }, []); + + const menuLeave = useCallback(() => { + isMouseOverMenu.current = false; + lowerMenu(); + }, [ lowerMenu ]); + + return ( + <> + {t('participantsPane.headings.participantsList', { count: participants.length })} + +
+ {participants.map(p => ( + + ))} +
+ + + ); +}; + diff --git a/react/features/participants-pane/components/ParticipantItem.js b/react/features/participants-pane/components/ParticipantItem.js new file mode 100644 index 000000000..f65572c1c --- /dev/null +++ b/react/features/participants-pane/components/ParticipantItem.js @@ -0,0 +1,154 @@ +// @flow + +import React, { type Node } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Avatar } from '../../base/avatar'; +import { + Icon, + IconCameraEmpty, + IconCameraEmptyDisabled, + IconMicrophoneEmpty, + IconMicrophoneEmptySlash +} from '../../base/icons'; +import { ActionTrigger, MediaState } from '../constants'; + +import { RaisedHandIndicator } from './RaisedHandIndicator'; +import { + ParticipantActionsHover, + ParticipantActionsPermanent, + ParticipantContainer, + ParticipantContent, + ParticipantName, + ParticipantNameContainer, + ParticipantStates +} from './styled'; + +/** + * Participant actions component mapping depending on trigger type. + */ +const Actions = { + [ActionTrigger.Hover]: ParticipantActionsHover, + [ActionTrigger.Permanent]: ParticipantActionsPermanent +}; + +/** + * Icon mapping for possible participant audio states. + */ +const AudioStateIcons = { + [MediaState.ForceMuted]: ( + + ), + [MediaState.Muted]: ( + + ), + [MediaState.Unmuted]: ( + + ), + [MediaState.None]: null +}; + +/** + * Icon mapping for possible participant video states. + */ +const VideoStateIcons = { + [MediaState.ForceMuted]: ( + + ), + [MediaState.Muted]: ( + + ), + [MediaState.Unmuted]: ( + + ), + [MediaState.None]: null +}; + +type Props = { + + /** + * Type of trigger for the participant actions + */ + actionsTrigger: ActionTrigger, + + /** + * Media state for audio + */ + audioMuteState: MediaState, + + /** + * React children + */ + children: Node, + + /** + * Is this item highlighted/raised + */ + isHighlighted?: boolean, + + /** + * Callback for when the mouse leaves this component + */ + onLeave?: Function, + + /** + * Participant reference + */ + participant: Object, + + /** + * Media state for video + */ + videoMuteState: MediaState +} + +export const ParticipantItem = ({ + children, + isHighlighted, + onLeave, + actionsTrigger = ActionTrigger.Hover, + audioMuteState = MediaState.None, + videoMuteState = MediaState.None, + participant: p +}: Props) => { + const ParticipantActions = Actions[actionsTrigger]; + const { t } = useTranslation(); + + return ( + + + + + + { p.name } + + { p.local ?  ({t('chat.you')}) : null } + + { !p.local && } + + {p.raisedHand && } + {VideoStateIcons[videoMuteState]} + {AudioStateIcons[audioMuteState]} + + + + ); +}; diff --git a/react/features/participants-pane/components/ParticipantsPane.js b/react/features/participants-pane/components/ParticipantsPane.js new file mode 100644 index 000000000..efb4b0783 --- /dev/null +++ b/react/features/participants-pane/components/ParticipantsPane.js @@ -0,0 +1,62 @@ +// @flow + +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { ThemeProvider } from 'styled-components'; + +import { openDialog } from '../../base/dialog'; +import { isLocalParticipantModerator } from '../../base/participants'; +import { MuteEveryoneDialog } from '../../video-menu/components/'; +import { close } from '../actions'; +import { classList, getParticipantsPaneOpen } from '../functions'; +import theme from '../theme.json'; + +import { LobbyParticipantList } from './LobbyParticipantList'; +import { MeetingParticipantList } from './MeetingParticipantList'; +import { + AntiCollapse, + Close, + Container, + Footer, + FooterButton, + Header +} from './styled'; + +export const ParticipantsPane = () => { + const dispatch = useDispatch(); + const paneOpen = useSelector(getParticipantsPaneOpen); + const isLocalModerator = useSelector(isLocalParticipantModerator); + const { t } = useTranslation(); + + const closePane = useCallback(() => dispatch(close(), [ dispatch ])); + const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]); + + return ( + +
+
+
+ +
+ + + + + + {isLocalModerator && ( +
+ + {t('participantsPane.actions.muteAll')} + +
+ )} +
+
+
+ ); +}; diff --git a/react/features/participants-pane/components/RaisedHandIndicator.js b/react/features/participants-pane/components/RaisedHandIndicator.js new file mode 100644 index 000000000..bc410ff88 --- /dev/null +++ b/react/features/participants-pane/components/RaisedHandIndicator.js @@ -0,0 +1,15 @@ +// @flow + +import React from 'react'; + +import { Icon, IconRaisedHandHollow } from '../../base/icons'; + +import { RaisedHandIndicatorBackground } from './styled'; + +export const RaisedHandIndicator = () => ( + + + +); diff --git a/react/features/participants-pane/components/index.js b/react/features/participants-pane/components/index.js new file mode 100644 index 000000000..78902302e --- /dev/null +++ b/react/features/participants-pane/components/index.js @@ -0,0 +1,9 @@ +export * from './InviteButton'; +export * from './LobbyParticipantItem'; +export * from './LobbyParticipantList'; +export * from './MeetingParticipantContextMenu'; +export * from './MeetingParticipantItem'; +export * from './MeetingParticipantList'; +export * from './ParticipantItem'; +export * from './ParticipantsPane'; +export * from './RaisedHandIndicator'; diff --git a/react/features/participants-pane/components/styled.js b/react/features/participants-pane/components/styled.js new file mode 100644 index 000000000..94a614fd1 --- /dev/null +++ b/react/features/participants-pane/components/styled.js @@ -0,0 +1,335 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { Icon, IconHorizontalPoints } from '../../base/icons'; +import { ActionTrigger } from '../constants'; + +export const ignoredChildClassName = 'ignore-child'; + +export const AntiCollapse = styled.br` + font-size: 0; +`; + +export const Button = styled.button` + align-items: center; + background-color: ${ + // eslint-disable-next-line no-confusing-arrow + props => props.primary ? '#0056E0' : '#3D3D3D' +}; + border: 0; + border-radius: 6px; + display: flex; + font-weight: unset; + justify-content: center; + + &:hover { + background-color: ${ + // eslint-disable-next-line no-confusing-arrow + props => props.primary ? '#246FE5' : '#525252' +}; + } +`; + +export const Container = styled.div` + box-sizing: border-box; + flex: 1; + overflow-y: auto; + position: relative; + padding: 0 ${props => props.theme.panePadding}px; + + & > * + *:not(.${ignoredChildClassName}) { + margin-top: 16px; + } + + &::-webkit-scrollbar { + display: none; + } +`; + +export const ContextMenu = styled.div.attrs(props => { + return { + className: props.className + }; +})` + background-color: #292929; + border-radius: 3px; + box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25); + color: white; + font-size: ${props => props.theme.contextFontSize}px; + font-weight: ${props => props.theme.contextFontWeight}; + margin-top: ${props => { + const { + participantActionButtonHeight, + participantItemHeight + } = props.theme; + + return ((3 * participantItemHeight) + participantActionButtonHeight) / 4; + }}px; + position: absolute; + right: ${props => props.theme.panePadding}px; + top: 0; + z-index: 2; + + & > li { + list-style: none; + } + + ${props => props.isHidden && ` + pointer-events: none; + visibility: hidden; + `} +`; + +export const ContextMenuIcon = styled(Icon).attrs({ + size: 20 +})` + & > svg { + fill: #a4b8d1; + } +`; + +export const ContextMenuItem = styled.div` + align-items: center; + box-sizing: border-box; + cursor: pointer; + display: flex; + height: 40px; + padding: 8px 16px; + + & > *:not(:last-child) { + margin-right: 16px; + } + + &:hover { + background-color: #525252; + } +`; + +export const ContextMenuItemGroup = styled.div` + &:not(:empty) { + padding: 8px 0; + } + + & + &:not(:empty) { + border-top: 1px solid #4C4D50; + } +`; + +export const Close = styled.div` + align-items: center; + cursor: pointer; + display: flex; + height: 20px; + justify-content: center; + width: 20px; + + &:before, &:after { + content: ''; + background-color: #a4b8d1; + border-radius: 2px; + height: 2px; + position: absolute; + transform-origin: center center; + width: 21px; + } + + &:before { + transform: rotate(45deg); + } + + &:after { + transform: rotate(-45deg); + } +`; + +export const Footer = styled.div` + background-color: #141414; + display: flex; + justify-content: flex-end; + padding: 24px ${props => props.theme.panePadding}px; + + & > *:not(:last-child) { + margin-right: 16px; + } +`; + +export const FooterButton = styled(Button)` + height: 40px; + font-size: 15px; + padding: 0 16px; +`; + +export const FooterEllipsisButton = styled(FooterButton).attrs({ + children: +})` + padding: 8px; +`; + +export const FooterEllipsisContainer = styled.div` + position: relative; +`; + +export const Header = styled.div` + align-items: center; + box-sizing: border-box; + display: flex; + height: ${props => props.theme.headerSize}px; + padding: 0 20px; +`; + +export const Heading = styled.div` + color: #d1dbe8; + font-style: normal; + font-size: 15px; + line-height: 24px; + margin: 8px 0 ${props => props.theme.panePadding}px; +`; + +export const ParticipantActionButton = styled(Button)` + height: ${props => props.theme.participantActionButtonHeight}px; + padding: 6px 10px; +`; + +export const ParticipantActionEllipsis = styled(ParticipantActionButton).attrs({ + children: , + primary: true +})` + padding: 6px; +`; + +export const ParticipantActions = styled.div` + align-items: center; + z-index: 1; + + & > *:not(:last-child) { + margin-right: 8px; + } +`; + +export const ParticipantActionsHover = styled(ParticipantActions)` + background-color: #292929; + bottom: 1px; + display: none; + position: absolute; + right: ${props => props.theme.panePadding}; + top: 0; + + &:after { + content: ''; + background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #292929 100%); + bottom: 0; + display: block; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + transform: translateX(-100%); + width: 40px; + } +`; + +export const ParticipantActionsPermanent = styled(ParticipantActions)` + display: flex; +`; + +export const ParticipantContent = styled.div` + align-items: center; + box-shadow: inset 0px -1px 0px rgba(255, 255, 255, 0.15); + display: flex; + flex: 1; + height: 100%; + overflow: hidden; + padding-right: ${props => props.theme.panePadding}px; +`; + +export const ParticipantContainer = styled.div` + align-items: center; + color: white; + display: flex; + font-size: 13px; + height: ${props => props.theme.participantItemHeight}px; + margin: 0 -${props => props.theme.panePadding}px; + padding-left: ${props => props.theme.panePadding}px; + position: relative; + + ${props => !props.isHighlighted && '&:hover {'} + background-color: #292929; + + & ${ParticipantActions} { + ${props => props.trigger === ActionTrigger.Hover && ` + display: flex; + `} + } + + & ${ParticipantContent} { + box-shadow: none; + } + ${props => !props.isHighlighted && '}'} +`; + +export const ParticipantInviteButton = styled(Button).attrs({ + primary: true +})` + font-size: 15px; + height: 40px; + width: 100%; + + & > *:not(:last-child) { + margin-right: 8px; + } +`; + +export const ParticipantName = styled.div` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const ParticipantNameContainer = styled.div` + display: flex; + flex: 1; + margin-right: 8px; + overflow: hidden; +`; + +export const ParticipantStates = styled.div` + display: flex; + justify-content: flex-end; + + & > * { + align-items: center; + display: flex; + justify-content: center; + } + + & > *:not(:last-child) { + margin-right: 8px; + } +`; + +export const RaisedHandIndicatorBackground = styled.div` + background-color: #ed9e1b; + border-radius: 3px; + height: 24px; + width: 24px; +`; + +export const VolumeInput = styled.input.attrs({ + type: 'range' +})` + width: 100%; +`; + +export const VolumeInputContainer = styled.div` + position: relative; + width: 100%; +`; + +export const VolumeOverlay = styled.div` + background-color: #0376da; + border-radius: 1px 0 0 1px; + height: 100%; + left: 0; + pointer-events: none; + position: absolute; +`; diff --git a/react/features/participants-pane/constants.js b/react/features/participants-pane/constants.js new file mode 100644 index 000000000..7b11e665b --- /dev/null +++ b/react/features/participants-pane/constants.js @@ -0,0 +1,22 @@ +/** + * Reducer key for the feature. + */ +export const REDUCER_KEY = 'features/participants-pane'; + +/** + * Enum of possible participant action triggers. + */ +export const ActionTrigger = { + Hover: 'ActionTrigger.Hover', + Permanent: 'ActionTrigger.Permanent' +}; + +/** + * Enum of possible participant media states. + */ +export const MediaState = { + Muted: 'MediaState.Muted', + ForceMuted: 'MediaState.ForceMuted', + Unmuted: 'MediaState.Unmuted', + None: 'MediaState.None' +}; diff --git a/react/features/participants-pane/functions.js b/react/features/participants-pane/functions.js new file mode 100644 index 000000000..5dfed11bf --- /dev/null +++ b/react/features/participants-pane/functions.js @@ -0,0 +1,66 @@ +import { REDUCER_KEY } from './constants'; + +/** + * Generates a class attribute value. + * + * @param {Iterable} args - String iterable. + * @returns {string} Class attribute value. + */ +export const classList = (...args) => args.filter(Boolean).join(' '); + + +/** + * Find the first styled ancestor component of an element. + * + * @param {Element} target - Element to look up. + * @param {StyledComponentClass} component - Styled component reference. + * @returns {Element|null} Ancestor. + */ +export const findStyledAncestor = (target, component) => { + if (!target || target.matches(`.${component.styledComponentId}`)) { + return target; + } + + return findStyledAncestor(target.parentElement, component); +}; + +/** + * Get a style property from a style declaration as a float. + * + * @param {CSSStyleDeclaration} styles - Style declaration. + * @param {string} name - Property name. + * @returns {number} Float value. + */ +export const getFloatStyleProperty = (styles, name) => + parseFloat(styles.getPropertyValue(name)); + +/** + * Gets the outer height of an element, including margins. + * + * @param {Element} element - Target element. + * @returns {number} Computed height. + */ +export const getComputedOuterHeight = element => { + const computedStyle = getComputedStyle(element); + + return element.offsetHeight + + getFloatStyleProperty(computedStyle, 'margin-top') + + getFloatStyleProperty(computedStyle, 'margin-bottom'); +}; + +/** + * Returns this feature's root state. + * + * @param {Object} state - Global state. + * @returns {Object} Feature state. + */ +const getState = state => state[REDUCER_KEY]; + +/** + * Is the participants pane open. + * + * @param {Object} state - Global state. + * @returns {boolean} Is the participants pane open. + */ +export const getParticipantsPaneOpen = state => Boolean(getState(state).isOpen); + diff --git a/react/features/participants-pane/reducer.js b/react/features/participants-pane/reducer.js new file mode 100644 index 000000000..6d547818f --- /dev/null +++ b/react/features/participants-pane/reducer.js @@ -0,0 +1,35 @@ +import { ReducerRegistry } from '../base/redux'; + +import { + PARTICIPANTS_PANE_CLOSE, + PARTICIPANTS_PANE_OPEN +} from './actionTypes'; +import { REDUCER_KEY } from './constants'; + +const DEFAULT_STATE = { + isOpen: false +}; + +/** + * Listen for actions that mutate the participants pane state + */ +ReducerRegistry.register( + REDUCER_KEY, (state = DEFAULT_STATE, action) => { + switch (action.type) { + case PARTICIPANTS_PANE_CLOSE: + return { + ...state, + isOpen: false + }; + + case PARTICIPANTS_PANE_OPEN: + return { + ...state, + isOpen: true + }; + + default: + return state; + } + }, +); diff --git a/react/features/participants-pane/theme.json b/react/features/participants-pane/theme.json new file mode 100644 index 000000000..b9624e6d1 --- /dev/null +++ b/react/features/participants-pane/theme.json @@ -0,0 +1,10 @@ +{ + "contextFontSize": 14, + "contextFontWeight": 400, + "headerSize": 60, + "panePadding": 16, + "participantActionButtonHeight": 32, + "participantItemHeight": 48, + "participantsPaneWidth": 315, + "rangeInputThumbSize": 14 +} \ No newline at end of file diff --git a/react/features/settings/components/web/audio/AudioSettingsContent.js b/react/features/settings/components/web/audio/AudioSettingsContent.js index 71bddfa10..17ec53321 100644 --- a/react/features/settings/components/web/audio/AudioSettingsContent.js +++ b/react/features/settings/components/web/audio/AudioSettingsContent.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import { translate } from '../../../../base/i18n'; -import { IconMicrophoneEmpty, IconVolumeEmpty } from '../../../../base/icons'; +import { IconMicrophoneHollow, IconVolumeEmpty } from '../../../../base/icons'; import JitsiMeetJS from '../../../../base/lib-jitsi-meet'; import { equals } from '../../../../base/redux'; import { createLocalAudioTracks } from '../../../functions'; @@ -248,7 +248,7 @@ class AudioSettingsContent extends Component {
{this.state.audioTracks.map((data, i) => this._renderMicrophoneEntry(data, i), diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index cf8040fed..1b64a221f 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -19,7 +19,7 @@ import { IconExitFullScreen, IconFeedback, IconFullScreen, - IconInviteMore, + IconParticipants, IconPresentation, IconRaisedHand, IconRec, @@ -37,13 +37,16 @@ import { OverflowMenuItem } from '../../../base/toolbox/components'; import { getLocalVideoTrack, toggleScreensharing } from '../../../base/tracks'; import { isVpaasMeeting } from '../../../billing-counter/functions'; import { ChatCounter, toggleChat } from '../../../chat'; -import { InviteMore } from '../../../conference'; import { EmbedMeetingDialog } from '../../../embed-meeting'; import { SharedDocumentButton } from '../../../etherpad'; import { openFeedbackDialog } from '../../../feedback'; -import { beginAddPeople } from '../../../invite'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; import { LocalRecordingInfoDialog } from '../../../local-recording'; +import { + close as closeParticipantsPane, + open as openParticipantsPane +} from '../../../participants-pane/actions'; +import { getParticipantsPaneOpen } from '../../../participants-pane/functions'; import { LiveStreamButton, RecordButton @@ -179,6 +182,11 @@ type Props = { */ _overflowMenuVisible: boolean, + /** + * Whether or not the participants pane is open. + */ + _participantsPaneOpen: boolean, + /** * Whether or not the local participant's hand is raised. */ @@ -240,11 +248,12 @@ class Toolbox extends Component { this._onShortcutToggleChat = this._onShortcutToggleChat.bind(this); this._onShortcutToggleFullScreen = this._onShortcutToggleFullScreen.bind(this); + this._onShortcutToggleParticipantsPane = this._onShortcutToggleParticipantsPane.bind(this); this._onShortcutToggleRaiseHand = this._onShortcutToggleRaiseHand.bind(this); this._onShortcutToggleScreenshare = this._onShortcutToggleScreenshare.bind(this); this._onShortcutToggleVideoQuality = this._onShortcutToggleVideoQuality.bind(this); this._onToolbarOpenFeedback = this._onToolbarOpenFeedback.bind(this); - this._onToolbarOpenInvite = this._onToolbarOpenInvite.bind(this); + this._onToolbarToggleParticipantsPane = this._onToolbarToggleParticipantsPane.bind(this); this._onToolbarOpenKeyboardShortcuts = this._onToolbarOpenKeyboardShortcuts.bind(this); this._onToolbarOpenSpeakerStats = this._onToolbarOpenSpeakerStats.bind(this); this._onToolbarOpenEmbedMeeting = this._onToolbarOpenEmbedMeeting.bind(this); @@ -282,6 +291,11 @@ class Toolbox extends Component { exec: this._onShortcutToggleScreenshare, helpDescription: 'keyboardShortcuts.toggleScreensharing' }, + this._shouldShowButton('participants-pane') && { + character: 'P', + exec: this._onShortcutToggleParticipantsPane, + helpDescription: 'keyboardShortcuts.toggleParticipantsPane' + }, this._shouldShowButton('raisehand') && { character: 'R', exec: this._onShortcutToggleRaiseHand, @@ -577,6 +591,25 @@ class Toolbox extends Component { this._doToggleChat(); } + _onShortcutToggleParticipantsPane: () => void; + + /** + * Creates an analytics keyboard shortcut event and dispatches an action for + * toggling the display of the participants pane. + * + * @private + * @returns {void} + */ + _onShortcutToggleParticipantsPane() { + sendAnalytics(createShortcutEvent( + 'toggle.participants-pane', + { + enable: !this.props._participantsPaneOpen + })); + + this._onToolbarToggleParticipantsPane(); + } + _onShortcutToggleVideoQuality: () => void; /** @@ -694,18 +727,22 @@ class Toolbox extends Component { this._doOpenFeedback(); } - _onToolbarOpenInvite: () => void; + _onToolbarToggleParticipantsPane: () => void; /** - * Creates an analytics toolbar event and dispatches an action for opening - * the modal for inviting people directly into the conference. + * Dispatches an action for toggling the participants pane. * * @private * @returns {void} */ - _onToolbarOpenInvite() { - sendAnalytics(createToolbarEvent('invite')); - this.props.dispatch(beginAddPeople()); + _onToolbarToggleParticipantsPane() { + const { dispatch, _participantsPaneOpen } = this.props; + + if (_participantsPaneOpen) { + dispatch(closeParticipantsPane()); + } else { + dispatch(openParticipantsPane()); + } } _onToolbarOpenKeyboardShortcuts: () => void; @@ -1163,6 +1200,25 @@ class Toolbox extends Component { text = { t(`toolbar.${_raisedHand ? 'lowerYourHand' : 'raiseYourHand'}`) } />); } + if (this._shouldShowButton('participants-pane') || this._shouldShowButton('invite')) { + buttons.has('participants-pane') + ? mainMenuAdditionalButtons.push( + ) + : overflowMenuAdditionalButtons.push( + + ); + } + if (this._shouldShowButton('tileview')) { buttons.has('tileview') ? mainMenuAdditionalButtons.push( @@ -1175,25 +1231,6 @@ class Toolbox extends Component { showLabel = { true } />); } - if (this._shouldShowButton('invite')) { - buttons.has('invite') - ? mainMenuAdditionalButtons.push( - ) - : overflowMenuAdditionalButtons.push( - - ); - } - return { mainMenuAdditionalButtons, overflowMenuAdditionalButtons @@ -1254,7 +1291,6 @@ class Toolbox extends Component { return (
-
{ this._renderAudioButton() } { this._renderVideoButton() } @@ -1344,6 +1380,7 @@ function _mapStateToProps(state) { _localRecState: localRecordingStates, _locked: locked, _overflowMenuVisible: overflowMenuVisible, + _participantsPaneOpen: getParticipantsPaneOpen(state), _raisedHand: localParticipant.raisedHand, _screensharing: (localVideo && localVideo.videoType === 'desktop') || isScreenAudioShared(state), _visible: isToolboxVisible(state), diff --git a/react/features/toolbox/functions.web.js b/react/features/toolbox/functions.web.js index ca1f59222..1bf4a42a9 100644 --- a/react/features/toolbox/functions.web.js +++ b/react/features/toolbox/functions.web.js @@ -25,23 +25,23 @@ export function getToolbarAdditionalButtons(width: number, isMobile: boolean): S switch (true) { case width >= WIDTH.FIT_9_ICONS: { buttons = isMobile - ? [ 'chat', 'raisehand', 'tileview', 'invite', 'overflow' ] - : [ 'desktop', 'chat', 'raisehand', 'tileview', 'invite', 'overflow' ]; + ? [ 'chat', 'raisehand', 'tileview', 'participants-pane', 'overflow' ] + : [ 'desktop', 'chat', 'raisehand', 'tileview', 'participants-pane', 'overflow' ]; break; } case width >= WIDTH.FIT_8_ICONS: { - buttons = [ 'desktop', 'chat', 'raisehand', 'invite', 'overflow' ]; + buttons = [ 'desktop', 'chat', 'raisehand', 'participants-pane', 'overflow' ]; break; } case width >= WIDTH.FIT_7_ICONS: { - buttons = [ 'desktop', 'chat', 'invite', 'overflow' ]; + buttons = [ 'desktop', 'chat', 'participants-pane', 'overflow' ]; break; } case width >= WIDTH.FIT_6_ICONS: { - buttons = [ 'chat', 'invite', 'overflow' ]; + buttons = [ 'chat', 'participants-pane', 'overflow' ]; break; } diff --git a/react/features/video-menu/components/AbstractMuteEveryoneDialog.js b/react/features/video-menu/components/AbstractMuteEveryoneDialog.js index 617bb5782..919dd7bdd 100644 --- a/react/features/video-menu/components/AbstractMuteEveryoneDialog.js +++ b/react/features/video-menu/components/AbstractMuteEveryoneDialog.js @@ -84,7 +84,7 @@ export default class AbstractMuteEveryoneDialog extends AbstractMuteRe * @returns {Props} */ export function abstractMapStateToProps(state: Object, ownProps: Props) { - const { exclude, t } = ownProps; + const { exclude = [], t } = ownProps; const whom = exclude // eslint-disable-next-line no-confusing-arrow