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.

middleware.ts 31KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885
  1. import i18n from 'i18next';
  2. import { batch } from 'react-redux';
  3. import { AnyAction } from 'redux';
  4. import { IStore } from '../../app/types';
  5. import { approveParticipant, approveParticipantAudio, approveParticipantVideo } from '../../av-moderation/actions';
  6. import { UPDATE_BREAKOUT_ROOMS } from '../../breakout-rooms/actionTypes';
  7. import { getBreakoutRooms } from '../../breakout-rooms/functions';
  8. import { toggleE2EE } from '../../e2ee/actions';
  9. import { MAX_MODE } from '../../e2ee/constants';
  10. import { hideNotification, showNotification } from '../../notifications/actions';
  11. import {
  12. LOCAL_RECORDING_NOTIFICATION_ID,
  13. NOTIFICATION_TIMEOUT_TYPE,
  14. RAISE_HAND_NOTIFICATION_ID
  15. } from '../../notifications/constants';
  16. import { open as openParticipantsPane } from '../../participants-pane/actions';
  17. import { isForceMuted } from '../../participants-pane/functions';
  18. import { CALLING, INVITED } from '../../presence-status/constants';
  19. import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
  20. import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../../recording/constants';
  21. import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app/actionTypes';
  22. import { CONFERENCE_JOINED, CONFERENCE_WILL_JOIN } from '../conference/actionTypes';
  23. import { forEachConference, getCurrentConference } from '../conference/functions';
  24. import { IJitsiConference } from '../conference/reducer';
  25. import { SET_CONFIG } from '../config/actionTypes';
  26. import { getDisableRemoveRaisedHandOnFocus } from '../config/functions.any';
  27. import { JitsiConferenceEvents } from '../lib-jitsi-meet';
  28. import { MEDIA_TYPE } from '../media/constants';
  29. import MiddlewareRegistry from '../redux/MiddlewareRegistry';
  30. import StateListenerRegistry from '../redux/StateListenerRegistry';
  31. import { playSound, registerSound, unregisterSound } from '../sounds/actions';
  32. import { isImageDataURL } from '../util/uri';
  33. import {
  34. DOMINANT_SPEAKER_CHANGED,
  35. GRANT_MODERATOR,
  36. KICK_PARTICIPANT,
  37. LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
  38. LOCAL_PARTICIPANT_RAISE_HAND,
  39. MUTE_REMOTE_PARTICIPANT,
  40. OVERWRITE_PARTICIPANTS_NAMES,
  41. OVERWRITE_PARTICIPANT_NAME,
  42. PARTICIPANT_JOINED,
  43. PARTICIPANT_LEFT,
  44. PARTICIPANT_UPDATED,
  45. RAISE_HAND_UPDATED,
  46. SET_LOCAL_PARTICIPANT_RECORDING_STATUS
  47. } from './actionTypes';
  48. import {
  49. localParticipantIdChanged,
  50. localParticipantJoined,
  51. localParticipantLeft,
  52. overwriteParticipantName,
  53. participantLeft,
  54. participantUpdated,
  55. raiseHand,
  56. raiseHandUpdateQueue,
  57. setLoadableAvatarUrl
  58. } from './actions';
  59. import {
  60. LOCAL_PARTICIPANT_DEFAULT_ID,
  61. LOWER_HAND_AUDIO_LEVEL,
  62. PARTICIPANT_JOINED_SOUND_ID,
  63. PARTICIPANT_LEFT_SOUND_ID
  64. } from './constants';
  65. import {
  66. getDominantSpeakerParticipant,
  67. getFirstLoadableAvatarUrl,
  68. getLocalParticipant,
  69. getParticipantById,
  70. getParticipantCount,
  71. getParticipantDisplayName,
  72. getRaiseHandsQueue,
  73. getRemoteParticipants,
  74. hasRaisedHand,
  75. isLocalParticipantModerator,
  76. isScreenShareParticipant,
  77. isWhiteboardParticipant
  78. } from './functions';
  79. import logger from './logger';
  80. import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds';
  81. import { IJitsiParticipant } from './types';
  82. import './subscriber';
  83. /**
  84. * Middleware that captures CONFERENCE_JOINED and CONFERENCE_LEFT actions and
  85. * updates respectively ID of local participant.
  86. *
  87. * @param {Store} store - The redux store.
  88. * @returns {Function}
  89. */
  90. MiddlewareRegistry.register(store => next => action => {
  91. switch (action.type) {
  92. case APP_WILL_MOUNT:
  93. _registerSounds(store);
  94. return _localParticipantJoined(store, next, action);
  95. case APP_WILL_UNMOUNT:
  96. _unregisterSounds(store);
  97. return _localParticipantLeft(store, next, action);
  98. case CONFERENCE_WILL_JOIN:
  99. store.dispatch(localParticipantIdChanged(action.conference.myUserId()));
  100. break;
  101. case DOMINANT_SPEAKER_CHANGED: {
  102. // Lower hand through xmpp when local participant becomes dominant speaker.
  103. const { id } = action.participant;
  104. const state = store.getState();
  105. const participant = getLocalParticipant(state);
  106. const dominantSpeaker = getDominantSpeakerParticipant(state);
  107. const isLocal = participant && participant.id === id;
  108. if (isLocal && dominantSpeaker?.id !== id
  109. && hasRaisedHand(participant)
  110. && !getDisableRemoveRaisedHandOnFocus(state)) {
  111. store.dispatch(raiseHand(false));
  112. }
  113. break;
  114. }
  115. case LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED: {
  116. const state = store.getState();
  117. const participant = getDominantSpeakerParticipant(state);
  118. if (
  119. participant?.local
  120. && hasRaisedHand(participant)
  121. && action.level > LOWER_HAND_AUDIO_LEVEL
  122. && !getDisableRemoveRaisedHandOnFocus(state)
  123. ) {
  124. store.dispatch(raiseHand(false));
  125. }
  126. break;
  127. }
  128. case GRANT_MODERATOR: {
  129. const { conference } = store.getState()['features/base/conference'];
  130. conference?.grantOwner(action.id);
  131. break;
  132. }
  133. case KICK_PARTICIPANT: {
  134. const { conference } = store.getState()['features/base/conference'];
  135. conference?.kickParticipant(action.id);
  136. break;
  137. }
  138. case LOCAL_PARTICIPANT_RAISE_HAND: {
  139. const { raisedHandTimestamp } = action;
  140. const localId = getLocalParticipant(store.getState())?.id;
  141. store.dispatch(participantUpdated({
  142. // XXX Only the local participant is allowed to update without
  143. // stating the JitsiConference instance (i.e. participant property
  144. // `conference` for a remote participant) because the local
  145. // participant is uniquely identified by the very fact that there is
  146. // only one local participant.
  147. id: localId ?? '',
  148. local: true,
  149. raisedHandTimestamp
  150. }));
  151. store.dispatch(raiseHandUpdateQueue({
  152. id: localId ?? '',
  153. raisedHandTimestamp
  154. }));
  155. if (typeof APP !== 'undefined') {
  156. APP.API.notifyRaiseHandUpdated(localId, raisedHandTimestamp);
  157. }
  158. break;
  159. }
  160. case SET_CONFIG: {
  161. const result = next(action);
  162. const state = store.getState();
  163. const { deploymentInfo } = state['features/base/config'];
  164. // if there userRegion set let's use it for the local participant
  165. if (deploymentInfo?.userRegion) {
  166. const localId = getLocalParticipant(state)?.id;
  167. if (localId) {
  168. store.dispatch(participantUpdated({
  169. id: localId,
  170. local: true,
  171. region: deploymentInfo.userRegion
  172. }));
  173. }
  174. }
  175. return result;
  176. }
  177. case CONFERENCE_JOINED: {
  178. const result = next(action);
  179. const state = store.getState();
  180. const { startSilent } = state['features/base/config'];
  181. if (startSilent) {
  182. const localId = getLocalParticipant(store.getState())?.id;
  183. if (localId) {
  184. store.dispatch(participantUpdated({
  185. id: localId,
  186. local: true,
  187. isSilent: startSilent
  188. }));
  189. }
  190. }
  191. return result;
  192. }
  193. case SET_LOCAL_PARTICIPANT_RECORDING_STATUS: {
  194. const state = store.getState();
  195. const { recording, onlySelf } = action;
  196. const localId = getLocalParticipant(state)?.id;
  197. const { localRecording } = state['features/base/config'];
  198. if (localRecording?.notifyAllParticipants && !onlySelf && localId) {
  199. store.dispatch(participantUpdated({
  200. // XXX Only the local participant is allowed to update without
  201. // stating the JitsiConference instance (i.e. participant property
  202. // `conference` for a remote participant) because the local
  203. // participant is uniquely identified by the very fact that there is
  204. // only one local participant.
  205. id: localId,
  206. local: true,
  207. localRecording: recording
  208. }));
  209. }
  210. break;
  211. }
  212. case MUTE_REMOTE_PARTICIPANT: {
  213. const { conference } = store.getState()['features/base/conference'];
  214. conference?.muteParticipant(action.id, action.mediaType);
  215. break;
  216. }
  217. case RAISE_HAND_UPDATED: {
  218. const { participant } = action;
  219. let queue = getRaiseHandsQueue(store.getState());
  220. if (participant.raisedHandTimestamp) {
  221. queue = [ ...queue, { id: participant.id,
  222. raisedHandTimestamp: participant.raisedHandTimestamp } ];
  223. // sort the queue before adding to store.
  224. queue = queue.sort(({ raisedHandTimestamp: a }, { raisedHandTimestamp: b }) => a - b);
  225. } else {
  226. // no need to sort on remove value.
  227. queue = queue.filter(({ id }) => id !== participant.id);
  228. }
  229. action.queue = queue;
  230. break;
  231. }
  232. case PARTICIPANT_JOINED: {
  233. // Do not play sounds when a screenshare or whiteboard participant tile is created for screenshare.
  234. (!isScreenShareParticipant(action.participant)
  235. && !isWhiteboardParticipant(action.participant)
  236. ) && _maybePlaySounds(store, action);
  237. return _participantJoinedOrUpdated(store, next, action);
  238. }
  239. case PARTICIPANT_LEFT: {
  240. // Do not play sounds when a tile for screenshare or whiteboard is removed.
  241. (!isScreenShareParticipant(action.participant)
  242. && !isWhiteboardParticipant(action.participant)
  243. ) && _maybePlaySounds(store, action);
  244. break;
  245. }
  246. case PARTICIPANT_UPDATED:
  247. return _participantJoinedOrUpdated(store, next, action);
  248. case OVERWRITE_PARTICIPANTS_NAMES: {
  249. const { participantList } = action;
  250. if (!Array.isArray(participantList)) {
  251. logger.error('Overwrite names failed. Argument is not an array.');
  252. return;
  253. }
  254. batch(() => {
  255. participantList.forEach(p => {
  256. store.dispatch(overwriteParticipantName(p.id, p.name));
  257. });
  258. });
  259. break;
  260. }
  261. case OVERWRITE_PARTICIPANT_NAME: {
  262. const { dispatch, getState } = store;
  263. const state = getState();
  264. const { id, name } = action;
  265. let breakoutRoom = false, identifier = id;
  266. if (id.indexOf('@') !== -1) {
  267. identifier = id.slice(id.indexOf('/') + 1);
  268. breakoutRoom = true;
  269. action.id = identifier;
  270. }
  271. if (breakoutRoom) {
  272. const rooms = getBreakoutRooms(state);
  273. const roomCounter = state['features/breakout-rooms'].roomCounter;
  274. const newRooms: any = {};
  275. Object.entries(rooms).forEach(([ key, r ]) => {
  276. const participants = r?.participants || {};
  277. const jid = Object.keys(participants).find(p =>
  278. p.slice(p.indexOf('/') + 1) === identifier);
  279. if (jid) {
  280. newRooms[key] = {
  281. ...r,
  282. participants: {
  283. ...participants,
  284. [jid]: {
  285. ...participants[jid],
  286. displayName: name
  287. }
  288. }
  289. };
  290. } else {
  291. newRooms[key] = r;
  292. }
  293. });
  294. dispatch({
  295. type: UPDATE_BREAKOUT_ROOMS,
  296. rooms,
  297. roomCounter,
  298. updatedNames: true
  299. });
  300. } else {
  301. dispatch(participantUpdated({
  302. id: identifier,
  303. name
  304. }));
  305. }
  306. break;
  307. }
  308. }
  309. return next(action);
  310. });
  311. /**
  312. * Syncs the redux state features/base/participants up with the redux state
  313. * features/base/conference by ensuring that the former does not contain remote
  314. * participants no longer relevant to the latter. Introduced to address an issue
  315. * with multiplying thumbnails in the filmstrip.
  316. */
  317. StateListenerRegistry.register(
  318. /* selector */ state => getCurrentConference(state),
  319. /* listener */ (conference, { dispatch, getState }) => {
  320. batch(() => {
  321. for (const [ id, p ] of getRemoteParticipants(getState())) {
  322. (!conference || p.conference !== conference)
  323. && dispatch(participantLeft(id, p.conference, {
  324. isReplaced: p.isReplaced
  325. }));
  326. }
  327. });
  328. });
  329. /**
  330. * Reset the ID of the local participant to
  331. * {@link LOCAL_PARTICIPANT_DEFAULT_ID}. Such a reset is deemed possible only if
  332. * the local participant and, respectively, her ID is not involved in a
  333. * conference which is still of interest to the user and, consequently, the app.
  334. * For example, a conference which is in the process of leaving is no longer of
  335. * interest the user, is unrecoverable from the perspective of the user and,
  336. * consequently, the app.
  337. */
  338. StateListenerRegistry.register(
  339. /* selector */ state => state['features/base/conference'],
  340. /* listener */ ({ leaving }, { dispatch, getState }) => {
  341. const state = getState();
  342. const localParticipant = getLocalParticipant(state);
  343. let id: string;
  344. if (!localParticipant
  345. || (id = localParticipant.id)
  346. === LOCAL_PARTICIPANT_DEFAULT_ID) {
  347. // The ID of the local participant has been reset already.
  348. return;
  349. }
  350. // The ID of the local may be reset only if it is not in use.
  351. const dispatchLocalParticipantIdChanged
  352. = forEachConference(
  353. state,
  354. conference =>
  355. conference === leaving || conference.myUserId() !== id);
  356. dispatchLocalParticipantIdChanged
  357. && dispatch(
  358. localParticipantIdChanged(LOCAL_PARTICIPANT_DEFAULT_ID));
  359. });
  360. /**
  361. * Registers listeners for participant change events.
  362. */
  363. StateListenerRegistry.register(
  364. state => state['features/base/conference'].conference,
  365. (conference, store) => {
  366. if (conference) {
  367. const propertyHandlers: {
  368. [key: string]: Function;
  369. } = {
  370. 'e2ee.enabled': (participant: IJitsiParticipant, value: string) =>
  371. _e2eeUpdated(store, conference, participant.getId(), value),
  372. 'features_e2ee': (participant: IJitsiParticipant, value: boolean) =>
  373. getParticipantById(store.getState(), participant.getId())?.e2eeSupported !== value
  374. && store.dispatch(participantUpdated({
  375. conference,
  376. id: participant.getId(),
  377. e2eeSupported: value
  378. })),
  379. 'features_jigasi': (participant: IJitsiParticipant, value: boolean) =>
  380. store.dispatch(participantUpdated({
  381. conference,
  382. id: participant.getId(),
  383. isJigasi: value
  384. })),
  385. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  386. 'features_screen-sharing': (participant: IJitsiParticipant, value: string) =>
  387. store.dispatch(participantUpdated({
  388. conference,
  389. id: participant.getId(),
  390. features: { 'screen-sharing': true }
  391. })),
  392. 'localRecording': (participant: IJitsiParticipant, value: string) =>
  393. _localRecordingUpdated(store, conference, participant.getId(), Boolean(value)),
  394. 'raisedHand': (participant: IJitsiParticipant, value: string) =>
  395. _raiseHandUpdated(store, conference, participant.getId(), value),
  396. 'region': (participant: IJitsiParticipant, value: string) =>
  397. store.dispatch(participantUpdated({
  398. conference,
  399. id: participant.getId(),
  400. region: value
  401. })),
  402. 'remoteControlSessionStatus': (participant: IJitsiParticipant, value: boolean) =>
  403. store.dispatch(participantUpdated({
  404. conference,
  405. id: participant.getId(),
  406. remoteControlSessionStatus: value
  407. }))
  408. };
  409. // update properties for the participants that are already in the conference
  410. conference.getParticipants().forEach((participant: any) => {
  411. Object.keys(propertyHandlers).forEach(propertyName => {
  412. const value = participant.getProperty(propertyName);
  413. if (value !== undefined) {
  414. propertyHandlers[propertyName as keyof typeof propertyHandlers](participant, value);
  415. }
  416. });
  417. });
  418. // We joined a conference
  419. conference.on(
  420. JitsiConferenceEvents.PARTICIPANT_PROPERTY_CHANGED,
  421. (participant: IJitsiParticipant, propertyName: string, oldValue: string, newValue: string) => {
  422. if (propertyHandlers.hasOwnProperty(propertyName)) {
  423. propertyHandlers[propertyName](participant, newValue);
  424. }
  425. });
  426. } else {
  427. const localParticipantId = getLocalParticipant(store.getState)?.id;
  428. // We left the conference, the local participant must be updated.
  429. _e2eeUpdated(store, conference, localParticipantId ?? '', false);
  430. _raiseHandUpdated(store, conference, localParticipantId ?? '', 0);
  431. }
  432. }
  433. );
  434. /**
  435. * Handles a E2EE enabled status update.
  436. *
  437. * @param {Store} store - The redux store.
  438. * @param {Object} conference - The conference for which we got an update.
  439. * @param {string} participantId - The ID of the participant from which we got an update.
  440. * @param {boolean} newValue - The new value of the E2EE enabled status.
  441. * @returns {void}
  442. */
  443. function _e2eeUpdated({ getState, dispatch }: IStore, conference: IJitsiConference,
  444. participantId: string, newValue: string | boolean) {
  445. const e2eeEnabled = newValue === 'true';
  446. const state = getState();
  447. const { e2ee = {} } = state['features/base/config'];
  448. if (e2eeEnabled === getParticipantById(state, participantId)?.e2eeEnabled) {
  449. return;
  450. }
  451. dispatch(participantUpdated({
  452. conference,
  453. id: participantId,
  454. e2eeEnabled
  455. }));
  456. if (e2ee.externallyManagedKey) {
  457. return;
  458. }
  459. const { maxMode } = getState()['features/e2ee'] || {};
  460. if (maxMode !== MAX_MODE.THRESHOLD_EXCEEDED || !e2eeEnabled) {
  461. dispatch(toggleE2EE(e2eeEnabled));
  462. }
  463. }
  464. /**
  465. * Initializes the local participant and signals that it joined.
  466. *
  467. * @private
  468. * @param {Store} store - The redux store.
  469. * @param {Dispatch} next - The redux dispatch function to dispatch the
  470. * specified action to the specified store.
  471. * @param {Action} action - The redux action which is being dispatched
  472. * in the specified store.
  473. * @private
  474. * @returns {Object} The value returned by {@code next(action)}.
  475. */
  476. function _localParticipantJoined({ getState, dispatch }: IStore, next: Function, action: AnyAction) {
  477. const result = next(action);
  478. const settings = getState()['features/base/settings'];
  479. dispatch(localParticipantJoined({
  480. avatarURL: settings.avatarURL,
  481. email: settings.email,
  482. name: settings.displayName,
  483. id: ''
  484. }));
  485. return result;
  486. }
  487. /**
  488. * Signals that the local participant has left.
  489. *
  490. * @param {Store} store - The redux store.
  491. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  492. * specified {@code action} into the specified {@code store}.
  493. * @param {Action} action - The redux action which is being dispatched in the
  494. * specified {@code store}.
  495. * @private
  496. * @returns {Object} The value returned by {@code next(action)}.
  497. */
  498. function _localParticipantLeft({ dispatch }: IStore, next: Function, action: AnyAction) {
  499. const result = next(action);
  500. dispatch(localParticipantLeft());
  501. return result;
  502. }
  503. /**
  504. * Plays sounds when participants join/leave conference.
  505. *
  506. * @param {Store} store - The redux store.
  507. * @param {Action} action - The redux action. Should be either
  508. * {@link PARTICIPANT_JOINED} or {@link PARTICIPANT_LEFT}.
  509. * @private
  510. * @returns {void}
  511. */
  512. function _maybePlaySounds({ getState, dispatch }: IStore, action: AnyAction) {
  513. const state = getState();
  514. const { startAudioMuted } = state['features/base/config'];
  515. const { soundsParticipantJoined: joinSound, soundsParticipantLeft: leftSound } = state['features/base/settings'];
  516. // We're not playing sounds for local participant
  517. // nor when the user is joining past the "startAudioMuted" limit.
  518. // The intention there was to not play user joined notification in big
  519. // conferences where 100th person is joining.
  520. if (!action.participant.local
  521. && (!startAudioMuted
  522. || getParticipantCount(state) < startAudioMuted)) {
  523. const { isReplacing, isReplaced } = action.participant;
  524. if (action.type === PARTICIPANT_JOINED) {
  525. if (!joinSound) {
  526. return;
  527. }
  528. const { presence } = action.participant;
  529. // The sounds for the poltergeist are handled by features/invite.
  530. if (presence !== INVITED && presence !== CALLING && !isReplacing) {
  531. dispatch(playSound(PARTICIPANT_JOINED_SOUND_ID));
  532. }
  533. } else if (action.type === PARTICIPANT_LEFT && !isReplaced && leftSound) {
  534. dispatch(playSound(PARTICIPANT_LEFT_SOUND_ID));
  535. }
  536. }
  537. }
  538. /**
  539. * Notifies the feature base/participants that the action
  540. * {@code PARTICIPANT_JOINED} or {@code PARTICIPANT_UPDATED} is being dispatched
  541. * within a specific redux store.
  542. *
  543. * @param {Store} store - The redux store in which the specified {@code action}
  544. * is being dispatched.
  545. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  546. * specified {@code action} in the specified {@code store}.
  547. * @param {Action} action - The redux action {@code PARTICIPANT_JOINED} or
  548. * {@code PARTICIPANT_UPDATED} which is being dispatched in the specified
  549. * {@code store}.
  550. * @private
  551. * @returns {Object} The value returned by {@code next(action)}.
  552. */
  553. function _participantJoinedOrUpdated(store: IStore, next: Function, action: AnyAction) {
  554. const { dispatch, getState } = store;
  555. const { overwrittenNameList } = store.getState()['features/base/participants'];
  556. const { participant: {
  557. avatarURL,
  558. email,
  559. id,
  560. local,
  561. localRecording,
  562. name,
  563. raisedHandTimestamp
  564. } } = action;
  565. // Send an external update of the local participant's raised hand state
  566. // if a new raised hand state is defined in the action.
  567. if (typeof raisedHandTimestamp !== 'undefined') {
  568. if (local) {
  569. const { conference } = getState()['features/base/conference'];
  570. const rHand = parseInt(raisedHandTimestamp, 10);
  571. // Send raisedHand signalling only if there is a change
  572. if (conference && rHand !== getLocalParticipant(getState())?.raisedHandTimestamp) {
  573. conference.setLocalParticipantProperty('raisedHand', rHand);
  574. }
  575. }
  576. }
  577. if (overwrittenNameList[id]) {
  578. action.participant.name = overwrittenNameList[id];
  579. }
  580. // Send an external update of the local participant's local recording state
  581. // if a new local recording state is defined in the action.
  582. if (typeof localRecording !== 'undefined') {
  583. if (local) {
  584. const conference = getCurrentConference(getState);
  585. // Send localRecording signalling only if there is a change
  586. if (conference
  587. && localRecording !== getLocalParticipant(getState())?.localRecording) {
  588. conference.setLocalParticipantProperty('localRecording', localRecording);
  589. }
  590. }
  591. }
  592. // Allow the redux update to go through and compare the old avatar
  593. // to the new avatar and emit out change events if necessary.
  594. const result = next(action);
  595. // Only run this if the config is populated, otherwise we preload external resources
  596. // even if disableThirdPartyRequests is set to true in config
  597. if (getState()['features/base/config']?.hosts) {
  598. const { disableThirdPartyRequests } = getState()['features/base/config'];
  599. const participantId = !id && local ? getLocalParticipant(getState())?.id : id;
  600. if (avatarURL || email || id || name) {
  601. if (!disableThirdPartyRequests) {
  602. const updatedParticipant = getParticipantById(getState(), participantId);
  603. getFirstLoadableAvatarUrl(updatedParticipant ?? { id: '' }, store)
  604. .then((urlData?: { isUsingCORS: boolean; src: string; }) => {
  605. dispatch(setLoadableAvatarUrl(
  606. participantId, urlData?.src ?? '', Boolean(urlData?.isUsingCORS)));
  607. });
  608. } else if (isImageDataURL(avatarURL)) {
  609. dispatch(setLoadableAvatarUrl(participantId, avatarURL, false));
  610. }
  611. }
  612. }
  613. return result;
  614. }
  615. /**
  616. * Handles a local recording status update.
  617. *
  618. * @param {Function} dispatch - The Redux dispatch function.
  619. * @param {Object} conference - The conference for which we got an update.
  620. * @param {string} participantId - The ID of the participant from which we got an update.
  621. * @param {boolean} newValue - The new value of the local recording status.
  622. * @returns {void}
  623. */
  624. function _localRecordingUpdated({ dispatch, getState }: IStore, conference: IJitsiConference,
  625. participantId: string, newValue: boolean) {
  626. const state = getState();
  627. const participant = getParticipantById(state, participantId);
  628. if (participant?.localRecording === newValue) {
  629. return;
  630. }
  631. dispatch(participantUpdated({
  632. conference,
  633. id: participantId,
  634. localRecording: newValue
  635. }));
  636. const participantName = getParticipantDisplayName(state, participantId);
  637. dispatch(showNotification({
  638. titleKey: 'notify.somebody',
  639. title: participantName,
  640. descriptionKey: newValue ? 'notify.localRecordingStarted' : 'notify.localRecordingStopped',
  641. uid: LOCAL_RECORDING_NOTIFICATION_ID
  642. }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  643. dispatch(playSound(newValue ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
  644. }
  645. /**
  646. * Handles a raise hand status update.
  647. *
  648. * @param {Function} dispatch - The Redux dispatch function.
  649. * @param {Object} conference - The conference for which we got an update.
  650. * @param {string} participantId - The ID of the participant from which we got an update.
  651. * @param {boolean} newValue - The new value of the raise hand status.
  652. * @returns {void}
  653. */
  654. function _raiseHandUpdated({ dispatch, getState }: IStore, conference: IJitsiConference,
  655. participantId: string, newValue: string | number) {
  656. let raisedHandTimestamp;
  657. switch (newValue) {
  658. case undefined:
  659. case 'false':
  660. raisedHandTimestamp = 0;
  661. break;
  662. case 'true':
  663. raisedHandTimestamp = Date.now();
  664. break;
  665. default:
  666. raisedHandTimestamp = parseInt(`${newValue}`, 10);
  667. }
  668. const state = getState();
  669. dispatch(participantUpdated({
  670. conference,
  671. id: participantId,
  672. raisedHandTimestamp
  673. }));
  674. dispatch(raiseHandUpdateQueue({
  675. id: participantId,
  676. raisedHandTimestamp
  677. }));
  678. if (typeof APP !== 'undefined') {
  679. APP.API.notifyRaiseHandUpdated(participantId, raisedHandTimestamp);
  680. }
  681. const isModerator = isLocalParticipantModerator(state);
  682. const participant = getParticipantById(state, participantId);
  683. let shouldDisplayAllowAudio = false;
  684. let shouldDisplayAllowVideo = false;
  685. if (isModerator) {
  686. shouldDisplayAllowAudio = isForceMuted(participant, MEDIA_TYPE.AUDIO, state);
  687. shouldDisplayAllowVideo = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
  688. }
  689. let action;
  690. if (shouldDisplayAllowAudio || shouldDisplayAllowVideo) {
  691. action = {
  692. customActionNameKey: [] as string[],
  693. customActionHandler: [] as Function[]
  694. };
  695. if (shouldDisplayAllowAudio) {
  696. action.customActionNameKey.push('notify.allowAudio');
  697. action.customActionHandler.push(() => {
  698. dispatch(approveParticipantAudio(participantId));
  699. dispatch(hideNotification(RAISE_HAND_NOTIFICATION_ID));
  700. });
  701. }
  702. if (shouldDisplayAllowVideo) {
  703. action.customActionNameKey.push('notify.allowVideo');
  704. action.customActionHandler.push(() => {
  705. dispatch(approveParticipantVideo(participantId));
  706. dispatch(hideNotification(RAISE_HAND_NOTIFICATION_ID));
  707. });
  708. }
  709. if (shouldDisplayAllowAudio && shouldDisplayAllowVideo) {
  710. action.customActionNameKey.push('notify.allowBoth');
  711. action.customActionHandler.push(() => {
  712. dispatch(approveParticipant(participantId));
  713. dispatch(hideNotification(RAISE_HAND_NOTIFICATION_ID));
  714. });
  715. }
  716. } else {
  717. action = {
  718. customActionNameKey: [ 'notify.viewParticipants' ],
  719. customActionHandler: [ () => dispatch(openParticipantsPane()) ]
  720. };
  721. }
  722. if (raisedHandTimestamp) {
  723. let notificationTitle;
  724. const participantName = getParticipantDisplayName(state, participantId);
  725. const { raisedHandsQueue } = state['features/base/participants'];
  726. if (raisedHandsQueue.length > 1) {
  727. const raisedHands = raisedHandsQueue.length - 1;
  728. notificationTitle = i18n.t('notify.raisedHands', {
  729. participantName,
  730. raisedHands
  731. });
  732. } else {
  733. notificationTitle = participantName;
  734. }
  735. dispatch(showNotification({
  736. titleKey: 'notify.somebody',
  737. title: notificationTitle,
  738. descriptionKey: 'notify.raisedHand',
  739. concatText: true,
  740. uid: RAISE_HAND_NOTIFICATION_ID,
  741. ...action
  742. }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
  743. dispatch(playSound(RAISE_HAND_SOUND_ID));
  744. }
  745. }
  746. /**
  747. * Registers sounds related with the participants feature.
  748. *
  749. * @param {Store} store - The redux store.
  750. * @private
  751. * @returns {void}
  752. */
  753. function _registerSounds({ dispatch }: IStore) {
  754. dispatch(
  755. registerSound(PARTICIPANT_JOINED_SOUND_ID, PARTICIPANT_JOINED_FILE));
  756. dispatch(registerSound(PARTICIPANT_LEFT_SOUND_ID, PARTICIPANT_LEFT_FILE));
  757. }
  758. /**
  759. * Unregisters sounds related with the participants feature.
  760. *
  761. * @param {Store} store - The redux store.
  762. * @private
  763. * @returns {void}
  764. */
  765. function _unregisterSounds({ dispatch }: IStore) {
  766. dispatch(unregisterSound(PARTICIPANT_JOINED_SOUND_ID));
  767. dispatch(unregisterSound(PARTICIPANT_LEFT_SOUND_ID));
  768. }