Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

reducer.ts 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. import { merge, union } from 'lodash-es';
  2. import { CONFERENCE_INFO } from '../../conference/components/constants';
  3. import { TOOLBAR_BUTTONS } from '../../toolbox/constants';
  4. import { ToolbarButton } from '../../toolbox/types';
  5. import { CONNECTION_PROPERTIES_UPDATED } from '../connection/actionTypes';
  6. import ReducerRegistry from '../redux/ReducerRegistry';
  7. import { equals } from '../redux/functions';
  8. import {
  9. CONFIG_WILL_LOAD,
  10. LOAD_CONFIG_ERROR,
  11. OVERWRITE_CONFIG,
  12. SET_CONFIG,
  13. UPDATE_CONFIG
  14. } from './actionTypes';
  15. import {
  16. IConfig,
  17. IDeeplinkingConfig,
  18. IDeeplinkingDesktopConfig,
  19. IDeeplinkingMobileConfig
  20. } from './configType';
  21. import { _cleanupConfig, _setDeeplinkingDefaults } from './functions';
  22. /**
  23. * The initial state of the feature base/config when executing in a
  24. * non-React Native environment. The mandatory configuration to be passed to
  25. * JitsiMeetJS#init(). The app will download config.js from the Jitsi Meet
  26. * deployment and take its values into account but the values below will be
  27. * enforced (because they are essential to the correct execution of the
  28. * application).
  29. *
  30. * @type {Object}
  31. */
  32. const INITIAL_NON_RN_STATE: IConfig = {
  33. };
  34. /**
  35. * The initial state of the feature base/config when executing in a React Native
  36. * environment. The mandatory configuration to be passed to JitsiMeetJS#init().
  37. * The app will download config.js from the Jitsi Meet deployment and take its
  38. * values into account but the values below will be enforced (because they are
  39. * essential to the correct execution of the application).
  40. *
  41. * @type {Object}
  42. */
  43. const INITIAL_RN_STATE: IConfig = {
  44. };
  45. /**
  46. * Mapping between old configs controlling the conference info headers visibility and the
  47. * new configs. Needed in order to keep backwards compatibility.
  48. */
  49. const CONFERENCE_HEADER_MAPPING = {
  50. hideConferenceTimer: [ 'conference-timer' ],
  51. hideConferenceSubject: [ 'subject' ],
  52. hideParticipantsStats: [ 'participants-count' ],
  53. hideRecordingLabel: [ 'recording' ]
  54. };
  55. export interface IConfigState extends IConfig {
  56. analysis?: {
  57. obfuscateRoomName?: boolean;
  58. };
  59. error?: Error;
  60. oldConfig?: {
  61. bosh?: string;
  62. focusUserJid?: string;
  63. hosts: {
  64. domain: string;
  65. muc: string;
  66. };
  67. p2p?: object;
  68. websocket?: string;
  69. };
  70. }
  71. ReducerRegistry.register<IConfigState>('features/base/config', (state = _getInitialState(), action): IConfigState => {
  72. switch (action.type) {
  73. case UPDATE_CONFIG:
  74. return _updateConfig(state, action);
  75. case CONFIG_WILL_LOAD:
  76. return {
  77. error: undefined,
  78. /**
  79. * The URL of the location associated with/configured by this
  80. * configuration.
  81. *
  82. * @type URL
  83. */
  84. locationURL: action.locationURL
  85. };
  86. case CONNECTION_PROPERTIES_UPDATED: {
  87. const { region, shard } = action.properties;
  88. const { deploymentInfo } = state;
  89. if (deploymentInfo?.region === region && deploymentInfo?.shard === shard) {
  90. return state;
  91. }
  92. return {
  93. ...state,
  94. deploymentInfo: JSON.parse(JSON.stringify({
  95. ...deploymentInfo,
  96. region,
  97. shard
  98. }))
  99. };
  100. }
  101. case LOAD_CONFIG_ERROR:
  102. // XXX LOAD_CONFIG_ERROR is one of the settlement execution paths of
  103. // the asynchronous "loadConfig procedure/process" started with
  104. // CONFIG_WILL_LOAD. Due to the asynchronous nature of it, whoever
  105. // is settling the process needs to provide proof that they have
  106. // started it and that the iteration of the process being completed
  107. // now is still of interest to the app.
  108. if (state.locationURL === action.locationURL) {
  109. return {
  110. /**
  111. * The {@link Error} which prevented the loading of the
  112. * configuration of the associated {@code locationURL}.
  113. *
  114. * @type Error
  115. */
  116. error: action.error
  117. };
  118. }
  119. break;
  120. case SET_CONFIG:
  121. return _setConfig(state, action);
  122. case OVERWRITE_CONFIG:
  123. return {
  124. ...state,
  125. ...action.config
  126. };
  127. }
  128. return state;
  129. });
  130. /**
  131. * Gets the initial state of the feature base/config. The mandatory
  132. * configuration to be passed to JitsiMeetJS#init(). The app will download
  133. * config.js from the Jitsi Meet deployment and take its values into account but
  134. * the values below will be enforced (because they are essential to the correct
  135. * execution of the application).
  136. *
  137. * @returns {Object}
  138. */
  139. function _getInitialState() {
  140. return (
  141. navigator.product === 'ReactNative'
  142. ? INITIAL_RN_STATE
  143. : INITIAL_NON_RN_STATE);
  144. }
  145. /**
  146. * Reduces a specific Redux action SET_CONFIG of the feature
  147. * base/lib-jitsi-meet.
  148. *
  149. * @param {IConfig} state - The Redux state of the feature base/config.
  150. * @param {Action} action - The Redux action SET_CONFIG to reduce.
  151. * @private
  152. * @returns {Object} The new state after the reduction of the specified action.
  153. */
  154. function _setConfig(state: IConfig, { config }: { config: IConfig; }) {
  155. // eslint-disable-next-line no-param-reassign
  156. config = _translateLegacyConfig(config);
  157. const { audioQuality } = config;
  158. const hdAudioOptions = {};
  159. if (audioQuality?.stereo) {
  160. Object.assign(hdAudioOptions, {
  161. disableAP: true,
  162. enableNoAudioDetection: false,
  163. enableNoisyMicDetection: false,
  164. enableTalkWhileMuted: false
  165. });
  166. }
  167. const newState = merge(
  168. {},
  169. config,
  170. hdAudioOptions,
  171. { error: undefined },
  172. // The config of _getInitialState() is meant to override the config
  173. // downloaded from the Jitsi Meet deployment because the former contains
  174. // values that are mandatory.
  175. _getInitialState()
  176. );
  177. _cleanupConfig(newState);
  178. return equals(state, newState) ? state : newState;
  179. }
  180. /**
  181. * Processes the conferenceInfo object against the defaults.
  182. *
  183. * @param {IConfig} config - The old config.
  184. * @returns {Object} The processed conferenceInfo object.
  185. */
  186. function _getConferenceInfo(config: IConfig) {
  187. const { conferenceInfo } = config;
  188. if (conferenceInfo) {
  189. return {
  190. alwaysVisible: conferenceInfo.alwaysVisible ?? [ ...CONFERENCE_INFO.alwaysVisible ],
  191. autoHide: conferenceInfo.autoHide ?? [ ...CONFERENCE_INFO.autoHide ]
  192. };
  193. }
  194. return {
  195. ...CONFERENCE_INFO
  196. };
  197. }
  198. /**
  199. * Constructs a new config {@code Object}, if necessary, out of a specific
  200. * interface_config {@code Object} which is in the latest format supported by jitsi-meet.
  201. *
  202. * @param {Object} oldValue - The config {@code Object} which may or may not be
  203. * in the latest form supported by jitsi-meet and from which a new config
  204. * {@code Object} is to be constructed if necessary.
  205. * @returns {Object} A config {@code Object} which is in the latest format
  206. * supported by jitsi-meet.
  207. */
  208. function _translateInterfaceConfig(oldValue: IConfig) {
  209. const newValue = oldValue;
  210. if (!Array.isArray(oldValue.toolbarButtons)
  211. && typeof interfaceConfig === 'object' && Array.isArray(interfaceConfig.TOOLBAR_BUTTONS)) {
  212. newValue.toolbarButtons = interfaceConfig.TOOLBAR_BUTTONS;
  213. }
  214. if (!oldValue.toolbarConfig) {
  215. oldValue.toolbarConfig = {};
  216. }
  217. newValue.toolbarConfig = oldValue.toolbarConfig || {};
  218. if (typeof oldValue.toolbarConfig.alwaysVisible !== 'boolean'
  219. && typeof interfaceConfig === 'object'
  220. && typeof interfaceConfig.TOOLBAR_ALWAYS_VISIBLE === 'boolean') {
  221. newValue.toolbarConfig.alwaysVisible = interfaceConfig.TOOLBAR_ALWAYS_VISIBLE;
  222. }
  223. if (typeof oldValue.toolbarConfig.initialTimeout !== 'number'
  224. && typeof interfaceConfig === 'object'
  225. && typeof interfaceConfig.INITIAL_TOOLBAR_TIMEOUT === 'number') {
  226. newValue.toolbarConfig.initialTimeout = interfaceConfig.INITIAL_TOOLBAR_TIMEOUT;
  227. }
  228. if (typeof oldValue.toolbarConfig.timeout !== 'number'
  229. && typeof interfaceConfig === 'object'
  230. && typeof interfaceConfig.TOOLBAR_TIMEOUT === 'number') {
  231. newValue.toolbarConfig.timeout = interfaceConfig.TOOLBAR_TIMEOUT;
  232. }
  233. if (!oldValue.connectionIndicators
  234. && typeof interfaceConfig === 'object'
  235. && (interfaceConfig.hasOwnProperty('CONNECTION_INDICATOR_DISABLED')
  236. || interfaceConfig.hasOwnProperty('CONNECTION_INDICATOR_AUTO_HIDE_ENABLED')
  237. || interfaceConfig.hasOwnProperty('CONNECTION_INDICATOR_AUTO_HIDE_TIMEOUT'))) {
  238. newValue.connectionIndicators = {
  239. disabled: interfaceConfig.CONNECTION_INDICATOR_DISABLED,
  240. autoHide: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED,
  241. autoHideTimeout: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_TIMEOUT
  242. };
  243. }
  244. if (oldValue.disableModeratorIndicator === undefined
  245. && typeof interfaceConfig === 'object'
  246. && interfaceConfig.hasOwnProperty('DISABLE_FOCUS_INDICATOR')) {
  247. newValue.disableModeratorIndicator = interfaceConfig.DISABLE_FOCUS_INDICATOR;
  248. }
  249. if (oldValue.defaultLocalDisplayName === undefined
  250. && typeof interfaceConfig === 'object'
  251. && interfaceConfig.hasOwnProperty('DEFAULT_LOCAL_DISPLAY_NAME')) {
  252. newValue.defaultLocalDisplayName = interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME;
  253. }
  254. if (oldValue.defaultRemoteDisplayName === undefined
  255. && typeof interfaceConfig === 'object'
  256. && interfaceConfig.hasOwnProperty('DEFAULT_REMOTE_DISPLAY_NAME')) {
  257. newValue.defaultRemoteDisplayName = interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME;
  258. }
  259. if (oldValue.defaultLogoUrl === undefined) {
  260. if (typeof interfaceConfig === 'object'
  261. && interfaceConfig.hasOwnProperty('DEFAULT_LOGO_URL')) {
  262. newValue.defaultLogoUrl = interfaceConfig.DEFAULT_LOGO_URL;
  263. } else {
  264. newValue.defaultLogoUrl = 'images/watermark.svg';
  265. }
  266. }
  267. // if we have `deeplinking` defined, ignore deprecated values, except `disableDeepLinking`.
  268. // Otherwise, compose the config.
  269. if (oldValue.deeplinking && newValue.deeplinking) { // make TS happy
  270. newValue.deeplinking.disabled = oldValue.deeplinking.hasOwnProperty('disabled')
  271. ? oldValue.deeplinking.disabled
  272. : Boolean(oldValue.disableDeepLinking);
  273. } else {
  274. const disabled = Boolean(oldValue.disableDeepLinking);
  275. const deeplinking: IDeeplinkingConfig = {
  276. desktop: {} as IDeeplinkingDesktopConfig,
  277. hideLogo: false,
  278. disabled,
  279. android: {} as IDeeplinkingMobileConfig,
  280. ios: {} as IDeeplinkingMobileConfig
  281. };
  282. if (typeof interfaceConfig === 'object') {
  283. if (deeplinking.desktop) {
  284. deeplinking.desktop.appName = interfaceConfig.NATIVE_APP_NAME;
  285. }
  286. deeplinking.hideLogo = Boolean(interfaceConfig.HIDE_DEEP_LINKING_LOGO);
  287. deeplinking.android = {
  288. appName: interfaceConfig.NATIVE_APP_NAME,
  289. appScheme: interfaceConfig.APP_SCHEME,
  290. downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_ANDROID,
  291. appPackage: interfaceConfig.ANDROID_APP_PACKAGE,
  292. fDroidUrl: interfaceConfig.MOBILE_DOWNLOAD_LINK_F_DROID
  293. };
  294. deeplinking.ios = {
  295. appName: interfaceConfig.NATIVE_APP_NAME,
  296. appScheme: interfaceConfig.APP_SCHEME,
  297. downloadLink: interfaceConfig.MOBILE_DOWNLOAD_LINK_IOS
  298. };
  299. }
  300. newValue.deeplinking = deeplinking;
  301. }
  302. return newValue;
  303. }
  304. /**
  305. * Constructs a new config {@code Object}, if necessary, out of a specific
  306. * config {@code Object} which is in the latest format supported by jitsi-meet.
  307. * Such a translation from an old config format to a new/the latest config
  308. * format is necessary because the mobile app bundles jitsi-meet and
  309. * lib-jitsi-meet at build time and does not download them at runtime from the
  310. * deployment on which it will join a conference.
  311. *
  312. * @param {Object} oldValue - The config {@code Object} which may or may not be
  313. * in the latest form supported by jitsi-meet and from which a new config
  314. * {@code Object} is to be constructed if necessary.
  315. * @returns {Object} A config {@code Object} which is in the latest format
  316. * supported by jitsi-meet.
  317. */
  318. function _translateLegacyConfig(oldValue: IConfig) {
  319. const newValue = _translateInterfaceConfig(oldValue);
  320. // Translate deprecated config values to new config values.
  321. const filteredConferenceInfo = Object.keys(CONFERENCE_HEADER_MAPPING).filter(key => oldValue[key as keyof IConfig]);
  322. if (filteredConferenceInfo.length) {
  323. newValue.conferenceInfo = _getConferenceInfo(oldValue);
  324. filteredConferenceInfo.forEach(key => {
  325. newValue.conferenceInfo = oldValue.conferenceInfo ?? {};
  326. // hideRecordingLabel does not mean not render it at all, but autoHide it
  327. if (key === 'hideRecordingLabel') {
  328. newValue.conferenceInfo.alwaysVisible
  329. = (newValue.conferenceInfo?.alwaysVisible ?? [])
  330. .filter(c => !CONFERENCE_HEADER_MAPPING[key].includes(c));
  331. newValue.conferenceInfo.autoHide
  332. = union(newValue.conferenceInfo.autoHide, CONFERENCE_HEADER_MAPPING[key]);
  333. } else {
  334. newValue.conferenceInfo.alwaysVisible
  335. = (newValue.conferenceInfo.alwaysVisible ?? [])
  336. .filter(c => !CONFERENCE_HEADER_MAPPING[key as keyof typeof CONFERENCE_HEADER_MAPPING].includes(c));
  337. newValue.conferenceInfo.autoHide
  338. = (newValue.conferenceInfo.autoHide ?? []).filter(c =>
  339. !CONFERENCE_HEADER_MAPPING[key as keyof typeof CONFERENCE_HEADER_MAPPING].includes(c));
  340. }
  341. });
  342. }
  343. newValue.welcomePage = oldValue.welcomePage || {};
  344. if (oldValue.hasOwnProperty('enableWelcomePage')
  345. && !newValue.welcomePage.hasOwnProperty('disabled')
  346. ) {
  347. newValue.welcomePage.disabled = !oldValue.enableWelcomePage;
  348. }
  349. newValue.prejoinConfig = oldValue.prejoinConfig || {};
  350. if (oldValue.hasOwnProperty('prejoinPageEnabled')
  351. && !newValue.prejoinConfig.hasOwnProperty('enabled')
  352. ) {
  353. newValue.prejoinConfig.enabled = oldValue.prejoinPageEnabled;
  354. }
  355. newValue.disabledSounds = newValue.disabledSounds || [];
  356. if (oldValue.disableJoinLeaveSounds) {
  357. newValue.disabledSounds.unshift('PARTICIPANT_LEFT_SOUND', 'PARTICIPANT_JOINED_SOUND');
  358. }
  359. if (oldValue.disableRecordAudioNotification) {
  360. newValue.disabledSounds.unshift(
  361. 'RECORDING_ON_SOUND',
  362. 'RECORDING_OFF_SOUND',
  363. 'LIVE_STREAMING_ON_SOUND',
  364. 'LIVE_STREAMING_OFF_SOUND'
  365. );
  366. }
  367. if (oldValue.disableIncomingMessageSound) {
  368. newValue.disabledSounds.unshift('INCOMING_MSG_SOUND');
  369. }
  370. newValue.raisedHands = newValue.raisedHands || {};
  371. if (oldValue.disableRemoveRaisedHandOnFocus) {
  372. newValue.raisedHands.disableRemoveRaisedHandOnFocus = oldValue.disableRemoveRaisedHandOnFocus;
  373. }
  374. if (oldValue.stereo || oldValue.opusMaxAverageBitrate) {
  375. newValue.audioQuality = {
  376. opusMaxAverageBitrate: oldValue.audioQuality?.opusMaxAverageBitrate ?? oldValue.opusMaxAverageBitrate,
  377. stereo: oldValue.audioQuality?.stereo ?? oldValue.stereo
  378. };
  379. }
  380. newValue.e2ee = newValue.e2ee || {};
  381. if (oldValue.e2eeLabels) {
  382. newValue.e2ee.labels = oldValue.e2eeLabels;
  383. }
  384. newValue.defaultLocalDisplayName
  385. = newValue.defaultLocalDisplayName || 'me';
  386. if (oldValue.hideAddRoomButton) {
  387. newValue.breakoutRooms = {
  388. /* eslint-disable-next-line no-extra-parens */
  389. ...(newValue.breakoutRooms || {}),
  390. hideAddRoomButton: oldValue.hideAddRoomButton
  391. };
  392. }
  393. newValue.defaultRemoteDisplayName
  394. = newValue.defaultRemoteDisplayName || 'Fellow Jitster';
  395. newValue.transcription = newValue.transcription || {};
  396. if (oldValue.transcribingEnabled !== undefined) {
  397. newValue.transcription = {
  398. ...newValue.transcription,
  399. enabled: oldValue.transcribingEnabled
  400. };
  401. }
  402. if (oldValue.transcribeWithAppLanguage !== undefined) {
  403. newValue.transcription = {
  404. ...newValue.transcription,
  405. useAppLanguage: oldValue.transcribeWithAppLanguage
  406. };
  407. }
  408. if (oldValue.preferredTranscribeLanguage !== undefined) {
  409. newValue.transcription = {
  410. ...newValue.transcription,
  411. preferredLanguage: oldValue.preferredTranscribeLanguage
  412. };
  413. }
  414. if (oldValue.autoCaptionOnRecord !== undefined) {
  415. newValue.transcription = {
  416. ...newValue.transcription,
  417. autoTranscribeOnRecord: oldValue.autoCaptionOnRecord
  418. };
  419. }
  420. newValue.recordingService = newValue.recordingService || {};
  421. if (oldValue.fileRecordingsServiceEnabled !== undefined
  422. && newValue.recordingService.enabled === undefined) {
  423. newValue.recordingService = {
  424. ...newValue.recordingService,
  425. enabled: oldValue.fileRecordingsServiceEnabled
  426. };
  427. }
  428. if (oldValue.fileRecordingsServiceSharingEnabled !== undefined
  429. && newValue.recordingService.sharingEnabled === undefined) {
  430. newValue.recordingService = {
  431. ...newValue.recordingService,
  432. sharingEnabled: oldValue.fileRecordingsServiceSharingEnabled
  433. };
  434. }
  435. newValue.liveStreaming = newValue.liveStreaming || {};
  436. // Migrate config.liveStreamingEnabled
  437. if (oldValue.liveStreamingEnabled !== undefined) {
  438. newValue.liveStreaming = {
  439. ...newValue.liveStreaming,
  440. enabled: oldValue.liveStreamingEnabled
  441. };
  442. }
  443. // Migrate interfaceConfig.LIVE_STREAMING_HELP_LINK
  444. if (oldValue.liveStreaming === undefined
  445. && typeof interfaceConfig === 'object'
  446. && interfaceConfig.hasOwnProperty('LIVE_STREAMING_HELP_LINK')) {
  447. newValue.liveStreaming = {
  448. ...newValue.liveStreaming,
  449. helpLink: interfaceConfig.LIVE_STREAMING_HELP_LINK
  450. };
  451. }
  452. newValue.speakerStats = newValue.speakerStats || {};
  453. if (oldValue.disableSpeakerStatsSearch !== undefined
  454. && newValue.speakerStats.disableSearch === undefined
  455. ) {
  456. newValue.speakerStats = {
  457. ...newValue.speakerStats,
  458. disableSearch: oldValue.disableSpeakerStatsSearch
  459. };
  460. }
  461. if (oldValue.speakerStatsOrder !== undefined
  462. && newValue.speakerStats.order === undefined) {
  463. newValue.speakerStats = {
  464. ...newValue.speakerStats,
  465. order: oldValue.speakerStatsOrder
  466. };
  467. }
  468. if (oldValue.autoKnockLobby !== undefined
  469. && newValue.lobby?.autoKnock === undefined) {
  470. newValue.lobby = {
  471. ...newValue.lobby || {},
  472. autoKnock: oldValue.autoKnockLobby
  473. };
  474. }
  475. if (oldValue.enableLobbyChat !== undefined
  476. && newValue.lobby?.enableChat === undefined) {
  477. newValue.lobby = {
  478. ...newValue.lobby || {},
  479. enableChat: oldValue.enableLobbyChat
  480. };
  481. }
  482. if (oldValue.hideLobbyButton !== undefined
  483. && newValue.securityUi?.hideLobbyButton === undefined) {
  484. newValue.securityUi = {
  485. ...newValue.securityUi || {},
  486. hideLobbyButton: oldValue.hideLobbyButton
  487. };
  488. }
  489. // Profile button is not available on mobile
  490. if (navigator.product !== 'ReactNative') {
  491. if (oldValue.disableProfile) {
  492. newValue.toolbarButtons = (newValue.toolbarButtons || TOOLBAR_BUTTONS)
  493. .filter((button: ToolbarButton) => button !== 'profile');
  494. }
  495. }
  496. _setDeeplinkingDefaults(newValue.deeplinking as IDeeplinkingConfig);
  497. return newValue;
  498. }
  499. /**
  500. * Updates the stored configuration with the given extra options.
  501. *
  502. * @param {Object} state - The Redux state of the feature base/config.
  503. * @param {Action} action - The Redux action to reduce.
  504. * @private
  505. * @returns {Object} The new state after the reduction of the specified action.
  506. */
  507. function _updateConfig(state: IConfig, { config }: { config: IConfig; }) {
  508. const newState = merge({}, state, config);
  509. _cleanupConfig(newState);
  510. return equals(state, newState) ? state : newState;
  511. }