You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

MeetingParticipantContextMenu.js 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. // @flow
  2. import { withStyles } from '@material-ui/core/styles';
  3. import React, { Component } from 'react';
  4. import { approveParticipant } from '../../../av-moderation/actions';
  5. import { Avatar } from '../../../base/avatar';
  6. import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
  7. import { openDialog } from '../../../base/dialog';
  8. import { isIosMobileBrowser } from '../../../base/environment/utils';
  9. import { translate } from '../../../base/i18n';
  10. import {
  11. IconCloseCircle,
  12. IconCrown,
  13. IconMessage,
  14. IconMicDisabled,
  15. IconMicrophone,
  16. IconMuteEveryoneElse,
  17. IconShareVideo,
  18. IconVideoOff
  19. } from '../../../base/icons';
  20. import { MEDIA_TYPE } from '../../../base/media';
  21. import {
  22. getLocalParticipant,
  23. getParticipantByIdOrUndefined,
  24. isLocalParticipantModerator,
  25. isParticipantModerator
  26. } from '../../../base/participants';
  27. import { connect } from '../../../base/redux';
  28. import { withPixelLineHeight } from '../../../base/styles/functions.web';
  29. import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
  30. import { openChatById } from '../../../chat/actions';
  31. import { setVolume } from '../../../filmstrip/actions.web';
  32. import { Drawer, JitsiPortal } from '../../../toolbox/components/web';
  33. import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
  34. import { VolumeSlider } from '../../../video-menu/components/web';
  35. import MuteRemoteParticipantsVideoDialog from '../../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
  36. import { getComputedOuterHeight, isForceMuted } from '../../functions';
  37. import {
  38. ContextMenu,
  39. ContextMenuIcon,
  40. ContextMenuItem,
  41. ContextMenuItemGroup,
  42. ignoredChildClassName
  43. } from './styled';
  44. type Props = {
  45. /**
  46. * Whether or not the participant is audio force muted.
  47. */
  48. _isAudioForceMuted: boolean,
  49. /**
  50. * True if the local participant is moderator and false otherwise.
  51. */
  52. _isLocalModerator: boolean,
  53. /**
  54. * True if the chat button is enabled and false otherwise.
  55. */
  56. _isChatButtonEnabled: boolean,
  57. /**
  58. * True if the participant is moderator and false otherwise.
  59. */
  60. _isParticipantModerator: boolean,
  61. /**
  62. * True if the participant is video muted and false otherwise.
  63. */
  64. _isParticipantVideoMuted: boolean,
  65. /**
  66. * True if the participant is audio muted and false otherwise.
  67. */
  68. _isParticipantAudioMuted: boolean,
  69. /**
  70. * Whether or not the participant is video force muted.
  71. */
  72. _isVideoForceMuted: boolean,
  73. /**
  74. * Shared video local participant owner.
  75. */
  76. _localVideoOwner: boolean,
  77. /**
  78. * Participant reference
  79. */
  80. _participant: Object,
  81. /**
  82. * A value between 0 and 1 indicating the volume of the participant's
  83. * audio element.
  84. */
  85. _volume: ?number,
  86. /**
  87. * Closes a drawer if open.
  88. */
  89. closeDrawer: Function,
  90. /**
  91. * An object containing the CSS classes.
  92. */
  93. classes?: {[ key: string]: string},
  94. /**
  95. * The dispatch function from redux.
  96. */
  97. dispatch: Function,
  98. /**
  99. * The participant for which the drawer is open.
  100. * It contains the displayName & participantID.
  101. */
  102. drawerParticipant: Object,
  103. /**
  104. * Callback used to open a confirmation dialog for audio muting.
  105. */
  106. muteAudio: Function,
  107. /**
  108. * Target elements against which positioning calculations are made
  109. */
  110. offsetTarget: HTMLElement,
  111. /**
  112. * Callback for the mouse entering the component
  113. */
  114. onEnter: Function,
  115. /**
  116. * Callback for the mouse leaving the component
  117. */
  118. onLeave: Function,
  119. /**
  120. * Callback for making a selection in the menu
  121. */
  122. onSelect: Function,
  123. /**
  124. * The ID of the participant.
  125. */
  126. participantID: string,
  127. /**
  128. * True if an overflow drawer should be displayed.
  129. */
  130. overflowDrawer: boolean,
  131. /**
  132. * The translate function.
  133. */
  134. t: Function
  135. };
  136. type State = {
  137. /**
  138. * If true the context menu will be hidden.
  139. */
  140. isHidden: boolean
  141. };
  142. const styles = theme => {
  143. return {
  144. drawer: {
  145. '& > div': {
  146. ...withPixelLineHeight(theme.typography.bodyShortRegularLarge),
  147. lineHeight: '32px',
  148. '& svg': {
  149. fill: theme.palette.icon01
  150. }
  151. },
  152. '&:first-child': {
  153. marginTop: 15
  154. }
  155. }
  156. };
  157. };
  158. /**
  159. * Implements the MeetingParticipantContextMenu component.
  160. */
  161. class MeetingParticipantContextMenu extends Component<Props, State> {
  162. /**
  163. * Reference to the context menu container div.
  164. */
  165. _containerRef: Object;
  166. /**
  167. * Creates new instance of MeetingParticipantContextMenu.
  168. *
  169. * @param {Props} props - The props.
  170. */
  171. constructor(props: Props) {
  172. super(props);
  173. this.state = {
  174. isHidden: true
  175. };
  176. this._containerRef = React.createRef();
  177. this._getCurrentParticipantId = this._getCurrentParticipantId.bind(this);
  178. this._onGrantModerator = this._onGrantModerator.bind(this);
  179. this._onKick = this._onKick.bind(this);
  180. this._onMuteEveryoneElse = this._onMuteEveryoneElse.bind(this);
  181. this._onMuteVideo = this._onMuteVideo.bind(this);
  182. this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
  183. this._position = this._position.bind(this);
  184. this._onVolumeChange = this._onVolumeChange.bind(this);
  185. this._onAskToUnmute = this._onAskToUnmute.bind(this);
  186. }
  187. _getCurrentParticipantId: () => string;
  188. /**
  189. * Returns the participant id for the item we want to operate.
  190. *
  191. * @returns {void}
  192. */
  193. _getCurrentParticipantId() {
  194. const { _participant, drawerParticipant, overflowDrawer } = this.props;
  195. return overflowDrawer ? drawerParticipant?.participantID : _participant?.id;
  196. }
  197. _onGrantModerator: () => void;
  198. /**
  199. * Grant moderator permissions.
  200. *
  201. * @returns {void}
  202. */
  203. _onGrantModerator() {
  204. this.props.dispatch(openDialog(GrantModeratorDialog, {
  205. participantID: this._getCurrentParticipantId()
  206. }));
  207. }
  208. _onKick: () => void;
  209. /**
  210. * Kicks the participant.
  211. *
  212. * @returns {void}
  213. */
  214. _onKick() {
  215. this.props.dispatch(openDialog(KickRemoteParticipantDialog, {
  216. participantID: this._getCurrentParticipantId()
  217. }));
  218. }
  219. _onStopSharedVideo: () => void;
  220. /**
  221. * Stops shared video.
  222. *
  223. * @returns {void}
  224. */
  225. _onStopSharedVideo() {
  226. const { dispatch } = this.props;
  227. dispatch(this._onStopSharedVideo());
  228. }
  229. _onMuteEveryoneElse: () => void;
  230. /**
  231. * Mutes everyone else.
  232. *
  233. * @returns {void}
  234. */
  235. _onMuteEveryoneElse() {
  236. this.props.dispatch(openDialog(MuteEveryoneDialog, {
  237. exclude: [ this._getCurrentParticipantId() ]
  238. }));
  239. }
  240. _onMuteVideo: () => void;
  241. /**
  242. * Mutes the video of the selected participant.
  243. *
  244. * @returns {void}
  245. */
  246. _onMuteVideo() {
  247. this.props.dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
  248. participantID: this._getCurrentParticipantId()
  249. }));
  250. }
  251. _onSendPrivateMessage: () => void;
  252. /**
  253. * Sends private message.
  254. *
  255. * @returns {void}
  256. */
  257. _onSendPrivateMessage() {
  258. const { closeDrawer, dispatch, overflowDrawer } = this.props;
  259. dispatch(openChatById(this._getCurrentParticipantId()));
  260. overflowDrawer && closeDrawer();
  261. }
  262. _position: () => void;
  263. /**
  264. * Positions the context menu.
  265. *
  266. * @returns {void}
  267. */
  268. _position() {
  269. const { _participant, offsetTarget } = this.props;
  270. if (_participant
  271. && this._containerRef.current
  272. && offsetTarget?.offsetParent
  273. && offsetTarget.offsetParent instanceof HTMLElement
  274. ) {
  275. const { current: container } = this._containerRef;
  276. const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget;
  277. const outerHeight = getComputedOuterHeight(container);
  278. container.style.top = offsetTop + outerHeight > offsetHeight + scrollTop
  279. ? offsetTop - outerHeight
  280. : offsetTop;
  281. this.setState({ isHidden: false });
  282. } else {
  283. this.setState({ isHidden: true });
  284. }
  285. }
  286. _onVolumeChange: (number) => void;
  287. /**
  288. * Handles volume changes.
  289. *
  290. * @param {number} value - The new value for the volume.
  291. * @returns {void}
  292. */
  293. _onVolumeChange(value) {
  294. const { _participant, dispatch } = this.props;
  295. const { id } = _participant;
  296. dispatch(setVolume(id, value));
  297. }
  298. _onAskToUnmute: () => void;
  299. /**
  300. * Handles click on ask to unmute.
  301. *
  302. * @returns {void}
  303. */
  304. _onAskToUnmute() {
  305. const { _participant, dispatch } = this.props;
  306. const { id } = _participant;
  307. dispatch(approveParticipant(id));
  308. }
  309. /**
  310. * Implements React Component's componentDidMount.
  311. *
  312. * @inheritdoc
  313. * @returns {void}
  314. */
  315. componentDidMount() {
  316. this._position();
  317. }
  318. /**
  319. * Implements React Component's componentDidUpdate.
  320. *
  321. * @inheritdoc
  322. */
  323. componentDidUpdate(prevProps: Props) {
  324. if (prevProps.offsetTarget !== this.props.offsetTarget || prevProps._participant !== this.props._participant) {
  325. this._position();
  326. }
  327. }
  328. /**
  329. * Implements React's {@link Component#render()}.
  330. *
  331. * @inheritdoc
  332. * @returns {ReactElement}
  333. */
  334. render() {
  335. const {
  336. _isAudioForceMuted,
  337. _isLocalModerator,
  338. _isChatButtonEnabled,
  339. _isParticipantModerator,
  340. _isParticipantVideoMuted,
  341. _isParticipantAudioMuted,
  342. _isVideoForceMuted,
  343. _localVideoOwner,
  344. _participant,
  345. _volume = 1,
  346. classes,
  347. closeDrawer,
  348. drawerParticipant,
  349. onEnter,
  350. onLeave,
  351. onSelect,
  352. overflowDrawer,
  353. muteAudio,
  354. t
  355. } = this.props;
  356. if (!_participant) {
  357. return null;
  358. }
  359. const showVolumeSlider = !isIosMobileBrowser()
  360. && overflowDrawer
  361. && typeof _volume === 'number'
  362. && !isNaN(_volume);
  363. const actions
  364. = _participant?.isFakeParticipant ? (
  365. <>
  366. {_localVideoOwner && (
  367. <ContextMenuItem onClick = { this._onStopSharedVideo }>
  368. <ContextMenuIcon src = { IconShareVideo } />
  369. <span>{t('toolbar.stopSharedVideo')}</span>
  370. </ContextMenuItem>
  371. )}
  372. </>
  373. ) : (
  374. <>
  375. {_isLocalModerator && (
  376. <ContextMenuItemGroup>
  377. <>
  378. {overflowDrawer && (_isAudioForceMuted || _isVideoForceMuted)
  379. && <ContextMenuItem onClick = { this._onAskToUnmute }>
  380. <ContextMenuIcon src = { IconMicrophone } />
  381. <span>
  382. {t(_isAudioForceMuted
  383. ? 'participantsPane.actions.askUnmute'
  384. : 'participantsPane.actions.allowVideo')}
  385. </span>
  386. </ContextMenuItem>
  387. }
  388. {
  389. !_isParticipantAudioMuted && overflowDrawer
  390. && <ContextMenuItem onClick = { muteAudio(_participant) }>
  391. <ContextMenuIcon src = { IconMicDisabled } />
  392. <span>{t('dialog.muteParticipantButton')}</span>
  393. </ContextMenuItem>
  394. }
  395. <ContextMenuItem onClick = { this._onMuteEveryoneElse }>
  396. <ContextMenuIcon src = { IconMuteEveryoneElse } />
  397. <span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
  398. </ContextMenuItem>
  399. </>
  400. {
  401. _isParticipantVideoMuted || (
  402. <ContextMenuItem onClick = { this._onMuteVideo }>
  403. <ContextMenuIcon src = { IconVideoOff } />
  404. <span>{t('participantsPane.actions.stopVideo')}</span>
  405. </ContextMenuItem>
  406. )
  407. }
  408. </ContextMenuItemGroup>
  409. )}
  410. <ContextMenuItemGroup>
  411. {
  412. _isLocalModerator && (
  413. <>
  414. {
  415. !_isParticipantModerator && (
  416. <ContextMenuItem onClick = { this._onGrantModerator }>
  417. <ContextMenuIcon src = { IconCrown } />
  418. <span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
  419. </ContextMenuItem>
  420. )
  421. }
  422. <ContextMenuItem onClick = { this._onKick }>
  423. <ContextMenuIcon src = { IconCloseCircle } />
  424. <span>{ t('videothumbnail.kick') }</span>
  425. </ContextMenuItem>
  426. </>
  427. )
  428. }
  429. {
  430. _isChatButtonEnabled && (
  431. <ContextMenuItem onClick = { this._onSendPrivateMessage }>
  432. <ContextMenuIcon src = { IconMessage } />
  433. <span>{t('toolbar.accessibilityLabel.privateMessage')}</span>
  434. </ContextMenuItem>
  435. )
  436. }
  437. </ContextMenuItemGroup>
  438. { showVolumeSlider
  439. && <ContextMenuItemGroup>
  440. <VolumeSlider
  441. initialValue = { _volume }
  442. key = 'volume-slider'
  443. onChange = { this._onVolumeChange } />
  444. </ContextMenuItemGroup>
  445. }
  446. </>
  447. );
  448. return (
  449. <>
  450. { !overflowDrawer
  451. && <ContextMenu
  452. className = { ignoredChildClassName }
  453. innerRef = { this._containerRef }
  454. isHidden = { this.state.isHidden }
  455. onClick = { onSelect }
  456. onMouseEnter = { onEnter }
  457. onMouseLeave = { onLeave }>
  458. { actions }
  459. </ContextMenu>}
  460. <JitsiPortal>
  461. <Drawer
  462. isOpen = { drawerParticipant && overflowDrawer }
  463. onClose = { closeDrawer }>
  464. <div className = { classes && classes.drawer }>
  465. <ContextMenuItemGroup>
  466. <ContextMenuItem>
  467. <Avatar
  468. participantId = { drawerParticipant && drawerParticipant.participantID }
  469. size = { 20 } />
  470. <span>{ drawerParticipant && drawerParticipant.displayName }</span>
  471. </ContextMenuItem>
  472. </ContextMenuItemGroup>
  473. { actions }
  474. </div>
  475. </Drawer>
  476. </JitsiPortal>
  477. </>
  478. );
  479. }
  480. }
  481. /**
  482. * Maps (parts of) the redux state to the associated props for this component.
  483. *
  484. * @param {Object} state - The Redux state.
  485. * @param {Object} ownProps - The own props of the component.
  486. * @private
  487. * @returns {Props}
  488. */
  489. function _mapStateToProps(state, ownProps): Object {
  490. const { participantID, overflowDrawer, drawerParticipant } = ownProps;
  491. const { ownerId } = state['features/shared-video'];
  492. const localParticipantId = getLocalParticipant(state).id;
  493. const participant = getParticipantByIdOrUndefined(state,
  494. overflowDrawer ? drawerParticipant?.participantID : participantID);
  495. const _isLocalModerator = isLocalParticipantModerator(state);
  496. const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
  497. const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state);
  498. const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
  499. const _isParticipantModerator = isParticipantModerator(participant);
  500. const { participantsVolume } = state['features/filmstrip'];
  501. const id = participant?.id;
  502. const isLocal = participant?.local ?? true;
  503. return {
  504. _isAudioForceMuted: isForceMuted(participant, MEDIA_TYPE.AUDIO, state),
  505. _isLocalModerator,
  506. _isChatButtonEnabled,
  507. _isParticipantModerator,
  508. _isParticipantVideoMuted,
  509. _isParticipantAudioMuted,
  510. _isVideoForceMuted: isForceMuted(participant, MEDIA_TYPE.VIDEO, state),
  511. _localVideoOwner: Boolean(ownerId === localParticipantId),
  512. _participant: participant,
  513. _volume: isLocal ? undefined : id ? participantsVolume[id] : undefined
  514. };
  515. }
  516. export default withStyles(styles)(translate(connect(_mapStateToProps)(MeetingParticipantContextMenu)));