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.

reducer.ts 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. import { AnyAction } from 'redux';
  2. import { MEDIA_TYPE } from '../media/constants';
  3. import ReducerRegistry from '../redux/ReducerRegistry';
  4. import { set } from '../redux/functions';
  5. import {
  6. DOMINANT_SPEAKER_CHANGED,
  7. OVERWRITE_PARTICIPANT_NAME,
  8. PARTICIPANT_ID_CHANGED,
  9. PARTICIPANT_JOINED,
  10. PARTICIPANT_LEFT,
  11. PARTICIPANT_SOURCES_UPDATED,
  12. PARTICIPANT_UPDATED,
  13. PIN_PARTICIPANT,
  14. RAISE_HAND_UPDATED,
  15. SCREENSHARE_PARTICIPANT_NAME_CHANGED,
  16. SET_LOADABLE_AVATAR_URL
  17. } from './actionTypes';
  18. import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
  19. import {
  20. isLocalScreenshareParticipant,
  21. isParticipantModerator,
  22. isRemoteScreenshareParticipant,
  23. isScreenShareParticipant
  24. } from './functions';
  25. import { FakeParticipant, ILocalParticipant, IParticipant, ISourceInfo } from './types';
  26. /**
  27. * Participant object.
  28. *
  29. * @typedef {Object} Participant
  30. * @property {string} id - Participant ID.
  31. * @property {string} name - Participant name.
  32. * @property {string} avatar - Path to participant avatar if any.
  33. * @property {string} role - Participant role.
  34. * @property {boolean} local - If true, participant is local.
  35. * @property {boolean} pinned - If true, participant is currently a
  36. * "PINNED_ENDPOINT".
  37. * @property {boolean} dominantSpeaker - If this participant is the dominant
  38. * speaker in the (associated) conference, {@code true}; otherwise,
  39. * {@code false}.
  40. * @property {string} email - Participant email.
  41. */
  42. /**
  43. * The participant properties which cannot be updated through
  44. * {@link PARTICIPANT_UPDATED}. They either identify the participant or can only
  45. * be modified through property-dedicated actions.
  46. *
  47. * @type {string[]}
  48. */
  49. const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
  50. // The following properties identify the participant:
  51. 'conference',
  52. 'id',
  53. 'local',
  54. // The following properties can only be modified through property-dedicated
  55. // actions:
  56. 'dominantSpeaker',
  57. 'pinned'
  58. ];
  59. const DEFAULT_STATE = {
  60. dominantSpeaker: undefined,
  61. fakeParticipants: new Map(),
  62. local: undefined,
  63. localScreenShare: undefined,
  64. numberOfNonModeratorParticipants: 0,
  65. numberOfParticipantsDisabledE2EE: 0,
  66. numberOfParticipantsNotSupportingE2EE: 0,
  67. overwrittenNameList: {},
  68. pinnedParticipant: undefined,
  69. raisedHandsQueue: [],
  70. remote: new Map(),
  71. remoteVideoSources: new Set<string>(),
  72. sortedRemoteVirtualScreenshareParticipants: new Map(),
  73. sortedRemoteParticipants: new Map(),
  74. speakersList: new Map()
  75. };
  76. export interface IParticipantsState {
  77. dominantSpeaker?: string;
  78. fakeParticipants: Map<string, IParticipant>;
  79. local?: ILocalParticipant;
  80. localScreenShare?: IParticipant;
  81. numberOfNonModeratorParticipants: number;
  82. numberOfParticipantsDisabledE2EE: number;
  83. numberOfParticipantsNotSupportingE2EE: number;
  84. overwrittenNameList: { [id: string]: string; };
  85. pinnedParticipant?: string;
  86. raisedHandsQueue: Array<{ id: string; raisedHandTimestamp: number; }>;
  87. remote: Map<string, IParticipant>;
  88. remoteVideoSources: Set<string>;
  89. sortedRemoteParticipants: Map<string, string>;
  90. sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
  91. speakersList: Map<string, string>;
  92. }
  93. /**
  94. * Listen for actions which add, remove, or update the set of participants in
  95. * the conference.
  96. *
  97. * @param {IParticipant[]} state - List of participants to be modified.
  98. * @param {Object} action - Action object.
  99. * @param {string} action.type - Type of action.
  100. * @param {IParticipant} action.participant - Information about participant to be
  101. * added/removed/modified.
  102. * @returns {IParticipant[]}
  103. */
  104. ReducerRegistry.register<IParticipantsState>('features/base/participants',
  105. (state = DEFAULT_STATE, action): IParticipantsState => {
  106. switch (action.type) {
  107. case PARTICIPANT_ID_CHANGED: {
  108. const { local } = state;
  109. if (local) {
  110. if (action.newValue === 'local' && state.raisedHandsQueue.find(pid => pid.id === local.id)) {
  111. state.raisedHandsQueue = state.raisedHandsQueue.filter(pid => pid.id !== local.id);
  112. }
  113. state.local = {
  114. ...local,
  115. id: action.newValue
  116. };
  117. return {
  118. ...state
  119. };
  120. }
  121. return state;
  122. }
  123. case DOMINANT_SPEAKER_CHANGED: {
  124. const { participant } = action;
  125. const { id, previousSpeakers = [] } = participant;
  126. const { dominantSpeaker, local } = state;
  127. const newSpeakers = [ id, ...previousSpeakers ];
  128. const sortedSpeakersList: Array<Array<string>> = [];
  129. for (const speaker of newSpeakers) {
  130. if (speaker !== local?.id) {
  131. const remoteParticipant = state.remote.get(speaker);
  132. remoteParticipant
  133. && sortedSpeakersList.push(
  134. [ speaker, _getDisplayName(state, remoteParticipant?.name) ]
  135. );
  136. }
  137. }
  138. // Keep the remote speaker list sorted alphabetically.
  139. sortedSpeakersList.sort((a, b) => a[1].localeCompare(b[1]));
  140. // Only one dominant speaker is allowed.
  141. if (dominantSpeaker) {
  142. _updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
  143. }
  144. if (_updateParticipantProperty(state, id, 'dominantSpeaker', true)) {
  145. return {
  146. ...state,
  147. dominantSpeaker: id, // @ts-ignore
  148. speakersList: new Map(sortedSpeakersList)
  149. };
  150. }
  151. delete state.dominantSpeaker;
  152. return {
  153. ...state
  154. };
  155. }
  156. case PIN_PARTICIPANT: {
  157. const { participant } = action;
  158. const { id } = participant;
  159. const { pinnedParticipant } = state;
  160. // Only one pinned participant is allowed.
  161. if (pinnedParticipant) {
  162. _updateParticipantProperty(state, pinnedParticipant, 'pinned', false);
  163. }
  164. if (id && _updateParticipantProperty(state, id, 'pinned', true)) {
  165. return {
  166. ...state,
  167. pinnedParticipant: id
  168. };
  169. }
  170. delete state.pinnedParticipant;
  171. return {
  172. ...state
  173. };
  174. }
  175. case SET_LOADABLE_AVATAR_URL:
  176. case PARTICIPANT_UPDATED: {
  177. const { participant } = action;
  178. let { id } = participant;
  179. const { local } = participant;
  180. if (!id && local) {
  181. id = LOCAL_PARTICIPANT_DEFAULT_ID;
  182. }
  183. let newParticipant: IParticipant | null = null;
  184. const oldParticipant = local || state.local?.id === id ? state.local : state.remote.get(id);
  185. if (state.remote.has(id)) {
  186. newParticipant = _participant(oldParticipant, action);
  187. state.remote.set(id, newParticipant);
  188. } else if (id === state.local?.id) {
  189. newParticipant = state.local = _participant(state.local, action);
  190. }
  191. if (oldParticipant && newParticipant && !newParticipant.fakeParticipant) {
  192. const isModerator = isParticipantModerator(newParticipant);
  193. if (isParticipantModerator(oldParticipant) !== isModerator) {
  194. state.numberOfNonModeratorParticipants += isModerator ? -1 : 1;
  195. }
  196. const e2eeEnabled = Boolean(newParticipant.e2eeEnabled);
  197. const e2eeSupported = Boolean(newParticipant.e2eeSupported);
  198. if (Boolean(oldParticipant.e2eeEnabled) !== e2eeEnabled) {
  199. state.numberOfParticipantsDisabledE2EE += e2eeEnabled ? -1 : 1;
  200. }
  201. if (!local && Boolean(oldParticipant.e2eeSupported) !== e2eeSupported) {
  202. state.numberOfParticipantsNotSupportingE2EE += e2eeSupported ? -1 : 1;
  203. }
  204. }
  205. return {
  206. ...state
  207. };
  208. }
  209. case SCREENSHARE_PARTICIPANT_NAME_CHANGED: {
  210. const { id, name } = action;
  211. if (state.sortedRemoteVirtualScreenshareParticipants.has(id)) {
  212. state.sortedRemoteVirtualScreenshareParticipants.delete(id);
  213. const sortedRemoteVirtualScreenshareParticipants = [ ...state.sortedRemoteVirtualScreenshareParticipants ];
  214. sortedRemoteVirtualScreenshareParticipants.push([ id, name ]);
  215. sortedRemoteVirtualScreenshareParticipants.sort((a, b) => a[1].localeCompare(b[1]));
  216. state.sortedRemoteVirtualScreenshareParticipants = new Map(sortedRemoteVirtualScreenshareParticipants);
  217. }
  218. return { ...state };
  219. }
  220. case PARTICIPANT_JOINED: {
  221. const participant = _participantJoined(action);
  222. const {
  223. fakeParticipant,
  224. id,
  225. name,
  226. pinned,
  227. sources
  228. } = participant;
  229. const { pinnedParticipant, dominantSpeaker } = state;
  230. if (pinned) {
  231. if (pinnedParticipant) {
  232. _updateParticipantProperty(state, pinnedParticipant, 'pinned', false);
  233. }
  234. state.pinnedParticipant = id;
  235. }
  236. if (participant.dominantSpeaker) {
  237. if (dominantSpeaker) {
  238. _updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
  239. }
  240. state.dominantSpeaker = id;
  241. }
  242. if (!fakeParticipant) {
  243. const isModerator = isParticipantModerator(participant);
  244. if (!isModerator) {
  245. state.numberOfNonModeratorParticipants += 1;
  246. }
  247. const { e2eeEnabled, e2eeSupported } = participant as IParticipant;
  248. if (!e2eeEnabled) {
  249. state.numberOfParticipantsDisabledE2EE += 1;
  250. }
  251. if (!participant.local && !e2eeSupported) {
  252. state.numberOfParticipantsNotSupportingE2EE += 1;
  253. }
  254. }
  255. if (participant.local) {
  256. return {
  257. ...state,
  258. local: participant
  259. };
  260. }
  261. if (isLocalScreenshareParticipant(participant)) {
  262. return {
  263. ...state,
  264. localScreenShare: participant
  265. };
  266. }
  267. state.remote.set(id, participant);
  268. if (sources?.size) {
  269. const videoSources: Map<string, ISourceInfo> | undefined = sources.get(MEDIA_TYPE.VIDEO);
  270. if (videoSources?.size) {
  271. const newRemoteVideoSources = new Set(state.remoteVideoSources);
  272. for (const source of videoSources.keys()) {
  273. newRemoteVideoSources.add(source);
  274. }
  275. state.remoteVideoSources = newRemoteVideoSources;
  276. }
  277. }
  278. // Insert the new participant.
  279. const displayName = _getDisplayName(state, name);
  280. const sortedRemoteParticipants = Array.from(state.sortedRemoteParticipants);
  281. sortedRemoteParticipants.push([ id, displayName ]);
  282. sortedRemoteParticipants.sort((a, b) => a[1].localeCompare(b[1]));
  283. // The sort order of participants is preserved since Map remembers the original insertion order of the keys.
  284. state.sortedRemoteParticipants = new Map(sortedRemoteParticipants);
  285. if (isRemoteScreenshareParticipant(participant)) {
  286. const sortedRemoteVirtualScreenshareParticipants = [ ...state.sortedRemoteVirtualScreenshareParticipants ];
  287. sortedRemoteVirtualScreenshareParticipants.push([ id, name ?? '' ]);
  288. sortedRemoteVirtualScreenshareParticipants.sort((a, b) => a[1].localeCompare(b[1]));
  289. state.sortedRemoteVirtualScreenshareParticipants = new Map(sortedRemoteVirtualScreenshareParticipants);
  290. }
  291. // Exclude the screenshare participant from the fake participant count to avoid duplicates.
  292. if (fakeParticipant && !isScreenShareParticipant(participant)) {
  293. state.fakeParticipants.set(id, participant);
  294. }
  295. return { ...state };
  296. }
  297. case PARTICIPANT_LEFT: {
  298. // XXX A remote participant is uniquely identified by their id in a
  299. // specific JitsiConference instance. The local participant is uniquely
  300. // identified by the very fact that there is only one local participant
  301. // (and the fact that the local participant "joins" at the beginning of
  302. // the app and "leaves" at the end of the app).
  303. const { conference, id } = action.participant;
  304. const {
  305. fakeParticipants,
  306. sortedRemoteVirtualScreenshareParticipants,
  307. remote,
  308. local,
  309. localScreenShare,
  310. dominantSpeaker,
  311. pinnedParticipant
  312. } = state;
  313. let oldParticipant = remote.get(id);
  314. let isLocalScreenShare = false;
  315. if (oldParticipant?.sources?.size) {
  316. const videoSources: Map<string, ISourceInfo> | undefined = oldParticipant.sources.get(MEDIA_TYPE.VIDEO);
  317. const newRemoteVideoSources = new Set(state.remoteVideoSources);
  318. if (videoSources?.size) {
  319. for (const source of videoSources.keys()) {
  320. newRemoteVideoSources.delete(source);
  321. }
  322. }
  323. state.remoteVideoSources = newRemoteVideoSources;
  324. } else if (oldParticipant?.fakeParticipant === FakeParticipant.RemoteScreenShare) {
  325. const newRemoteVideoSources = new Set(state.remoteVideoSources);
  326. newRemoteVideoSources.delete(id);
  327. state.remoteVideoSources = newRemoteVideoSources;
  328. }
  329. if (oldParticipant && oldParticipant.conference === conference) {
  330. remote.delete(id);
  331. } else if (local?.id === id) {
  332. oldParticipant = state.local;
  333. delete state.local;
  334. } else if (localScreenShare?.id === id) {
  335. isLocalScreenShare = true;
  336. oldParticipant = state.local;
  337. delete state.localScreenShare;
  338. } else {
  339. // no participant found
  340. return state;
  341. }
  342. state.sortedRemoteParticipants.delete(id);
  343. state.raisedHandsQueue = state.raisedHandsQueue.filter(pid => pid.id !== id);
  344. if (dominantSpeaker === id) {
  345. state.dominantSpeaker = undefined;
  346. }
  347. // Remove the participant from the list of speakers.
  348. state.speakersList.has(id) && state.speakersList.delete(id);
  349. if (pinnedParticipant === id) {
  350. state.pinnedParticipant = undefined;
  351. }
  352. if (fakeParticipants.has(id)) {
  353. fakeParticipants.delete(id);
  354. }
  355. if (sortedRemoteVirtualScreenshareParticipants.has(id)) {
  356. sortedRemoteVirtualScreenshareParticipants.delete(id);
  357. state.sortedRemoteVirtualScreenshareParticipants = new Map(sortedRemoteVirtualScreenshareParticipants);
  358. }
  359. if (oldParticipant && !oldParticipant.fakeParticipant && !isLocalScreenShare) {
  360. const { e2eeEnabled, e2eeSupported } = oldParticipant;
  361. if (!isParticipantModerator(oldParticipant)) {
  362. state.numberOfNonModeratorParticipants -= 1;
  363. }
  364. if (!e2eeEnabled) {
  365. state.numberOfParticipantsDisabledE2EE -= 1;
  366. }
  367. if (!oldParticipant.local && !e2eeSupported) {
  368. state.numberOfParticipantsNotSupportingE2EE -= 1;
  369. }
  370. }
  371. return { ...state };
  372. }
  373. case PARTICIPANT_SOURCES_UPDATED: {
  374. const { id, sources } = action.participant;
  375. const participant = state.remote.get(id);
  376. if (participant) {
  377. participant.sources = sources;
  378. const videoSources: Map<string, ISourceInfo> = sources.get(MEDIA_TYPE.VIDEO);
  379. if (videoSources?.size) {
  380. const newRemoteVideoSources = new Set(state.remoteVideoSources);
  381. for (const source of videoSources.keys()) {
  382. newRemoteVideoSources.add(source);
  383. }
  384. state.remoteVideoSources = newRemoteVideoSources;
  385. }
  386. }
  387. return { ...state };
  388. }
  389. case RAISE_HAND_UPDATED: {
  390. return {
  391. ...state,
  392. raisedHandsQueue: action.queue
  393. };
  394. }
  395. case OVERWRITE_PARTICIPANT_NAME: {
  396. const { id, name } = action;
  397. return {
  398. ...state,
  399. overwrittenNameList: {
  400. ...state.overwrittenNameList,
  401. [id]: name
  402. }
  403. };
  404. }
  405. }
  406. return state;
  407. });
  408. /**
  409. * Returns the participant's display name, default string if display name is not set on the participant.
  410. *
  411. * @param {Object} state - The local participant redux state.
  412. * @param {string} name - The display name of the participant.
  413. * @returns {string}
  414. */
  415. function _getDisplayName(state: Object, name?: string): string {
  416. // @ts-ignore
  417. const config = state['features/base/config'];
  418. return name ?? (config?.defaultRemoteDisplayName || 'Fellow Jitster');
  419. }
  420. /**
  421. * Reducer function for a single participant.
  422. *
  423. * @param {IParticipant|undefined} state - Participant to be modified.
  424. * @param {Object} action - Action object.
  425. * @param {string} action.type - Type of action.
  426. * @param {IParticipant} action.participant - Information about participant to be
  427. * added/modified.
  428. * @param {JitsiConference} action.conference - Conference instance.
  429. * @private
  430. * @returns {IParticipant}
  431. */
  432. function _participant(state: IParticipant | ILocalParticipant = { id: '' },
  433. action: AnyAction): IParticipant | ILocalParticipant {
  434. switch (action.type) {
  435. case SET_LOADABLE_AVATAR_URL:
  436. case PARTICIPANT_UPDATED: {
  437. const { participant } = action; // eslint-disable-line no-shadow
  438. const newState = { ...state };
  439. for (const key in participant) {
  440. if (participant.hasOwnProperty(key)
  441. && PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE.indexOf(key)
  442. === -1) {
  443. // @ts-ignore
  444. newState[key] = participant[key];
  445. }
  446. }
  447. return newState;
  448. }
  449. }
  450. return state;
  451. }
  452. /**
  453. * Reduces a specific redux action of type {@link PARTICIPANT_JOINED} in the
  454. * feature base/participants.
  455. *
  456. * @param {Action} action - The redux action of type {@code PARTICIPANT_JOINED}
  457. * to reduce.
  458. * @private
  459. * @returns {Object} The new participant derived from the payload of the
  460. * specified {@code action} to be added into the redux state of the feature
  461. * base/participants after the reduction of the specified
  462. * {@code action}.
  463. */
  464. function _participantJoined({ participant }: { participant: IParticipant; }) {
  465. const {
  466. avatarURL,
  467. botType,
  468. dominantSpeaker,
  469. email,
  470. fakeParticipant,
  471. isReplacing,
  472. loadableAvatarUrl,
  473. local,
  474. name,
  475. pinned,
  476. presence,
  477. role,
  478. sources
  479. } = participant;
  480. let { conference, id } = participant;
  481. if (local) {
  482. // conference
  483. //
  484. // XXX The local participant is not identified in association with a
  485. // JitsiConference because it is identified by the very fact that it is
  486. // the local participant.
  487. conference = undefined;
  488. // id
  489. id || (id = LOCAL_PARTICIPANT_DEFAULT_ID);
  490. }
  491. return {
  492. avatarURL,
  493. botType,
  494. conference,
  495. dominantSpeaker: dominantSpeaker || false,
  496. email,
  497. fakeParticipant,
  498. id,
  499. isReplacing,
  500. loadableAvatarUrl,
  501. local: local || false,
  502. name,
  503. pinned: pinned || false,
  504. presence,
  505. role: role || PARTICIPANT_ROLE.NONE,
  506. sources
  507. };
  508. }
  509. /**
  510. * Updates a specific property for a participant.
  511. *
  512. * @param {State} state - The redux state.
  513. * @param {string} id - The ID of the participant.
  514. * @param {string} property - The property to update.
  515. * @param {*} value - The new value.
  516. * @returns {boolean} - True if a participant was updated and false otherwise.
  517. */
  518. function _updateParticipantProperty(state: IParticipantsState, id: string, property: string, value: boolean) {
  519. const { remote, local, localScreenShare } = state;
  520. if (remote.has(id)) {
  521. remote.set(id, set(remote.get(id) ?? {
  522. id: '',
  523. name: ''
  524. }, property as keyof IParticipant, value));
  525. return true;
  526. } else if (local?.id === id || local?.id === 'local') {
  527. // The local participant's ID can chance from something to "local" when
  528. // not in a conference.
  529. state.local = set(local, property as keyof ILocalParticipant, value);
  530. return true;
  531. } else if (localScreenShare?.id === id) {
  532. state.localScreenShare = set(localScreenShare, property as keyof IParticipant, value);
  533. return true;
  534. }
  535. return false;
  536. }