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 8.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { differenceWith, isEqual } from 'lodash-es';
  2. import { IStore } from '../app/types';
  3. import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
  4. import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
  5. import { pinParticipant } from '../base/participants/actions';
  6. import { getParticipantById, getPinnedParticipant } from '../base/participants/functions';
  7. import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
  8. import { updateSettings } from '../base/settings/actions';
  9. import { addStageParticipant, removeStageParticipant, setFilmstripVisible } from '../filmstrip/actions';
  10. import { setTileView } from '../video-layout/actions.any';
  11. import {
  12. setFollowMeModerator,
  13. setFollowMeState
  14. } from './actions';
  15. import { FOLLOW_ME_COMMAND } from './constants';
  16. import { isFollowMeActive } from './functions';
  17. import logger from './logger';
  18. import './subscriber';
  19. /**
  20. * The timeout after which a follow-me command that has been received will be
  21. * ignored if not consumed.
  22. *
  23. * @type {number} in seconds
  24. * @private
  25. */
  26. const _FOLLOW_ME_RECEIVED_TIMEOUT = 30;
  27. /**
  28. * An instance of a timeout used as a workaround when attempting to pin a
  29. * non-existent particapant, which may be caused by participant join information
  30. * not being received yet.
  31. *
  32. * @type {TimeoutID}
  33. */
  34. let nextOnStageTimeout: number;
  35. /**
  36. * A count of how many seconds the nextOnStageTimeout has ticked while waiting
  37. * for a participant to be discovered that should be pinned. This variable
  38. * works in conjunction with {@code _FOLLOW_ME_RECEIVED_TIMEOUT} and
  39. * {@code nextOnStageTimeout}.
  40. *
  41. * @type {number}
  42. */
  43. let nextOnStageTimer = 0;
  44. /**
  45. * Represents "Follow Me" feature which enables a moderator to (partially)
  46. * control the user experience/interface (e.g. Filmstrip visibility) of (other)
  47. * non-moderator participant.
  48. */
  49. MiddlewareRegistry.register(store => next => action => {
  50. switch (action.type) {
  51. case CONFERENCE_JOIN_IN_PROGRESS: {
  52. const { conference } = action;
  53. conference.addCommandListener(
  54. FOLLOW_ME_COMMAND, ({ attributes }: any, id: string) => {
  55. _onFollowMeCommand(attributes, id, store);
  56. });
  57. break;
  58. }
  59. case PARTICIPANT_LEFT:
  60. if (store.getState()['features/follow-me'].moderator === action.participant.id) {
  61. store.dispatch(setFollowMeModerator());
  62. }
  63. break;
  64. }
  65. return next(action);
  66. });
  67. /**
  68. * Notifies this instance about a "Follow Me" command received by the Jitsi
  69. * conference.
  70. *
  71. * @param {Object} attributes - The attributes carried by the command.
  72. * @param {string} id - The identifier of the participant who issuing the
  73. * command. A notable idiosyncrasy to be mindful of here is that the command
  74. * may be issued by the local participant.
  75. * @param {Object} store - The redux store. Used to calculate and dispatch
  76. * updates.
  77. * @private
  78. * @returns {void}
  79. */
  80. function _onFollowMeCommand(attributes: any = {}, id: string, store: IStore) {
  81. const state = store.getState();
  82. // We require to know who issued the command because (1) only a
  83. // moderator is allowed to send commands and (2) a command MUST be
  84. // issued by a defined commander.
  85. if (typeof id === 'undefined') {
  86. return;
  87. }
  88. const participantSendingCommand = getParticipantById(state, id);
  89. if (participantSendingCommand) {
  90. // The Command(s) API will send us our own commands and we don't want
  91. // to act upon them.
  92. if (participantSendingCommand.local) {
  93. return;
  94. }
  95. if (participantSendingCommand.role !== 'moderator') {
  96. logger.warn('Received follow-me command not from moderator');
  97. return;
  98. }
  99. } else {
  100. // This is the case of jibri receiving commands from a hidden participant.
  101. const { iAmRecorder } = state['features/base/config'];
  102. const { conference } = state['features/base/conference'];
  103. // As this participant is not stored in redux store we do the checks on the JitsiParticipant from lib-jitsi-meet
  104. const participant = conference?.getParticipantById(id);
  105. if (!iAmRecorder || !participant || participant.getRole() !== 'moderator'
  106. || !participant.isHiddenFromRecorder()) {
  107. logger.warn('Something went wrong with follow-me command');
  108. return;
  109. }
  110. }
  111. if (!isFollowMeActive(state)) {
  112. store.dispatch(setFollowMeModerator(id, attributes.recorder));
  113. }
  114. // just a command that follow me was turned off
  115. if (attributes.off) {
  116. store.dispatch(setFollowMeModerator());
  117. return;
  118. }
  119. // when recorder flag is on, follow me is handled only on recorder side
  120. if (attributes.recorder && !store.getState()['features/base/config'].iAmRecorder) {
  121. return;
  122. }
  123. const oldState = state['features/follow-me'].state || {};
  124. store.dispatch(setFollowMeState(attributes));
  125. // XMPP will translate all booleans to strings, so explicitly check against
  126. // the string form of the boolean {@code true}.
  127. if (oldState.filmstripVisible !== attributes.filmstripVisible) {
  128. store.dispatch(setFilmstripVisible(attributes.filmstripVisible === 'true'));
  129. }
  130. if (oldState.tileViewEnabled !== attributes.tileViewEnabled) {
  131. store.dispatch(setTileView(attributes.tileViewEnabled === 'true'));
  132. }
  133. // For now gate etherpad checks behind a web-app check to be extra safe
  134. // against calling a web-app global.
  135. if (typeof APP !== 'undefined'
  136. && oldState.sharedDocumentVisible !== attributes.sharedDocumentVisible) {
  137. const isEtherpadVisible = attributes.sharedDocumentVisible === 'true';
  138. const documentManager = APP.UI.getSharedDocumentManager();
  139. if (documentManager
  140. && isEtherpadVisible !== state['features/etherpad'].editing) {
  141. documentManager.toggleEtherpad();
  142. }
  143. }
  144. const pinnedParticipant = getPinnedParticipant(state);
  145. const idOfParticipantToPin = attributes.nextOnStage;
  146. if (typeof idOfParticipantToPin !== 'undefined'
  147. && (!pinnedParticipant || idOfParticipantToPin !== pinnedParticipant.id)
  148. && oldState.nextOnStage !== attributes.nextOnStage) {
  149. _pinVideoThumbnailById(store, idOfParticipantToPin);
  150. } else if (typeof idOfParticipantToPin === 'undefined' && pinnedParticipant) {
  151. store.dispatch(pinParticipant(null));
  152. }
  153. if (attributes.pinnedStageParticipants !== undefined) {
  154. const stageParticipants = JSON.parse(attributes.pinnedStageParticipants);
  155. let oldStageParticipants = [];
  156. if (oldState.pinnedStageParticipants !== undefined) {
  157. oldStageParticipants = JSON.parse(oldState.pinnedStageParticipants);
  158. }
  159. if (!isEqual(stageParticipants, oldStageParticipants)) {
  160. const toRemove = differenceWith(oldStageParticipants, stageParticipants, isEqual);
  161. const toAdd = differenceWith(stageParticipants, oldStageParticipants, isEqual);
  162. toRemove.forEach((p: { participantId: string; }) =>
  163. store.dispatch(removeStageParticipant(p.participantId)));
  164. toAdd.forEach((p: { participantId: string; }) =>
  165. store.dispatch(addStageParticipant(p.participantId, true)));
  166. }
  167. }
  168. if (attributes.maxStageParticipants !== undefined
  169. && oldState.maxStageParticipants !== attributes.maxStageParticipants) {
  170. store.dispatch(updateSettings({
  171. maxStageParticipants: Number(attributes.maxStageParticipants)
  172. }));
  173. }
  174. }
  175. /**
  176. * Pins the video thumbnail given by clickId.
  177. *
  178. * @param {Object} store - The redux store.
  179. * @param {string} clickId - The identifier of the participant to pin.
  180. * @private
  181. * @returns {void}
  182. */
  183. function _pinVideoThumbnailById(store: IStore, clickId: string) {
  184. if (getParticipantById(store.getState(), clickId)) {
  185. clearTimeout(nextOnStageTimeout);
  186. nextOnStageTimer = 0;
  187. store.dispatch(pinParticipant(clickId));
  188. } else {
  189. nextOnStageTimeout = window.setTimeout(() => {
  190. if (nextOnStageTimer > _FOLLOW_ME_RECEIVED_TIMEOUT) {
  191. nextOnStageTimer = 0;
  192. return;
  193. }
  194. nextOnStageTimer++;
  195. _pinVideoThumbnailById(store, clickId);
  196. }, 1000);
  197. }
  198. }