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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. // @flow
  2. import i18n from 'i18next';
  3. import { batch } from 'react-redux';
  4. import UIEvents from '../../../../service/UI/UIEvents';
  5. import { approveParticipant } from '../../av-moderation/actions';
  6. import { toggleE2EE } from '../../e2ee/actions';
  7. import { MAX_MODE } from '../../e2ee/constants';
  8. import {
  9. NOTIFICATION_TIMEOUT_TYPE,
  10. RAISE_HAND_NOTIFICATION_ID,
  11. showNotification
  12. } from '../../notifications';
  13. import { isForceMuted } from '../../participants-pane/functions';
  14. import { CALLING, INVITED } from '../../presence-status';
  15. import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
  16. import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
  17. import {
  18. CONFERENCE_WILL_JOIN,
  19. forEachConference,
  20. getCurrentConference
  21. } from '../conference';
  22. import { getDisableRemoveRaisedHandOnFocus } from '../config/functions.any';
  23. import { JitsiConferenceEvents } from '../lib-jitsi-meet';
  24. import { MEDIA_TYPE } from '../media';
  25. import { MiddlewareRegistry, StateListenerRegistry } from '../redux';
  26. import { playSound, registerSound, unregisterSound } from '../sounds';
  27. import {
  28. DOMINANT_SPEAKER_CHANGED,
  29. GRANT_MODERATOR,
  30. KICK_PARTICIPANT,
  31. LOCAL_PARTICIPANT_RAISE_HAND,
  32. MUTE_REMOTE_PARTICIPANT,
  33. PARTICIPANT_DISPLAY_NAME_CHANGED,
  34. PARTICIPANT_JOINED,
  35. PARTICIPANT_LEFT,
  36. PARTICIPANT_UPDATED,
  37. RAISE_HAND_UPDATED
  38. } from './actionTypes';
  39. import {
  40. localParticipantIdChanged,
  41. localParticipantJoined,
  42. localParticipantLeft,
  43. participantLeft,
  44. participantUpdated,
  45. raiseHandUpdateQueue,
  46. setLoadableAvatarUrl
  47. } from './actions';
  48. import {
  49. LOCAL_PARTICIPANT_DEFAULT_ID,
  50. PARTICIPANT_JOINED_SOUND_ID,
  51. PARTICIPANT_LEFT_SOUND_ID
  52. } from './constants';
  53. import {
  54. getFirstLoadableAvatarUrl,
  55. getLocalParticipant,
  56. getParticipantById,
  57. getParticipantCount,
  58. getParticipantDisplayName,
  59. getRaiseHandsQueue,
  60. getRemoteParticipants,
  61. isLocalParticipantModerator
  62. } from './functions';
  63. import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds';
  64. import { hasRaisedHand, raiseHand } from '.';
  65. declare var APP: Object;
  66. /**
  67. * Middleware that captures CONFERENCE_JOINED and CONFERENCE_LEFT actions and
  68. * updates respectively ID of local participant.
  69. *
  70. * @param {Store} store - The redux store.
  71. * @returns {Function}
  72. */
  73. MiddlewareRegistry.register(store => next => action => {
  74. switch (action.type) {
  75. case APP_WILL_MOUNT:
  76. _registerSounds(store);
  77. return _localParticipantJoined(store, next, action);
  78. case APP_WILL_UNMOUNT:
  79. _unregisterSounds(store);
  80. return _localParticipantLeft(store, next, action);
  81. case CONFERENCE_WILL_JOIN:
  82. store.dispatch(localParticipantIdChanged(action.conference.myUserId()));
  83. break;
  84. case DOMINANT_SPEAKER_CHANGED: {
  85. // Lower hand through xmpp when local participant becomes dominant speaker.
  86. const { id } = action.participant;
  87. const state = store.getState();
  88. const participant = getLocalParticipant(state);
  89. const isLocal = participant && participant.id === id;
  90. if (isLocal && hasRaisedHand(participant) && !getDisableRemoveRaisedHandOnFocus(state)) {
  91. store.dispatch(raiseHand(false));
  92. }
  93. break;
  94. }
  95. case GRANT_MODERATOR: {
  96. const { conference } = store.getState()['features/base/conference'];
  97. conference.grantOwner(action.id);
  98. break;
  99. }
  100. case KICK_PARTICIPANT: {
  101. const { conference } = store.getState()['features/base/conference'];
  102. conference.kickParticipant(action.id);
  103. break;
  104. }
  105. case LOCAL_PARTICIPANT_RAISE_HAND: {
  106. const { raisedHandTimestamp } = action;
  107. const localId = getLocalParticipant(store.getState())?.id;
  108. store.dispatch(participantUpdated({
  109. // XXX Only the local participant is allowed to update without
  110. // stating the JitsiConference instance (i.e. participant property
  111. // `conference` for a remote participant) because the local
  112. // participant is uniquely identified by the very fact that there is
  113. // only one local participant.
  114. id: localId,
  115. local: true,
  116. raisedHandTimestamp
  117. }));
  118. store.dispatch(raiseHandUpdateQueue({
  119. id: localId,
  120. raisedHandTimestamp
  121. }));
  122. if (typeof APP !== 'undefined') {
  123. APP.API.notifyRaiseHandUpdated(localId, raisedHandTimestamp);
  124. }
  125. break;
  126. }
  127. case MUTE_REMOTE_PARTICIPANT: {
  128. const { conference } = store.getState()['features/base/conference'];
  129. conference.muteParticipant(action.id, action.mediaType);
  130. break;
  131. }
  132. // TODO Remove this middleware when the local display name update flow is
  133. // fully brought into redux.
  134. case PARTICIPANT_DISPLAY_NAME_CHANGED: {
  135. if (typeof APP !== 'undefined') {
  136. const participant = getLocalParticipant(store.getState());
  137. if (participant && participant.id === action.id) {
  138. APP.UI.emitEvent(UIEvents.NICKNAME_CHANGED, action.name);
  139. }
  140. }
  141. break;
  142. }
  143. case RAISE_HAND_UPDATED: {
  144. const { participant } = action;
  145. let queue = getRaiseHandsQueue(store.getState());
  146. if (participant.raisedHandTimestamp) {
  147. queue.push({
  148. id: participant.id,
  149. raisedHandTimestamp: participant.raisedHandTimestamp
  150. });
  151. // sort the queue before adding to store.
  152. queue = queue.sort(({ raisedHandTimestamp: a }, { raisedHandTimestamp: b }) => a - b);
  153. } else {
  154. // no need to sort on remove value.
  155. queue = queue.filter(({ id }) => id !== participant.id);
  156. }
  157. action.queue = queue;
  158. break;
  159. }
  160. case PARTICIPANT_JOINED: {
  161. _maybePlaySounds(store, action);
  162. return _participantJoinedOrUpdated(store, next, action);
  163. }
  164. case PARTICIPANT_LEFT:
  165. _maybePlaySounds(store, action);
  166. break;
  167. case PARTICIPANT_UPDATED:
  168. return _participantJoinedOrUpdated(store, next, action);
  169. }
  170. return next(action);
  171. });
  172. /**
  173. * Syncs the redux state features/base/participants up with the redux state
  174. * features/base/conference by ensuring that the former does not contain remote
  175. * participants no longer relevant to the latter. Introduced to address an issue
  176. * with multiplying thumbnails in the filmstrip.
  177. */
  178. StateListenerRegistry.register(
  179. /* selector */ state => getCurrentConference(state),
  180. /* listener */ (conference, { dispatch, getState }) => {
  181. batch(() => {
  182. for (const [ id, p ] of getRemoteParticipants(getState())) {
  183. (!conference || p.conference !== conference)
  184. && dispatch(participantLeft(id, p.conference, p.isReplaced));
  185. }
  186. });
  187. });
  188. /**
  189. * Reset the ID of the local participant to
  190. * {@link LOCAL_PARTICIPANT_DEFAULT_ID}. Such a reset is deemed possible only if
  191. * the local participant and, respectively, her ID is not involved in a
  192. * conference which is still of interest to the user and, consequently, the app.
  193. * For example, a conference which is in the process of leaving is no longer of
  194. * interest the user, is unrecoverable from the perspective of the user and,
  195. * consequently, the app.
  196. */
  197. StateListenerRegistry.register(
  198. /* selector */ state => state['features/base/conference'],
  199. /* listener */ ({ leaving }, { dispatch, getState }) => {
  200. const state = getState();
  201. const localParticipant = getLocalParticipant(state);
  202. let id;
  203. if (!localParticipant
  204. || (id = localParticipant.id)
  205. === LOCAL_PARTICIPANT_DEFAULT_ID) {
  206. // The ID of the local participant has been reset already.
  207. return;
  208. }
  209. // The ID of the local may be reset only if it is not in use.
  210. const dispatchLocalParticipantIdChanged
  211. = forEachConference(
  212. state,
  213. conference =>
  214. conference === leaving || conference.myUserId() !== id);
  215. dispatchLocalParticipantIdChanged
  216. && dispatch(
  217. localParticipantIdChanged(LOCAL_PARTICIPANT_DEFAULT_ID));
  218. });
  219. /**
  220. * Registers listeners for participant change events.
  221. */
  222. StateListenerRegistry.register(
  223. state => state['features/base/conference'].conference,
  224. (conference, store) => {
  225. if (conference) {
  226. const propertyHandlers = {
  227. 'e2ee.enabled': (participant, value) => _e2eeUpdated(store, conference, participant.getId(), value),
  228. 'features_e2ee': (participant, value) =>
  229. store.dispatch(participantUpdated({
  230. conference,
  231. id: participant.getId(),
  232. e2eeSupported: value
  233. })),
  234. 'features_jigasi': (participant, value) =>
  235. store.dispatch(participantUpdated({
  236. conference,
  237. id: participant.getId(),
  238. isJigasi: value
  239. })),
  240. 'features_screen-sharing': (participant, value) => // eslint-disable-line no-unused-vars
  241. store.dispatch(participantUpdated({
  242. conference,
  243. id: participant.getId(),
  244. features: { 'screen-sharing': true }
  245. })),
  246. 'raisedHand': (participant, value) =>
  247. _raiseHandUpdated(store, conference, participant.getId(), value),
  248. 'remoteControlSessionStatus': (participant, value) =>
  249. store.dispatch(participantUpdated({
  250. conference,
  251. id: participant.getId(),
  252. remoteControlSessionStatus: value
  253. }))
  254. };
  255. // update properties for the participants that are already in the conference
  256. conference.getParticipants().forEach(participant => {
  257. Object.keys(propertyHandlers).forEach(propertyName => {
  258. const value = participant.getProperty(propertyName);
  259. if (value !== undefined) {
  260. propertyHandlers[propertyName](participant, value);
  261. }
  262. });
  263. });
  264. // We joined a conference
  265. conference.on(
  266. JitsiConferenceEvents.PARTICIPANT_PROPERTY_CHANGED,
  267. (participant, propertyName, oldValue, newValue) => {
  268. if (propertyHandlers.hasOwnProperty(propertyName)) {
  269. propertyHandlers[propertyName](participant, newValue);
  270. }
  271. });
  272. } else {
  273. const localParticipantId = getLocalParticipant(store.getState).id;
  274. // We left the conference, the local participant must be updated.
  275. _e2eeUpdated(store, conference, localParticipantId, false);
  276. _raiseHandUpdated(store, conference, localParticipantId, 0);
  277. }
  278. }
  279. );
  280. /**
  281. * Handles a E2EE enabled status update.
  282. *
  283. * @param {Store} store - The redux store.
  284. * @param {Object} conference - The conference for which we got an update.
  285. * @param {string} participantId - The ID of the participant from which we got an update.
  286. * @param {boolean} newValue - The new value of the E2EE enabled status.
  287. * @returns {void}
  288. */
  289. function _e2eeUpdated({ getState, dispatch }, conference, participantId, newValue) {
  290. const e2eeEnabled = newValue === 'true';
  291. const { e2ee = {} } = getState()['features/base/config'];
  292. dispatch(participantUpdated({
  293. conference,
  294. id: participantId,
  295. e2eeEnabled
  296. }));
  297. if (e2ee.externallyManagedKey) {
  298. return;
  299. }
  300. const { maxMode } = getState()['features/e2ee'] || {};
  301. if (maxMode !== MAX_MODE.THRESHOLD_EXCEEDED || !e2eeEnabled) {
  302. dispatch(toggleE2EE(e2eeEnabled));
  303. }
  304. }
  305. /**
  306. * Initializes the local participant and signals that it joined.
  307. *
  308. * @private
  309. * @param {Store} store - The redux store.
  310. * @param {Dispatch} next - The redux dispatch function to dispatch the
  311. * specified action to the specified store.
  312. * @param {Action} action - The redux action which is being dispatched
  313. * in the specified store.
  314. * @private
  315. * @returns {Object} The value returned by {@code next(action)}.
  316. */
  317. function _localParticipantJoined({ getState, dispatch }, next, action) {
  318. const result = next(action);
  319. const settings = getState()['features/base/settings'];
  320. dispatch(localParticipantJoined({
  321. avatarURL: settings.avatarURL,
  322. email: settings.email,
  323. name: settings.displayName
  324. }));
  325. return result;
  326. }
  327. /**
  328. * Signals that the local participant has left.
  329. *
  330. * @param {Store} store - The redux store.
  331. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  332. * specified {@code action} into the specified {@code store}.
  333. * @param {Action} action - The redux action which is being dispatched in the
  334. * specified {@code store}.
  335. * @private
  336. * @returns {Object} The value returned by {@code next(action)}.
  337. */
  338. function _localParticipantLeft({ dispatch }, next, action) {
  339. const result = next(action);
  340. dispatch(localParticipantLeft());
  341. return result;
  342. }
  343. /**
  344. * Plays sounds when participants join/leave conference.
  345. *
  346. * @param {Store} store - The redux store.
  347. * @param {Action} action - The redux action. Should be either
  348. * {@link PARTICIPANT_JOINED} or {@link PARTICIPANT_LEFT}.
  349. * @private
  350. * @returns {void}
  351. */
  352. function _maybePlaySounds({ getState, dispatch }, action) {
  353. const state = getState();
  354. const { startAudioMuted } = state['features/base/config'];
  355. const { soundsParticipantJoined: joinSound, soundsParticipantLeft: leftSound } = state['features/base/settings'];
  356. // We're not playing sounds for local participant
  357. // nor when the user is joining past the "startAudioMuted" limit.
  358. // The intention there was to not play user joined notification in big
  359. // conferences where 100th person is joining.
  360. if (!action.participant.local
  361. && (!startAudioMuted
  362. || getParticipantCount(state) < startAudioMuted)) {
  363. const { isReplacing, isReplaced } = action.participant;
  364. if (action.type === PARTICIPANT_JOINED) {
  365. if (!joinSound) {
  366. return;
  367. }
  368. const { presence } = action.participant;
  369. // The sounds for the poltergeist are handled by features/invite.
  370. if (presence !== INVITED && presence !== CALLING && !isReplacing) {
  371. dispatch(playSound(PARTICIPANT_JOINED_SOUND_ID));
  372. }
  373. } else if (action.type === PARTICIPANT_LEFT && !isReplaced && leftSound) {
  374. dispatch(playSound(PARTICIPANT_LEFT_SOUND_ID));
  375. }
  376. }
  377. }
  378. /**
  379. * Notifies the feature base/participants that the action
  380. * {@code PARTICIPANT_JOINED} or {@code PARTICIPANT_UPDATED} is being dispatched
  381. * within a specific redux store.
  382. *
  383. * @param {Store} store - The redux store in which the specified {@code action}
  384. * is being dispatched.
  385. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  386. * specified {@code action} in the specified {@code store}.
  387. * @param {Action} action - The redux action {@code PARTICIPANT_JOINED} or
  388. * {@code PARTICIPANT_UPDATED} which is being dispatched in the specified
  389. * {@code store}.
  390. * @private
  391. * @returns {Object} The value returned by {@code next(action)}.
  392. */
  393. function _participantJoinedOrUpdated(store, next, action) {
  394. const { dispatch, getState } = store;
  395. const { participant: { avatarURL, email, id, local, name, raisedHandTimestamp } } = action;
  396. // Send an external update of the local participant's raised hand state
  397. // if a new raised hand state is defined in the action.
  398. if (typeof raisedHandTimestamp !== 'undefined') {
  399. if (local) {
  400. const { conference } = getState()['features/base/conference'];
  401. const rHand = parseInt(raisedHandTimestamp, 10);
  402. // Send raisedHand signalling only if there is a change
  403. if (conference && rHand !== getLocalParticipant(getState()).raisedHandTimestamp) {
  404. conference.setLocalParticipantProperty('raisedHand', rHand);
  405. }
  406. }
  407. }
  408. // Allow the redux update to go through and compare the old avatar
  409. // to the new avatar and emit out change events if necessary.
  410. const result = next(action);
  411. // Only run this if the config is populated, otherwise we preload external resources
  412. // even if disableThirdPartyRequests is set to true in config
  413. if (Object.keys(getState()['features/base/config']).length) {
  414. const { disableThirdPartyRequests } = getState()['features/base/config'];
  415. if (!disableThirdPartyRequests && (avatarURL || email || id || name)) {
  416. const participantId = !id && local ? getLocalParticipant(getState()).id : id;
  417. const updatedParticipant = getParticipantById(getState(), participantId);
  418. getFirstLoadableAvatarUrl(updatedParticipant, store)
  419. .then(url => {
  420. dispatch(setLoadableAvatarUrl(participantId, url));
  421. });
  422. }
  423. }
  424. // Notify external listeners of potential avatarURL changes.
  425. if (typeof APP === 'object') {
  426. const currentKnownId = local ? APP.conference.getMyUserId() : id;
  427. // Force update of local video getting a new id.
  428. APP.UI.refreshAvatarDisplay(currentKnownId);
  429. }
  430. return result;
  431. }
  432. /**
  433. * Handles a raise hand status update.
  434. *
  435. * @param {Function} dispatch - The Redux dispatch function.
  436. * @param {Object} conference - The conference for which we got an update.
  437. * @param {string} participantId - The ID of the participant from which we got an update.
  438. * @param {boolean} newValue - The new value of the raise hand status.
  439. * @returns {void}
  440. */
  441. function _raiseHandUpdated({ dispatch, getState }, conference, participantId, newValue) {
  442. let raisedHandTimestamp;
  443. switch (newValue) {
  444. case undefined:
  445. case 'false':
  446. raisedHandTimestamp = 0;
  447. break;
  448. case 'true':
  449. raisedHandTimestamp = Date.now();
  450. break;
  451. default:
  452. raisedHandTimestamp = parseInt(newValue, 10);
  453. }
  454. const state = getState();
  455. dispatch(participantUpdated({
  456. conference,
  457. id: participantId,
  458. raisedHandTimestamp
  459. }));
  460. dispatch(raiseHandUpdateQueue({
  461. id: participantId,
  462. raisedHandTimestamp
  463. }));
  464. if (typeof APP !== 'undefined') {
  465. APP.API.notifyRaiseHandUpdated(participantId, raisedHandTimestamp);
  466. }
  467. const isModerator = isLocalParticipantModerator(state);
  468. const participant = getParticipantById(state, participantId);
  469. let shouldDisplayAllowAction = false;
  470. if (isModerator) {
  471. shouldDisplayAllowAction = isForceMuted(participant, MEDIA_TYPE.AUDIO, state)
  472. || isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
  473. }
  474. const action = shouldDisplayAllowAction ? {
  475. customActionNameKey: [ 'notify.allowAction' ],
  476. customActionHandler: [ () => dispatch(approveParticipant(participantId)) ]
  477. } : {};
  478. if (raisedHandTimestamp) {
  479. let notificationTitle;
  480. const participantName = getParticipantDisplayName(state, participantId);
  481. const { raisedHandsQueue } = state['features/base/participants'];
  482. if (raisedHandsQueue.length > 1) {
  483. const raisedHands = raisedHandsQueue.length - 1;
  484. notificationTitle = i18n.t('notify.raisedHands', {
  485. participantName,
  486. raisedHands
  487. });
  488. } else {
  489. notificationTitle = participantName;
  490. }
  491. dispatch(showNotification({
  492. titleKey: 'notify.somebody',
  493. title: notificationTitle,
  494. descriptionKey: 'notify.raisedHand',
  495. raiseHandNotification: true,
  496. concatText: true,
  497. uid: RAISE_HAND_NOTIFICATION_ID,
  498. ...action
  499. }, shouldDisplayAllowAction ? NOTIFICATION_TIMEOUT_TYPE.MEDIUM : NOTIFICATION_TIMEOUT_TYPE.SHORT));
  500. dispatch(playSound(RAISE_HAND_SOUND_ID));
  501. }
  502. }
  503. /**
  504. * Registers sounds related with the participants feature.
  505. *
  506. * @param {Store} store - The redux store.
  507. * @private
  508. * @returns {void}
  509. */
  510. function _registerSounds({ dispatch }) {
  511. dispatch(
  512. registerSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_FILE));
  513. dispatch(registerSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_FILE));
  514. }
  515. /**
  516. * Unregisters sounds related with the participants feature.
  517. *
  518. * @param {Store} store - The redux store.
  519. * @private
  520. * @returns {void}
  521. */
  522. function _unregisterSounds({ dispatch }) {
  523. dispatch(unregisterSound(PARTICIPANT_JOINED_SOUND_ID));
  524. dispatch(unregisterSound(PARTICIPANT_LEFT_SOUND_ID));
  525. }