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.

Prejoin.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. /* eslint-disable react/jsx-no-bind */
  2. import React, { useRef, useState } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { connect } from 'react-redux';
  5. import { makeStyles } from 'tss-react/mui';
  6. import { IReduxState } from '../../../app/types';
  7. import Avatar from '../../../base/avatar/components/Avatar';
  8. import { isNameReadOnly } from '../../../base/config/functions.web';
  9. import { IconArrowDown, IconArrowUp, IconPhoneRinging, IconVolumeOff } from '../../../base/icons/svg';
  10. import { isVideoMutedByUser } from '../../../base/media/functions';
  11. import { getLocalParticipant } from '../../../base/participants/functions';
  12. import Popover from '../../../base/popover/components/Popover.web';
  13. import ActionButton from '../../../base/premeeting/components/web/ActionButton';
  14. import PreMeetingScreen from '../../../base/premeeting/components/web/PreMeetingScreen';
  15. import { updateSettings } from '../../../base/settings/actions';
  16. import { getDisplayName } from '../../../base/settings/functions.web';
  17. import { withPixelLineHeight } from '../../../base/styles/functions.web';
  18. import { getLocalJitsiVideoTrack } from '../../../base/tracks/functions.web';
  19. import Button from '../../../base/ui/components/web/Button';
  20. import Input from '../../../base/ui/components/web/Input';
  21. import { BUTTON_TYPES } from '../../../base/ui/constants.any';
  22. import isInsecureRoomName from '../../../base/util/isInsecureRoomName';
  23. import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
  24. import {
  25. joinConference as joinConferenceAction,
  26. joinConferenceWithoutAudio as joinConferenceWithoutAudioAction,
  27. setJoinByPhoneDialogVisiblity as setJoinByPhoneDialogVisiblityAction
  28. } from '../../actions.web';
  29. import {
  30. isDeviceStatusVisible,
  31. isDisplayNameRequired,
  32. isJoinByPhoneButtonVisible,
  33. isJoinByPhoneDialogVisible,
  34. isPrejoinDisplayNameVisible
  35. } from '../../functions';
  36. import JoinByPhoneDialog from './dialogs/JoinByPhoneDialog';
  37. interface IProps {
  38. /**
  39. * Indicates whether the display name is editable.
  40. */
  41. canEditDisplayName: boolean;
  42. /**
  43. * Flag signaling if the device status is visible or not.
  44. */
  45. deviceStatusVisible: boolean;
  46. /**
  47. * If join by phone button should be visible.
  48. */
  49. hasJoinByPhoneButton: boolean;
  50. /**
  51. * Joins the current meeting.
  52. */
  53. joinConference: Function;
  54. /**
  55. * Joins the current meeting without audio.
  56. */
  57. joinConferenceWithoutAudio: Function;
  58. /**
  59. * Whether conference join is in progress.
  60. */
  61. joiningInProgress?: boolean;
  62. /**
  63. * The name of the user that is about to join.
  64. */
  65. name: string;
  66. /**
  67. * Local participant id.
  68. */
  69. participantId?: string;
  70. /**
  71. * The prejoin config.
  72. */
  73. prejoinConfig?: any;
  74. /**
  75. * Whether the name input should be read only or not.
  76. */
  77. readOnlyName: boolean;
  78. /**
  79. * Sets visibility of the 'JoinByPhoneDialog'.
  80. */
  81. setJoinByPhoneDialogVisiblity: Function;
  82. /**
  83. * Flag signaling the visibility of camera preview.
  84. */
  85. showCameraPreview: boolean;
  86. /**
  87. * If 'JoinByPhoneDialog' is visible or not.
  88. */
  89. showDialog: boolean;
  90. /**
  91. * If should show an error when joining without a name.
  92. */
  93. showErrorOnJoin: boolean;
  94. /**
  95. * If should show unsafe room warning when joining.
  96. */
  97. showUnsafeRoomWarning: boolean;
  98. /**
  99. * Whether the user has approved to join a room with unsafe name.
  100. */
  101. unsafeRoomConsent?: boolean;
  102. /**
  103. * Updates settings.
  104. */
  105. updateSettings: Function;
  106. /**
  107. * The JitsiLocalTrack to display.
  108. */
  109. videoTrack?: Object;
  110. }
  111. const useStyles = makeStyles()(theme => {
  112. return {
  113. inputContainer: {
  114. width: '100%'
  115. },
  116. input: {
  117. width: '100%',
  118. marginBottom: theme.spacing(3),
  119. '& input': {
  120. textAlign: 'center'
  121. }
  122. },
  123. avatarContainer: {
  124. display: 'flex',
  125. alignItems: 'center',
  126. flexDirection: 'column'
  127. },
  128. avatar: {
  129. margin: `${theme.spacing(2)} auto ${theme.spacing(3)}`
  130. },
  131. avatarName: {
  132. ...withPixelLineHeight(theme.typography.bodyShortBoldLarge),
  133. color: theme.palette.text01,
  134. marginBottom: theme.spacing(5),
  135. textAlign: 'center'
  136. },
  137. error: {
  138. backgroundColor: theme.palette.actionDanger,
  139. color: theme.palette.text01,
  140. borderRadius: theme.shape.borderRadius,
  141. width: '100%',
  142. ...withPixelLineHeight(theme.typography.labelRegular),
  143. boxSizing: 'border-box',
  144. padding: theme.spacing(1),
  145. textAlign: 'center',
  146. marginTop: `-${theme.spacing(2)}`,
  147. marginBottom: theme.spacing(3)
  148. },
  149. dropdownContainer: {
  150. position: 'relative',
  151. width: '100%'
  152. },
  153. dropdownButtons: {
  154. width: '300px',
  155. padding: '8px 0',
  156. backgroundColor: theme.palette.action02,
  157. color: theme.palette.text04,
  158. borderRadius: theme.shape.borderRadius,
  159. position: 'relative',
  160. top: `-${theme.spacing(3)}`
  161. }
  162. };
  163. });
  164. const Prejoin = ({
  165. canEditDisplayName,
  166. deviceStatusVisible,
  167. hasJoinByPhoneButton,
  168. joinConference,
  169. joinConferenceWithoutAudio,
  170. joiningInProgress,
  171. name,
  172. participantId,
  173. prejoinConfig,
  174. readOnlyName,
  175. setJoinByPhoneDialogVisiblity,
  176. showCameraPreview,
  177. showDialog,
  178. showErrorOnJoin,
  179. showUnsafeRoomWarning,
  180. unsafeRoomConsent,
  181. updateSettings: dispatchUpdateSettings,
  182. videoTrack
  183. }: IProps) => {
  184. const showDisplayNameField = useRef(canEditDisplayName || showErrorOnJoin);
  185. const [ showJoinByPhoneButtons, setShowJoinByPhoneButtons ] = useState(false);
  186. const { classes } = useStyles();
  187. const { t } = useTranslation();
  188. /**
  189. * Handler for the join button.
  190. *
  191. * @param {Object} e - The synthetic event.
  192. * @returns {void}
  193. */
  194. const onJoinButtonClick = () => {
  195. if (showErrorOnJoin) {
  196. return;
  197. }
  198. joinConference();
  199. };
  200. /**
  201. * Closes the dropdown.
  202. *
  203. * @returns {void}
  204. */
  205. const onDropdownClose = () => {
  206. setShowJoinByPhoneButtons(false);
  207. };
  208. /**
  209. * Displays the join by phone buttons dropdown.
  210. *
  211. * @param {Object} e - The synthetic event.
  212. * @returns {void}
  213. */
  214. const onOptionsClick = (e?: React.KeyboardEvent | React.MouseEvent | undefined) => {
  215. e?.stopPropagation();
  216. setShowJoinByPhoneButtons(show => !show);
  217. };
  218. /**
  219. * Sets the guest participant name.
  220. *
  221. * @param {string} displayName - Participant name.
  222. * @returns {void}
  223. */
  224. const setName = (displayName: string) => {
  225. dispatchUpdateSettings({
  226. displayName
  227. });
  228. };
  229. /**
  230. * Closes the join by phone dialog.
  231. *
  232. * @returns {undefined}
  233. */
  234. const closeDialog = () => {
  235. setJoinByPhoneDialogVisiblity(false);
  236. };
  237. /**
  238. * Displays the dialog for joining a meeting by phone.
  239. *
  240. * @returns {undefined}
  241. */
  242. const doShowDialog = () => {
  243. setJoinByPhoneDialogVisiblity(true);
  244. onDropdownClose();
  245. };
  246. /**
  247. * KeyPress handler for accessibility.
  248. *
  249. * @param {Object} e - The key event to handle.
  250. *
  251. * @returns {void}
  252. */
  253. const showDialogKeyPress = (e: React.KeyboardEvent) => {
  254. if (e.key === ' ' || e.key === 'Enter') {
  255. e.preventDefault();
  256. doShowDialog();
  257. }
  258. };
  259. /**
  260. * KeyPress handler for accessibility.
  261. *
  262. * @param {Object} e - The key event to handle.
  263. *
  264. * @returns {void}
  265. */
  266. const onJoinConferenceWithoutAudioKeyPress = (e: React.KeyboardEvent) => {
  267. if (joinConferenceWithoutAudio
  268. && (e.key === ' '
  269. || e.key === 'Enter')) {
  270. e.preventDefault();
  271. joinConferenceWithoutAudio();
  272. }
  273. };
  274. /**
  275. * Gets the list of extra join buttons.
  276. *
  277. * @returns {Object} - The list of extra buttons.
  278. */
  279. const getExtraJoinButtons = () => {
  280. const noAudio = {
  281. key: 'no-audio',
  282. testId: 'prejoin.joinWithoutAudio',
  283. icon: IconVolumeOff,
  284. label: t('prejoin.joinWithoutAudio'),
  285. onClick: joinConferenceWithoutAudio,
  286. onKeyPress: onJoinConferenceWithoutAudioKeyPress
  287. };
  288. const byPhone = {
  289. key: 'by-phone',
  290. testId: 'prejoin.joinByPhone',
  291. icon: IconPhoneRinging,
  292. label: t('prejoin.joinAudioByPhone'),
  293. onClick: doShowDialog,
  294. onKeyPress: showDialogKeyPress
  295. };
  296. return {
  297. noAudio,
  298. byPhone
  299. };
  300. };
  301. /**
  302. * Handle keypress on input.
  303. *
  304. * @param {KeyboardEvent} e - Keyboard event.
  305. * @returns {void}
  306. */
  307. const onInputKeyPress = (e: React.KeyboardEvent) => {
  308. if (e.key === 'Enter') {
  309. joinConference();
  310. }
  311. };
  312. const extraJoinButtons = getExtraJoinButtons();
  313. let extraButtonsToRender = Object.values(extraJoinButtons).filter((val: any) =>
  314. !(prejoinConfig?.hideExtraJoinButtons || []).includes(val.key)
  315. );
  316. if (!hasJoinByPhoneButton) {
  317. extraButtonsToRender = extraButtonsToRender.filter((btn: any) => btn.key !== 'by-phone');
  318. }
  319. const hasExtraJoinButtons = Boolean(extraButtonsToRender.length);
  320. return (
  321. <PreMeetingScreen
  322. showDeviceStatus = { deviceStatusVisible }
  323. showUnsafeRoomWarning = { showUnsafeRoomWarning }
  324. title = { t('prejoin.joinMeeting') }
  325. videoMuted = { !showCameraPreview }
  326. videoTrack = { videoTrack }>
  327. <div
  328. className = { classes.inputContainer }
  329. data-testid = 'prejoin.screen'>
  330. {showDisplayNameField.current ? (<Input
  331. accessibilityLabel = { t('dialog.enterDisplayName') }
  332. autoComplete = { 'name' }
  333. autoFocus = { true }
  334. className = { classes.input }
  335. error = { showErrorOnJoin }
  336. id = 'premeeting-name-input'
  337. onChange = { setName }
  338. onKeyPress = { showUnsafeRoomWarning && !unsafeRoomConsent ? undefined : onInputKeyPress }
  339. placeholder = { t('dialog.enterDisplayName') }
  340. readOnly = { readOnlyName }
  341. value = { name } />
  342. ) : (
  343. <div className = { classes.avatarContainer }>
  344. <Avatar
  345. className = { classes.avatar }
  346. displayName = { name }
  347. participantId = { participantId }
  348. size = { 72 } />
  349. <div className = { classes.avatarName }>{name}</div>
  350. </div>
  351. )}
  352. {showErrorOnJoin && <div
  353. className = { classes.error }
  354. data-testid = 'prejoin.errorMessage'>{t('prejoin.errorMissingName')}</div>}
  355. <div className = { classes.dropdownContainer }>
  356. <Popover
  357. content = { hasExtraJoinButtons && <div className = { classes.dropdownButtons }>
  358. {extraButtonsToRender.map(({ key, ...rest }) => (
  359. <Button
  360. disabled = { joiningInProgress }
  361. fullWidth = { true }
  362. key = { key }
  363. type = { BUTTON_TYPES.SECONDARY }
  364. { ...rest } />
  365. ))}
  366. </div> }
  367. onPopoverClose = { onDropdownClose }
  368. position = 'bottom'
  369. trigger = 'click'
  370. visible = { showJoinByPhoneButtons }>
  371. <ActionButton
  372. OptionsIcon = { showJoinByPhoneButtons ? IconArrowUp : IconArrowDown }
  373. ariaDropDownLabel = { t('prejoin.joinWithoutAudio') }
  374. ariaLabel = { t('prejoin.joinMeeting') }
  375. ariaPressed = { showJoinByPhoneButtons }
  376. disabled = { joiningInProgress || (showUnsafeRoomWarning && !unsafeRoomConsent) }
  377. hasOptions = { hasExtraJoinButtons }
  378. onClick = { onJoinButtonClick }
  379. onOptionsClick = { onOptionsClick }
  380. role = 'button'
  381. tabIndex = { 0 }
  382. testId = 'prejoin.joinMeeting'
  383. type = 'primary'>
  384. {t('prejoin.joinMeeting')}
  385. </ActionButton>
  386. </Popover>
  387. </div>
  388. </div>
  389. {showDialog && (
  390. <JoinByPhoneDialog
  391. joinConferenceWithoutAudio = { joinConferenceWithoutAudio }
  392. onClose = { closeDialog } />
  393. )}
  394. </PreMeetingScreen>
  395. );
  396. };
  397. /**
  398. * Maps (parts of) the redux state to the React {@code Component} props.
  399. *
  400. * @param {Object} state - The redux state.
  401. * @returns {Object}
  402. */
  403. function mapStateToProps(state: IReduxState) {
  404. const name = getDisplayName(state);
  405. const showErrorOnJoin = isDisplayNameRequired(state) && !name;
  406. const { id: participantId } = getLocalParticipant(state) ?? {};
  407. const { joiningInProgress } = state['features/prejoin'];
  408. const { room } = state['features/base/conference'];
  409. const { unsafeRoomConsent } = state['features/base/premeeting'];
  410. return {
  411. canEditDisplayName: isPrejoinDisplayNameVisible(state),
  412. deviceStatusVisible: isDeviceStatusVisible(state),
  413. hasJoinByPhoneButton: isJoinByPhoneButtonVisible(state),
  414. joiningInProgress,
  415. name,
  416. participantId,
  417. prejoinConfig: state['features/base/config'].prejoinConfig,
  418. readOnlyName: isNameReadOnly(state),
  419. showCameraPreview: !isVideoMutedByUser(state),
  420. showDialog: isJoinByPhoneDialogVisible(state),
  421. showErrorOnJoin,
  422. showUnsafeRoomWarning: isInsecureRoomName(room) && isUnsafeRoomWarningEnabled(state),
  423. unsafeRoomConsent,
  424. videoTrack: getLocalJitsiVideoTrack(state)
  425. };
  426. }
  427. const mapDispatchToProps = {
  428. joinConferenceWithoutAudio: joinConferenceWithoutAudioAction,
  429. joinConference: joinConferenceAction,
  430. setJoinByPhoneDialogVisiblity: setJoinByPhoneDialogVisiblityAction,
  431. updateSettings
  432. };
  433. export default connect(mapStateToProps, mapDispatchToProps)(Prejoin);