diff --git a/app/components-react/editor/elements/hooks.tsx b/app/components-react/editor/elements/hooks.tsx index e1be3d43cad1..6af440291d9d 100644 --- a/app/components-react/editor/elements/hooks.tsx +++ b/app/components-react/editor/elements/hooks.tsx @@ -2,13 +2,12 @@ import React, { useEffect, useState } from 'react'; import { $t } from 'services/i18n'; import styles from './BaseElement.m.less'; import Scrollable from 'components-react/shared/Scrollable'; -import { useModule } from '../../hooks/useModule'; -import { mutation } from '../../store'; +import { useModule, injectState, mutation } from 'slap'; class BaseElementModule { - state = { + state = injectState({ sizeWatcherInterval: 0, - }; + }); sizeWatcherCallbacks: Function[] = []; @@ -47,7 +46,7 @@ export default function useBaseElement( const [height, setHeight] = useState(0); const [width, setWidth] = useState(0); - const { addSizeWatcher, removeSizeWatcher } = useModule(BaseElementModule).select(); + const { addSizeWatcher, removeSizeWatcher } = useModule(BaseElementModule); useEffect(() => { const sizeWatcher = () => { diff --git a/app/components-react/hooks.ts b/app/components-react/hooks.ts index 75b354bdeaf6..8b7ce4b0b43e 100644 --- a/app/components-react/hooks.ts +++ b/app/components-react/hooks.ts @@ -1,9 +1,6 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import debounce from 'lodash/debounce'; import { StatefulService } from '../services/core'; -import { createBinding, TBindings } from './shared/inputs'; -import { useForm } from './shared/inputs/Form'; -import { FormInstance } from 'antd/lib/form/hooks/useForm'; /** * Creates a reactive state for a React component based on Vuex store @@ -74,116 +71,6 @@ export function useDebounce any>(ms = 0, cb: T) { return useCallback(debounce(cb, ms), []); } -/** - * Init state with an async callback - * TODO investigate if we can just use a library for the async code https://github.com/slorber/react-async-hook - */ -export function useAsyncState( - defaultState: TStateType | (() => TStateType), - asyncCb?: (initialState: TStateType) => Promise, -): [TStateType, (newState: TStateType) => unknown, Promise | undefined] { - // define a state - const [state, setState] = useState(defaultState); - - let isDestroyed = false; - - // create and save the promise if provided - const [promise] = useState(() => { - if (asyncCb) { - return asyncCb(state).then(newState => { - // do not update the state if the component has been destroyed - if (isDestroyed) return null; - setState(newState); - return newState; - }); - } - }); - - useOnDestroy(() => { - isDestroyed = true; - }); - - return [state, setState, promise]; -} - -/** - * Create the state object and return helper methods - */ -export function useFormState(initializer: T | (() => T)): TUseFormStateResult { - const [s, setStateRaw] = useState(initializer); - - // create a reference to the last actual state - const stateRef = useRef(s); - - // use isDestroyed flag to prevent updating state on destroyed components - const isDestroyedRef = useRef(false); - useOnDestroy(() => { - isDestroyedRef.current = true; - }); - - // create a reference to AntForm - const form = useForm(); - - function setState(newState: T) { - if (isDestroyedRef.current) return; - // keep the reference in sync when we update the state - stateRef.current = newState; - setStateRaw(newState); - } - - // create a function for state patching - function updateState(patch: Partial) { - setState({ ...stateRef.current, ...patch }); - } - - function setItem( - dictionaryName: TDict, - key: TKey, - value: T[TDict][TKey], - ): void { - setState({ - ...stateRef.current, - [dictionaryName]: { ...stateRef.current[dictionaryName], [key]: value }, - }); - } - - return { - s, - setState, - updateState, - setItem, - bind: createBinding(() => stateRef.current, setState), - stateRef, - form, - }; -} - -type TUseFormStateResult = { - s: TState; - setState: (p: TState) => unknown; - updateState: (p: Partial) => unknown; - setItem: ( - dictionaryName: TDict, - key: TKey, - value: TState[TDict][TKey], - ) => unknown; - bind: TBindings; - stateRef: { current: TState }; - form: FormInstance; -}; - -/** - * Returns a function for force updating of the component - * Use it only for frequently used components for optimization purposes - * - * Current implementation from - * https://github.com/ant-design/ant-design/blob/master/components/_util/hooks/useForceUpdate.ts - */ -export function useForceUpdate() { - const [, forceUpdate] = React.useReducer(x => x + 1, 0); - return forceUpdate; -} - /** * Sets a function that guarantees a re-render and fresh state on every tick of the delay */ diff --git a/app/components-react/hooks/useComponentId.tsx b/app/components-react/hooks/useComponentId.tsx deleted file mode 100644 index b45b85de43e2..000000000000 --- a/app/components-react/hooks/useComponentId.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useOnCreate } from '../hooks'; - -let nextComponentId = 1; - -/** - * Returns a unique component id - * If DEBUG=true then the componentId includes a component name - */ -export function useComponentId() { - const DEBUG = false; - return useOnCreate(() => { - return DEBUG ? `${nextComponentId++}_${getComponentName()}` : `${nextComponentId++}`; - }); -} - -/** - * Get component name from the callstack - * Use for debugging only - */ -function getComponentName(): string { - try { - throw new Error(); - } catch (e: unknown) { - const error = e as Error; - return error.stack!.split('\n')[10].split('at ')[1].split('(')[0].trim(); - } -} diff --git a/app/components-react/hooks/useModule.tsx b/app/components-react/hooks/useModule.tsx deleted file mode 100644 index 81431fc06e04..000000000000 --- a/app/components-react/hooks/useModule.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useRef } from 'react'; -import { useOnCreate, useOnDestroy } from '../hooks'; -import { IReduxModule, getModuleManager, useSelector, createDependencyWatcher } from '../store'; -import { useComponentId } from './useComponentId'; -import { merge } from '../../util/merge'; -import { lockThis } from '../../util/lockThis'; - -/** - * A hook for using ReduxModules in components - * - * @example 1 - * // get module instance in the component - * const myModule = useModule(MyModule) - * - * // use Redux to select reactive props from the module - * const { foo } = useSelector(() => ({ foo: myModule.foo })) - * - * @example 2 - * // same as example 1 but with one-liner syntax - * const { foo } = useModule(MyModule).select() - * - * @example 3 - * // same as example 2 but with a computed prop - * const { foo, fooBar } = useModule(MyModule) - * .selectExtra(module => { fooBar: module.foo + module.bar })) - */ -function useModuleContext< - TInitParams, - TState, - TModuleClass extends new (...args: any[]) => IReduxModule, - TReturnType extends InstanceType & { - select: () => InstanceType & - InstanceType['state'] & { module: InstanceType }; - - selectExtra: ( - fn: (module: InstanceType) => TComputedProps, - ) => InstanceType & TComputedProps & { module: InstanceType }; - } ->(ModuleClass: TModuleClass, initParams?: TInitParams, moduleName = ''): TReturnType { - const computedPropsFnRef = useRef(null); - const computedPropsRef = useRef({}); - const dependencyWatcherRef = useRef(null); - const componentId = useComponentId(); - moduleName = moduleName || ModuleClass.name; - - // register the component in the ModuleManager upon component creation - const { module, select, selector } = useOnCreate(() => { - // get existing module's instance or create a new one - const moduleManager = getModuleManager(); - let module = moduleManager.getModule(moduleName); - if (!module) { - module = moduleManager.registerModule(new ModuleClass(), initParams, moduleName); - } - // register the component in the module - moduleManager.registerComponent(moduleName, componentId); - - // lockedModule is a copy of the module where all methods have a persistent `this` - // as if we called `module.methodName = module.methodName.bind(this)` for each method - const lockedModule = lockThis(module); - - // calculate computed props that were passed via `.selectExtra()` call - // and save them in `computedPropsRef` - function calculateComputedProps() { - const compute = computedPropsFnRef.current; - if (!compute) return; - const computedProps = compute(module); - Object.assign(computedPropsRef.current, computedProps); - return computedPropsRef.current; - } - - // Create a public `.select()` method that allows to select reactive state for the component - function select( - fn?: (module: InstanceType) => TComputedProps, - ): InstanceType & TComputedProps { - // create DependencyWatcher as a source of state to select from - if (!dependencyWatcherRef.current) { - // calculate computed props if the function provided - if (fn) { - computedPropsFnRef.current = fn; - calculateComputedProps(); - } - // we have several sources of data to select from - // use `merge` function to join them into a single object - const mergedModule = merge( - // allow to select variables from the module's state - () => module.state, - // allow to select getters and actions from the module - () => lockedModule, - // allow to select computed props - () => computedPropsRef.current, - // allow to select the whole module itself - () => ({ module }), - ); - dependencyWatcherRef.current = createDependencyWatcher(mergedModule); - } - return dependencyWatcherRef.current.watcherProxy; - } - - // Create a Redux selector. - // Redux calls this method every time when component's dependencies have been changed - function selector() { - // recalculate computed props - calculateComputedProps(); - // select component's dependencies - return dependencyWatcherRef.current?.getDependentValues(); - } - - return { - module, - selector, - select, - }; - }); - - // unregister the component from the module onDestroy - useOnDestroy(() => { - getModuleManager().unRegisterComponent(moduleName, componentId); - }); - - // call Redux selector to make selected props reactive - useSelector(selector); - - // return Module with extra `select` method - // TODO: `.selectExtra()` is the same method as `.select()` - // and it was added here only because of typing issues related to multiple tsconfings in the project. - // We should use only the `.select` after resolving typing issues - const mergeResult = merge( - () => module, - () => ({ select, selectExtra: select }), - ); - return (mergeResult as unknown) as TReturnType; -} - -/** - * Get the Redux module instance from the current React context - * Creates a new module instance if no instances exist - */ -export function useModule< - TState, - TModuleClass extends new (...args: any[]) => IReduxModule ->(ModuleClass: TModuleClass) { - return useModuleContext(ModuleClass); -} - -/** - * Create a Redux module instance with given params - */ -export function useModuleRoot< - TInitParams, - TState, - TModuleClass extends new (...args: any[]) => IReduxModule ->(ModuleClass: TModuleClass, initParams?: TInitParams, moduleName = '') { - return useModuleContext(ModuleClass, initParams, moduleName); -} - -/** - * same as useModule but locates a module by name instead of a class - */ -export function useModuleByName>( - moduleName: string, -): TUseModuleReturnType { - const moduleManager = getModuleManager(); - const module = moduleManager.getModule(moduleName); - if (!module) throw new Error(`Can not find module with name "${moduleName}" `); - return (useModuleContext( - module.constructor as new (...args: any[]) => IReduxModule, - null, - moduleName, - ) as unknown) as TUseModuleReturnType; -} - -type TUseModuleReturnType> = TModule & { - select: () => TModule & TModule['state'] & { module: TModule }; -}; diff --git a/app/components-react/index.ts b/app/components-react/index.ts index 5879af21c80e..1d95799c8c02 100644 --- a/app/components-react/index.ts +++ b/app/components-react/index.ts @@ -90,7 +90,7 @@ export const components = { WidgetWindow: createRoot(WidgetWindow), CustomCodeWindow: createRoot(CustomCodeWindow), SafeMode, - AdvancedAudio, + AdvancedAudio: createRoot(AdvancedAudio), SourceShowcase: createRoot(SourceShowcase), SourceFilters, RecentEvents, diff --git a/app/components-react/pages/layout-editor/hooks.ts b/app/components-react/pages/layout-editor/hooks.ts index 9c9c93c052f3..97377c494e69 100644 --- a/app/components-react/pages/layout-editor/hooks.ts +++ b/app/components-react/pages/layout-editor/hooks.ts @@ -1,17 +1,16 @@ import cloneDeep from 'lodash/cloneDeep'; import { Services } from 'components-react/service-provider'; import { ELayoutElement, ELayout, LayoutSlot } from 'services/layout'; -import { useModule } from 'components-react/hooks/useModule'; -import { mutation } from 'components-react/store'; +import { injectState, mutation, useModule } from 'slap'; class LayoutEditorModule { - state = { + state = injectState({ currentLayout: this.layoutService.views.currentTab.currentLayout || ELayout.Default, slottedElements: cloneDeep(this.layoutService.views.currentTab.slottedElements) || {}, browserUrl: this.layoutService.views.currentTab.slottedElements[ELayoutElement.Browser]?.src || '', showModal: false, - }; + }); private get layoutService() { return Services.LayoutService; @@ -23,32 +22,17 @@ class LayoutEditorModule { setCurrentTab(tab: string) { this.layoutService.actions.setCurrentTab(tab); - this.setCurrentLayout(this.layoutService.state.tabs[tab].currentLayout); + this.state.setCurrentLayout(this.layoutService.state.tabs[tab].currentLayout); this.setSlottedElements(cloneDeep(this.layoutService.state.tabs[tab].slottedElements)); } - @mutation() - setCurrentLayout(layout: ELayout) { - this.state.currentLayout = layout; - } - - @mutation() - setBrowserUrl(url: string) { - this.state.browserUrl = url; - } - @mutation() setSlottedElements( elements: { [Element in ELayoutElement]?: { slot: LayoutSlot; src?: string } }, ) { this.state.slottedElements = elements; if (!elements[ELayoutElement.Browser]) return; - this.setBrowserUrl(elements[ELayoutElement.Browser]?.src || ''); - } - - @mutation() - setShowModal(bool: boolean) { - this.state.showModal = bool; + this.state.setBrowserUrl(elements[ELayoutElement.Browser]?.src || ''); } handleElementDrag(event: React.DragEvent, el: ELayoutElement) { @@ -81,5 +65,5 @@ class LayoutEditorModule { } export function useLayoutEditor() { - return useModule(LayoutEditorModule).select(); + return useModule(LayoutEditorModule); } diff --git a/app/components-react/pages/onboarding/Connect.tsx b/app/components-react/pages/onboarding/Connect.tsx index fa02ae1dc389..8be8bc6011bb 100644 --- a/app/components-react/pages/onboarding/Connect.tsx +++ b/app/components-react/pages/onboarding/Connect.tsx @@ -3,8 +3,7 @@ import styles from './Connect.m.less'; import commonStyles from './Common.m.less'; import { $t } from 'services/i18n'; import { Services } from 'components-react/service-provider'; -import { useModule } from 'components-react/hooks/useModule'; -import { mutation } from 'components-react/store'; +import { injectState, useModule, mutation } from 'slap'; import { ExtraPlatformConnect } from './ExtraPlatformConnect'; import { EPlatformCallResult, TPlatform } from 'services/platforms'; import cx from 'classnames'; @@ -23,8 +22,8 @@ export function Connect() { authInProgress, authPlatform, setExtraPlatform, - } = useModule(LoginModule).select(); - const { next } = useModule(OnboardingModule).select(); + } = useModule(LoginModule); + const { next } = useModule(OnboardingModule); const { UsageStatisticsService } = Services; if (selectedExtraPlatform) { @@ -124,9 +123,9 @@ export function Connect() { type TExtraPlatform = 'nimotv' | 'dlive'; export class LoginModule { - state = { + state = injectState({ selectedExtraPlatform: undefined as TExtraPlatform | undefined, - }; + }); get UserService() { return Services.UserService; diff --git a/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx b/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx index 9c538d9691a8..2c447cea489f 100644 --- a/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx +++ b/app/components-react/pages/onboarding/ExtraPlatformConnect.tsx @@ -1,4 +1,4 @@ -import { useModule } from 'components-react/hooks/useModule'; +import { useModule } from 'slap'; import PlatformLogo from 'components-react/shared/PlatformLogo'; import React, { useState } from 'react'; import { $t } from 'services/i18n'; @@ -11,8 +11,8 @@ import { Services } from 'components-react/service-provider'; import Form from 'components-react/shared/inputs/Form'; export function ExtraPlatformConnect() { - const { selectedExtraPlatform, setExtraPlatform } = useModule(LoginModule).select(); - const { next } = useModule(OnboardingModule).select(); + const { selectedExtraPlatform, setExtraPlatform } = useModule(LoginModule); + const { next } = useModule(OnboardingModule); const [key, setKey] = useState(''); if (!selectedExtraPlatform) return
; diff --git a/app/components-react/pages/onboarding/FreshOrImport.tsx b/app/components-react/pages/onboarding/FreshOrImport.tsx index 835192df2afe..47017bdfa3e5 100644 --- a/app/components-react/pages/onboarding/FreshOrImport.tsx +++ b/app/components-react/pages/onboarding/FreshOrImport.tsx @@ -1,5 +1,5 @@ import { Tooltip } from 'antd'; -import { useModule } from 'components-react/hooks/useModule'; +import { useModule } from 'slap'; import KevinSvg from 'components-react/shared/KevinSvg'; import React from 'react'; import { $t } from 'services/i18n'; @@ -10,7 +10,7 @@ import ObsSvg from './ObsSvg'; import { OnboardingModule } from './Onboarding'; export function FreshOrImport() { - const { setImportFromObs, next } = useModule(OnboardingModule).select(); + const { setImportFromObs, next } = useModule(OnboardingModule); const optionsMetadata = [ { diff --git a/app/components-react/pages/onboarding/MacPermissions.tsx b/app/components-react/pages/onboarding/MacPermissions.tsx index c28aea924805..77a8e777fad5 100644 --- a/app/components-react/pages/onboarding/MacPermissions.tsx +++ b/app/components-react/pages/onboarding/MacPermissions.tsx @@ -1,4 +1,4 @@ -import { useModule } from 'components-react/hooks/useModule'; +import { useModule } from 'slap'; import { Services } from 'components-react/service-provider'; import React, { useEffect, useState } from 'react'; import { $t } from 'services/i18n'; @@ -7,7 +7,7 @@ import { OnboardingModule } from './Onboarding'; export function MacPermissions() { const { MacPermissionsService } = Services; - const { next } = useModule(OnboardingModule).select(); + const { next } = useModule(OnboardingModule); const [permissions, setPermissions] = useState(() => MacPermissionsService.getPermissionsStatus(), ); diff --git a/app/components-react/pages/onboarding/ObsImport.tsx b/app/components-react/pages/onboarding/ObsImport.tsx index 76c6932f5d50..ae563a2f6be1 100644 --- a/app/components-react/pages/onboarding/ObsImport.tsx +++ b/app/components-react/pages/onboarding/ObsImport.tsx @@ -1,11 +1,10 @@ -import { useModule } from 'components-react/hooks/useModule'; +import { injectState, useModule, mutation } from 'slap'; import { alertAsync } from 'components-react/modals'; import { Services } from 'components-react/service-provider'; import AutoProgressBar from 'components-react/shared/AutoProgressBar'; import { ListInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; import KevinSvg from 'components-react/shared/KevinSvg'; -import { mutation } from 'components-react/store'; import React from 'react'; import { $t } from 'services/i18n'; import commonStyles from './Common.m.less'; @@ -13,7 +12,7 @@ import styles from './ObsImport.m.less'; import { OnboardingModule } from './Onboarding'; export function ObsImport() { - const { importing, percent } = useModule(ObsImportModule).select(); + const { importing, percent } = useModule(ObsImportModule); return (
@@ -40,10 +39,10 @@ export function ObsImport() { } function PreImport() { - const { setProcessing, next } = useModule(OnboardingModule).select(); + const { setProcessing, next } = useModule(OnboardingModule); const { profiles, selectedProfile, setSelectedProfile, startImport } = useModule( ObsImportModule, - ).select(); + ); return (
@@ -129,12 +128,12 @@ function FeatureCards() { } class ObsImportModule { - state = { + state = injectState({ profiles: [] as string[], selectedProfile: '' as string | null, importing: false, percent: 0, - }; + }); init() { // Intentionally synchronous diff --git a/app/components-react/pages/onboarding/Onboarding.tsx b/app/components-react/pages/onboarding/Onboarding.tsx index 7b4aae3c2379..2673969917a9 100644 --- a/app/components-react/pages/onboarding/Onboarding.tsx +++ b/app/components-react/pages/onboarding/Onboarding.tsx @@ -2,17 +2,16 @@ import React from 'react'; import styles from './Onboarding.m.less'; import commonStyles from './Common.m.less'; import { Services } from 'components-react/service-provider'; -import { useModule } from 'components-react/hooks/useModule'; +import { injectState, useModule, mutation } from 'slap'; import cx from 'classnames'; -import { mutation } from 'components-react/store'; import { $t } from 'services/i18n'; import * as stepComponents from './steps'; import Utils from 'services/utils'; -import { ONBOARDING_STEPS } from 'services/onboarding'; +import {IOnboardingStep, ONBOARDING_STEPS} from 'services/onboarding'; import Scrollable from 'components-react/shared/Scrollable'; export default function Onboarding() { - const { currentStep, next, processing, finish } = useModule(OnboardingModule).select(); + const { currentStep, next, processing, finish } = useModule(OnboardingModule); // TODO: Onboarding service needs a refactor away from step index-based. // In the meantime, if we run a render cycle and step index is greater @@ -54,7 +53,7 @@ export default function Onboarding() { function TopBar() { const { stepIndex, preboardingOffset, singletonStep, steps } = useModule( OnboardingModule, - ).select(); + ); if (stepIndex < preboardingOffset || singletonStep) { return
; @@ -87,7 +86,7 @@ function TopBar() { } function ActionButton() { - const { currentStep, next, processing } = useModule(OnboardingModule).select(); + const { currentStep, next, processing } = useModule(OnboardingModule); if (currentStep.hideButton) return null; const isPrimeStep = currentStep.label === $t('Prime'); @@ -105,10 +104,10 @@ function ActionButton() { } export class OnboardingModule { - state = { + state = injectState({ stepIndex: 0, processing: false, - }; + }); get OnboardingService() { return Services.OnboardingService; @@ -126,7 +125,7 @@ export class OnboardingModule { return this.OnboardingService.views.singletonStep; } - get currentStep() { + get currentStep(): IOnboardingStep { // Useful for testing in development if (Utils.env.SLD_FORCE_ONBOARDING_STEP) { return ONBOARDING_STEPS()[Utils.env.SLD_FORCE_ONBOARDING_STEP]; diff --git a/app/components-react/pages/onboarding/Optimize.tsx b/app/components-react/pages/onboarding/Optimize.tsx index d90daa9ac05b..237e49a5662c 100644 --- a/app/components-react/pages/onboarding/Optimize.tsx +++ b/app/components-react/pages/onboarding/Optimize.tsx @@ -1,4 +1,4 @@ -import { useModule } from 'components-react/hooks/useModule'; +import { useModule } from 'slap'; import { Services } from 'components-react/service-provider'; import AutoProgressBar from 'components-react/shared/AutoProgressBar'; import React, { useState } from 'react'; @@ -30,7 +30,7 @@ export function Optimize() { ]; const percentage = optimizing && stepInfo ? (steps.indexOf(stepInfo.description) + 1) / steps.length : 0; - const { setProcessing, next } = useModule(OnboardingModule).select(); + const { setProcessing, next } = useModule(OnboardingModule); function summaryForStep(progress: IConfigProgress) { return { diff --git a/app/components-react/pages/onboarding/Prime.tsx b/app/components-react/pages/onboarding/Prime.tsx index 78d883920659..b0d2d23ad87b 100644 --- a/app/components-react/pages/onboarding/Prime.tsx +++ b/app/components-react/pages/onboarding/Prime.tsx @@ -1,5 +1,5 @@ -import { useModule } from 'components-react/hooks/useModule'; -import React, { useEffect, useRef } from 'react'; +import { useModule } from 'slap'; +import React from 'react'; import { $t } from 'services/i18n'; import commonStyles from './Common.m.less'; import { OnboardingModule } from './Onboarding'; @@ -10,7 +10,7 @@ import { useWatchVuex } from 'components-react/hooks'; export function Prime() { const { MagicLinkService, UserService } = Services; - const { next } = useModule(OnboardingModule).select(); + const { next } = useModule(OnboardingModule); const primeMetadata = { standard: [ { text: $t('Go live to one platform'), icon: 'icon-broadcast' }, diff --git a/app/components-react/pages/onboarding/StreamingOrRecording.tsx b/app/components-react/pages/onboarding/StreamingOrRecording.tsx index fdcabbc85dab..e525621e4347 100644 --- a/app/components-react/pages/onboarding/StreamingOrRecording.tsx +++ b/app/components-react/pages/onboarding/StreamingOrRecording.tsx @@ -1,5 +1,4 @@ import { Button } from 'antd'; -import { useModule } from 'components-react/hooks/useModule'; import React, { useState } from 'react'; import { $t } from 'services/i18n'; import { $i } from 'services/utils'; @@ -8,9 +7,10 @@ import { OnboardingModule } from './Onboarding'; import cx from 'classnames'; import { confirmAsync } from 'components-react/modals'; import { Services } from 'components-react/service-provider'; +import { useModule } from 'slap'; export function StreamingOrRecording() { - const { next, setRecordingMode } = useModule(OnboardingModule).select(); + const { next, setRecordingMode } = useModule(OnboardingModule); const [active, setActive] = useState<'streaming' | 'recording' | null>(null); async function onContinue() { diff --git a/app/components-react/pages/onboarding/ThemeSelector.tsx b/app/components-react/pages/onboarding/ThemeSelector.tsx index 0ba24bf7cce3..7805b8d7b14e 100644 --- a/app/components-react/pages/onboarding/ThemeSelector.tsx +++ b/app/components-react/pages/onboarding/ThemeSelector.tsx @@ -1,11 +1,11 @@ import { Services } from 'components-react/service-provider'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { $t } from 'services/i18n'; import { IThemeMetadata } from 'services/onboarding'; import commonStyles from './Common.m.less'; import styles from './ThemeSelector.m.less'; import cx from 'classnames'; -import { useModule } from 'components-react/hooks/useModule'; +import { useModule } from 'slap'; import { OnboardingModule } from './Onboarding'; import AutoProgressBar from 'components-react/shared/AutoProgressBar'; import { usePromise } from 'components-react/hooks'; @@ -19,7 +19,7 @@ export function ThemeSelector() { const [progress, setProgress] = useState(0); const detailIndex = themesMetadata.findIndex(theme => theme.data.id === showDetail); const detailTheme = themesMetadata[detailIndex]; - const { setProcessing, next } = useModule(OnboardingModule).select(); + const { setProcessing, next } = useModule(OnboardingModule); function getFilteredMetadata() { if (!showDetail) return themesMetadata; diff --git a/app/components-react/pages/stream-scheduler/StreamScheduler.tsx b/app/components-react/pages/stream-scheduler/StreamScheduler.tsx index 1f70e1127377..5c52a73efe85 100644 --- a/app/components-react/pages/stream-scheduler/StreamScheduler.tsx +++ b/app/components-react/pages/stream-scheduler/StreamScheduler.tsx @@ -10,8 +10,6 @@ import { ListInput, TimeInput } from '../../shared/inputs'; import Form, { useForm } from '../../shared/inputs/Form'; import { confirmAsync } from '../../modals'; import { IStreamEvent, useStreamScheduler } from './useStreamScheduler'; -import { Services } from '../../service-provider'; -import { getDefined } from '../../../util/properties-type-guards'; import Scrollable from '../../shared/Scrollable'; /** diff --git a/app/components-react/pages/stream-scheduler/useStreamScheduler.tsx b/app/components-react/pages/stream-scheduler/useStreamScheduler.tsx index 2e581c63d9c9..6a6048d787b0 100644 --- a/app/components-react/pages/stream-scheduler/useStreamScheduler.tsx +++ b/app/components-react/pages/stream-scheduler/useStreamScheduler.tsx @@ -15,13 +15,11 @@ import { IYoutubeLiveBroadcast, IYoutubeStartStreamOptions, } from '../../../services/platforms/youtube'; -import { mutation } from '../../store'; -import { Moment } from 'moment'; import { message } from 'antd'; import { $t } from '../../../services/i18n'; import { IStreamError } from '../../../services/streaming/stream-error'; -import { useModule } from '../../hooks/useModule'; import { IGoLiveSettings } from '../../../services/streaming'; +import { injectState, useModule, mutation } from 'slap'; /** * Represents a single stream event @@ -71,7 +69,7 @@ interface ISchedulerPlatformSettings extends Partial> */ export function useStreamScheduler() { // call `.select()` so all getters and state returned from the hook will be reactive - return useModule(StreamSchedulerModule).select(); + return useModule(StreamSchedulerModule); } /** @@ -79,7 +77,7 @@ export function useStreamScheduler() { * The module controls the components' state and provides actions */ class StreamSchedulerModule { - state = { + state = injectState({ /** * `true` if should show a spinner in the modal window */ @@ -113,7 +111,8 @@ class StreamSchedulerModule { * Fill out the default settings for each platform */ platformSettings: this.defaultPlatformSettings, - }; + defaultPlatformSettings: this.defaultPlatformSettings, + }); /** * Load all events into state on module init diff --git a/app/components-react/root/ReactRoot.tsx b/app/components-react/root/ReactRoot.tsx index a399eeecbf37..bacf753fe59e 100644 --- a/app/components-react/root/ReactRoot.tsx +++ b/app/components-react/root/ReactRoot.tsx @@ -1,16 +1,90 @@ import React from 'react'; -import { Provider } from 'react-redux'; -import { store } from '../store'; +import { createApp, Dict, inject, ReactModules, Store, TAppContext } from 'slap'; +import { getResource, StatefulService } from '../../services'; +import { AppServices } from '../../app-services'; + +/** + * This module adds reactivity support from Vuex + * It ensures that React components will be re-rendered when Vuex updates their dependencies + * + */ +class VuexModule { + private store = inject(Store); + + /** + * Keep revisions for each StatefulService module in this state + */ + private modules: Dict = {}; + + init() { + // make sure the module will be added to the component dependency list + // when the component is building their dependencies + StatefulService.onStateRead = serviceName => { + if (this.store.recordingAccessors) { + const module = this.resolveState(serviceName); + this.store.affectedModules[serviceName] = module.state.revision; + } + }; + + // watch for mutations from the global Vuex store + // and increment the revision number for affected StatefulService + StatefulService.store.subscribe(mutation => { + if (!mutation.payload.__vuexSyncIgnore) return; + const serviceName = mutation.type.split('.')[0]; + const module = this.resolveState(serviceName); + module.incrementRevision(); + }); + } + + /** + * Create and memoize the state for the stateful service + */ + private resolveState(serviceName: string) { + if (!this.modules[serviceName]) { + const module = this.store.createState(serviceName, { + revision: 0, + incrementRevision() { + this.revision++; + }, + }); + module.finishInitialization(); + this.modules[serviceName] = module; + } + return this.modules[serviceName]; + } +} + +// keep initialized modules in the global variable +// until we have multiple React roots +let modulesApp: TAppContext; + +// create and memoize the React Modules +function resolveApp() { + if (modulesApp) return modulesApp; + const app = createApp({ VuexModule }); + const scope = app.servicesScope; + scope.init(VuexModule); + + // register Services to be accessible via `inject()` + Object.keys(AppServices).forEach(serviceName => { + scope.register(() => getResource(serviceName), serviceName, { shouldCallHooks: false }); + }); + + modulesApp = app; + return modulesApp; +} /** * Creates a root React component with integrated Redux store */ export function createRoot(ChildComponent: (props: any) => JSX.Element) { return function ReactRoot(childProps: Object) { + const app = resolveApp(); + return ( - + - + ); }; } diff --git a/app/components-react/root/StudioFooter.tsx b/app/components-react/root/StudioFooter.tsx index 2313cc0f8de5..00fc53e6a7ab 100644 --- a/app/components-react/root/StudioFooter.tsx +++ b/app/components-react/root/StudioFooter.tsx @@ -12,7 +12,7 @@ import StartStreamingButton from './StartStreamingButton'; import NotificationsArea from './NotificationsArea'; import { Tooltip } from 'antd'; import { confirmAsync } from 'components-react/modals'; -import { useModule } from 'components-react/hooks/useModule'; +import { useModule } from 'slap'; export default function StudioFooterComponent() { const { @@ -36,7 +36,7 @@ export default function StudioFooterComponent() { youtubeEnabled, recordingModeEnabled, replayBufferEnabled, - } = useModule(FooterModule).select(); + } = useModule(FooterModule); useEffect(confirmYoutubeEnabled, [platform]); diff --git a/app/components-react/shared/inputs/inputs.ts b/app/components-react/shared/inputs/inputs.ts index 6cbbe696e31e..6270d5ee5bb0 100644 --- a/app/components-react/shared/inputs/inputs.ts +++ b/app/components-react/shared/inputs/inputs.ts @@ -3,7 +3,8 @@ */ import React, { useEffect, useContext, ChangeEvent, FocusEvent, useCallback, useRef } from 'react'; import { FormContext } from './Form'; -import { useDebounce, useOnCreate, useForceUpdate } from '../../hooks'; +import { useDebounce } from '../../hooks'; +import { useOnCreate, useForceUpdate, createFormBinding } from 'slap'; import uuid from 'uuid'; import { FormItemProps } from 'antd/lib/form'; import { CheckboxChangeEvent } from 'antd/lib/checkbox'; @@ -342,65 +343,12 @@ export function useTextInput< */ export function createBinding( stateGetter: TState | (() => TState), - stateSetter?: (newTarget: Partial) => unknown, + stateSetter: (newTarget: Partial) => unknown, extraPropsGenerator?: (fieldName: keyof TState) => TExtraProps, -): TBindings { - function getState(): TState { - return typeof stateGetter === 'function' - ? (stateGetter as Function)() - : (stateGetter as TState); - } - - const metadata = { - _proxyName: 'Binding', - _binding: { - id: `binding__${uuid()}`, - dependencies: {} as Record, - }, - }; - - return (new Proxy(metadata, { - get(t, fieldName: string) { - if (fieldName in metadata) return metadata[fieldName]; - const fieldValue = getState()[fieldName]; - // register the fieldName in the dependencies list - // that helps keep this binding up to date when use it inside ReduxModules - metadata._binding.dependencies[fieldName] = fieldValue; - const extraProps = extraPropsGenerator ? extraPropsGenerator(fieldName as keyof TState) : {}; - return { - name: fieldName, - value: fieldValue, - onChange(newVal: unknown) { - const state = getState(); - // if the state object has a defined setter than use the local setter - if (Object.getOwnPropertyDescriptor(state, fieldName)?.set) { - state[fieldName] = newVal; - } else if (stateSetter) { - stateSetter({ ...state, [fieldName]: newVal }); - } - }, - ...extraProps, - }; - }, - }) as unknown) as TBindings; +) { + return createFormBinding(stateGetter, stateSetter, extraPropsGenerator).proxy; } -export type TBindings = { - [K in keyof TState]: { - name: K; - value: TState[K]; - onChange: (newVal: TState[K]) => unknown; - }; -} & - TExtraProps & { - _proxyName: 'Binding'; - _binding: { - id: string; - dependencies: Record; - clone: () => TBindings; - }; - }; - function createValidationRules(type: TInputType, inputProps: IInputCommonProps) { const rules = inputProps.rules ? [...inputProps.rules] : []; if (inputProps.required) { diff --git a/app/components-react/store/index.ts b/app/components-react/store/index.ts deleted file mode 100644 index d4068169bcd1..000000000000 --- a/app/components-react/store/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './store'; diff --git a/app/components-react/store/store.ts b/app/components-react/store/store.ts deleted file mode 100644 index abcac51870b6..000000000000 --- a/app/components-react/store/store.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { configureStore, createSlice, PayloadAction, Store } from '@reduxjs/toolkit'; -import { batch, useSelector as useReduxSelector } from 'react-redux'; -import { StatefulService } from '../../services'; -import { useOnCreate } from '../hooks'; -import { useEffect, useRef } from 'react'; -import { isSimilar } from '../../util/isDeepEqual'; -import { createBinding, TBindings } from '../shared/inputs'; -import { getDefined } from '../../util/properties-type-guards'; -import { unstable_batchedUpdates } from 'react-dom'; -import Utils from '../../services/utils'; -import { traverseClassInstance } from '../../util/traverseClassInstance'; - -/* - * This file provides Redux integration in a modular way - */ - -// INITIALIZE REDUX STORE - -export const modulesSlice = createSlice({ - name: 'modules', - initialState: {}, - reducers: { - initModule: (state, action) => { - const { moduleName, initialState } = action.payload; - state[moduleName] = initialState; - }, - destroyModule: (state, action: PayloadAction) => { - const moduleName = action.payload; - delete state[moduleName]; - }, - mutateModule: (state, action) => { - const { moduleName, methodName, args } = action.payload; - const moduleManager = getModuleManager(); - const module = getModuleManager().getModule(moduleName); - moduleManager.setImmerState(state); - module[methodName](...args); - moduleManager.setImmerState(null); - }, - }, -}); - -export const store = configureStore({ - reducer: { - modules: modulesSlice.reducer, - }, -}); - -const actions = modulesSlice.actions; - -/** - * ReduxModuleManager helps to organize code splitting with help of Redux Modules - * Each Redux Module controls its own chunk of state in the global Redux store - * Redux Modules are objects that contain initialState, actions, mutations and getters - * - * Use Redux Modules when you need share some logic or state between several React components - * - * StatefulServices could be used as an alternative to Redux Modules - * However, there are some significant differences between Redux Modules and StatefulServices: - * - StatefulServices are singleton objects. Redux Modules could have multiple instances - * - StatefulServices always exist after initialization. Redux Modules exist only while components that are using them are mounted - * - StatefulServices exist in the Worker window only and are reachable from other windows by IPC only. Redux Modules could exist in any window . - * - * Redux Modules are perfect for situations where: - * - You want to share some logic or state between several React components that are intended to work together - * - You want to simplify a complex React component so it should be responsible only for rendering, and you extract everything unrelated to rendering to a module - * - You have performance issues in your React component related to a redundant re-renderings or multiple IPC calls to Services - * - * StatefulServices and Services are perfect for situations where: - * - You want to have some global reactive state across multiple windows - * - You need a place for `http` data fetching, like API calls. So you can monitor all your http requests in the same dev-tools window - * - You need some polling/watching code that should work across the entire app - * - You need to expose some API for external usage and generate jsdoc documentation - * - * With further migration to Redux we probably want StatefulServices to be a slightly modified version - * of ReduxModules because they use similar concepts - */ -class ReduxModuleManager { - public immerState: any; - registeredModules: Record = {}; - - /** - * Register a new Redux Module and initialize it - * @param module the module object - * @param initParams params that will be passed in the `.init()` handler after module initialization - */ - registerModule>( - module: TModule, - initParams?: TInitParams, - moduleName = '', - ): TModule { - // use constructor name as a module name if other name not provided - moduleName = moduleName || module.constructor.name; - - // call `init()` method of module if exist - unstable_batchedUpdates(() => { - module.name = moduleName; - // create a record in `registeredModules` with the newly created module - this.registeredModules[moduleName] = { - componentIds: [], - module, - watchers: [], - }; - - module.init && module.init(initParams as TInitParams); - const initialState = module.state; - - // replace module methods with mutation calls - replaceMethodsWithMutations(module); - - // prevent usage of destroyed modules - catchDestroyedModuleCalls(module); - - // Re-define the `state` variable of the module - // It should be linked to the global Redux state after module initialization - // But when mutation is running it should be linked to a special Proxy from the Immer library - Object.defineProperty(module, 'state', { - get: () => { - // prevent accessing state on destroyed module - if (!moduleManager.getModule(moduleName)) { - throw new Error('ReduxModule_is_destroyed'); - } - if (this.immerState) return this.immerState[moduleName]; - const globalState = store.getState() as any; - return globalState.modules[moduleName]; - }, - set: (newState: unknown) => { - const isMutationRunning = !!this.immerState; - if (!isMutationRunning) throw new Error('Can not change the state outside of mutation'); - this.immerState[moduleName] = newState; - }, - }); - - // call the `initModule` mutation to initialize the module's initial state - store.dispatch(modulesSlice.actions.initModule({ moduleName, initialState })); - }); - - return module; - } - - /** - * Unregister the module and erase its state from Redux - */ - unregisterModule(moduleName: string) { - const module = this.getModule(moduleName); - module.destroy && module.destroy(); - store.dispatch(actions.destroyModule(moduleName)); - delete this.registeredModules[moduleName]; - } - - /** - * Get the Module by name - */ - getModule>(moduleName: string): TModule { - return this.registeredModules[moduleName]?.module as TModule; - } - - /** - * Register a component that is using the module - */ - registerComponent(moduleName: string, componentId: string) { - this.registeredModules[moduleName].componentIds.push(componentId); - } - - /** - * Un-register a component that is using the module. - * If the module doesnt have registered components it will be destroyed - */ - unRegisterComponent(moduleName: string, componentId: string) { - const moduleMetadata = this.registeredModules[moduleName]; - moduleMetadata.componentIds = moduleMetadata.componentIds.filter(id => id !== componentId); - if (!moduleMetadata.componentIds.length) this.unregisterModule(moduleName); - } - - /** - * When Redux is running mutation it replaces the state object with a special Proxy object from - * the Immer library. Keep this object in the `immerState` property - */ - setImmerState(immerState: unknown) { - this.immerState = immerState; - } - - /** - * Run watcher functions registered in modules - */ - runWatchers() { - Object.keys(this.registeredModules).map(moduleName => { - const watchers = this.registeredModules[moduleName].watchers; - watchers.forEach(watcher => { - const newVal = watcher.selector(); - const prevVal = watcher.prevValue; - watcher.prevValue = newVal; - if (newVal !== prevVal) { - watcher.onChange(newVal, prevVal); - } - }); - }); - } -} - -let moduleManager: ReduxModuleManager; - -/** - * The ModuleManager is a singleton object accessible in other files via the `getModuleManager()` call - */ -export function getModuleManager() { - if (!moduleManager) { - // create the ModuleManager and - // automatically register some additional modules - moduleManager = new ReduxModuleManager(); - - // add a BatchedUpdatesModule for rendering optimizations - moduleManager.registerModule(new BatchedUpdatesModule()); - - // add a VuexModule for Vuex support - moduleManager.registerModule(new VuexModule()); - - // save module manager in the global namespace for debugging - if (Utils.isDevMode()) window['mm'] = moduleManager; - } - return moduleManager; -} - -/** - * This module introduces a simple implementation of batching updates for the performance optimization - * It prevents components from being re-rendered in a not-ready state - * and reduces an overall amount of redundant re-renderings - * - * React 18 introduced automated batched updates. - * So most likely we can remove this module after the migration to the new version of React - * https://github.com/reactwg/react-18/discussions/21 - */ -class BatchedUpdatesModule { - state = { - isRenderingDisabled: false, - }; - - /** - * Temporary disables rendering for components when multiple mutations are being applied - */ - temporaryDisableRendering() { - // if rendering is already disabled just ignore - if (this.state.isRenderingDisabled) return; - - // disable rendering - this.setIsRenderingDisabled(true); - - // enable rendering again when Javascript processes the current queue of tasks - setTimeout(() => { - this.setIsRenderingDisabled(false); - moduleManager.runWatchers(); - }); - } - - @mutation() - private setIsRenderingDisabled(disabled: boolean) { - this.state.isRenderingDisabled = disabled; - } -} - -/** - * This module adds reactivity support from Vuex - * It ensures that React components are be re-rendered when Vuex updates their dependencies - * - * We should remove this module after we fully migrate our components to Redux - */ -class VuexModule { - /** - * Keep revisions for each StatefulService module in this state - */ - state: Record = {}; - - init() { - // watch for mutations from the global Vuex store - // and increment the revision number for affected StatefulService - StatefulService.store.subscribe(mutation => { - const serviceName = mutation.type.split('.')[0]; - this.incrementRevision(serviceName); - }); - } - - @mutation() - incrementRevision(statefulServiceName: string) { - if (!this.state[statefulServiceName]) { - this.state[statefulServiceName] = 1; - } else { - this.state[statefulServiceName]++; - } - } -} - -/** - * A decorator that registers the object method as an mutation - */ -export function mutation() { - return function (target: any, methodName: string) { - target.mutations = target.mutations || []; - // mark the method as an mutation - target.mutations.push(methodName); - }; -} - -function replaceMethodsWithMutations(module: IReduxModule) { - const moduleName = getDefined(module.name); - const mutationNames: string[] = Object.getPrototypeOf(module).mutations || []; - - mutationNames.forEach(mutationName => { - const originalMethod = module[mutationName]; - - // override the original Module method to dispatch mutations - module[mutationName] = function (...args: any[]) { - // if this method was called from another mutation - // we don't need to dispatch a new mutation again - // just call the original method - const mutationIsRunning = !!moduleManager.immerState; - if (mutationIsRunning) return originalMethod.apply(module, args); - - // prevent accessing state on deleted module - if (!moduleManager.getModule(moduleName)) { - throw new Error('ReduxModule_is_destroyed'); - } - - const batchedUpdatesModule = moduleManager.getModule( - 'BatchedUpdatesModule', - ); - - // clear unserializable events from arguments - args = args.map(arg => { - const isReactEvent = arg && arg._reactName; - if (isReactEvent) return { _reactName: arg._reactName }; - return arg; - }); - - // dispatch reducer and call `temporaryDisableRendering()` - // so next mutation in the javascript queue will not cause redundant re-renderings in components - batch(() => { - if (moduleName !== 'BatchedUpdatesModule') batchedUpdatesModule.temporaryDisableRendering(); - store.dispatch(actions.mutateModule({ moduleName, methodName: mutationName, args })); - }); - }; - }); -} - -/** - * Add try/catch that silently stops all method calls for a destroyed module - */ -function catchDestroyedModuleCalls(module: any) { - // wrap each method in try/catch block - traverseClassInstance(module, (propName, descriptor) => { - // ignore getters - if (descriptor.get || typeof module[propName] !== 'function') return; - - const originalMethod = module[propName]; - module[propName] = (...args: unknown[]) => { - try { - return originalMethod.apply(module, args); - } catch (e: unknown) { - // silently stop execution if module is destroyed - if ((e as any).message !== 'ReduxModule_is_destroyed') throw e; - } - }; - }); -} - -/** - * This `useSelector` is a wrapper for the original `useSelector` method from Redux - * - Optimizes component re-rendering via batched updates from Redux and Vuex - * - Uses isDeepEqual with depth 2 as a default comparison function - */ -export function useSelector(fn: () => T): T { - const moduleManager = getModuleManager(); - const batchedUpdatesModule = moduleManager.getModule( - 'BatchedUpdatesModule', - ); - const cachedSelectedResult = useRef(null); - const isMountedRef = useRef(false); - - // save the selector function and update it each component re-rendering - // this prevents having staled closure variables in the selector - const selectorFnRef = useRef(fn); - selectorFnRef.current = fn; - - // create the selector function - const selector = useOnCreate(() => { - return () => { - // if `isRenderingDisabled=true` selector will return previously cached values - if (batchedUpdatesModule.state.isRenderingDisabled && isMountedRef.current) { - return cachedSelectedResult.current; - } - - // otherwise execute the selector - cachedSelectedResult.current = selectorFnRef.current(); - return cachedSelectedResult.current; - }; - }); - - useEffect(() => { - isMountedRef.current = true; - }); - - return useReduxSelector(selector, (prevState, newState) => { - // there is no reason to compare prevState and newState if - // the rendering is disabled for components - if (batchedUpdatesModule.state.isRenderingDisabled) { - return true; - } - - // use `isSimilar` function to compare 2 states - if (!isSimilar(prevState, newState)) { - return false; - } - return true; - }) as T; -} - -/** - * Wraps the given object in a Proxy for watching read operations on this object - * - * @example - * - * const myObject = { foo: 1, bar: 2, qux: 3}; - * const { watcherProxy, getDependentFields } = createDependencyWatcher(myObject); - * const { foo, bar } = watcherProxy; - * getDependentFields(); // returns ['foo', 'bar']; - * - */ -export function createDependencyWatcher(watchedObject: T) { - const dependencies: Record = {}; - const watcherProxy = new Proxy( - { - _proxyName: 'DependencyWatcher', - _watchedObject: watchedObject, - _dependencies: dependencies, - }, - { - get: (target, propName: string) => { - // if (propName === 'hasOwnProperty') return watchedObject.hasOwnProperty; - if (propName in target) return target[propName]; - const value = watchedObject[propName]; - dependencies[propName] = value; - return value; - // } - }, - }, - ) as T; - - function getDependentFields() { - return Object.keys(dependencies); - } - - function getDependentValues(): Partial { - const values: Partial = {}; - Object.keys(dependencies).forEach(propName => { - const value = dependencies[propName]; - // if one of the dependencies is a Binding then expose its internal dependencies - if (value && value._proxyName === 'Binding') { - const bindingMetadata = value._binding; - Object.keys(bindingMetadata.dependencies).forEach(bindingPropName => { - values[`${bindingPropName}__binding-${bindingMetadata.id}`] = - dependencies[propName][bindingPropName].value; - }); - return; - } - // if it's not a Binding then just take the value from the watchedObject - values[propName] = watchedObject[propName]; - }); - return values; - } - - return { watcherProxy, getDependentFields, getDependentValues }; -} - -/** - * Watch changes on a reactive state in the module - */ -export function watch( - module: IReduxModule, - selector: () => T, - onChange: (newVal: T, prevVal: T) => unknown, -) { - const moduleName = getDefined(module.name); - const moduleMetadata = moduleManager.registeredModules[moduleName]; - moduleMetadata.watchers.push({ - selector, - onChange, - prevValue: selector(), - }); -} - -interface IWatcher { - selector: () => T; - onChange: (newVal: T, prevVal: T) => unknown; - prevValue: T; -} - -/** - * Returns a reactive binding for inputs - * - * @example 1 usage with getter and setter - * - * const bind = useBinding({ - * get theme() { - * return this.customizationService.state.theme - * }, - * set theme(val: string) { - * this.customizationService.actions.setSettings({ theme: val }); - * } - * }) - * - * return - * - * - * @example 2 usage with a setter function - * - * const bind = useBinding( - * () => this.customizationService.state, - * newState => this.customizationService.setSettings(newState) - * ) - * - * return - * - */ -export function useBinding( - stateGetter: TState | (() => TState), - stateSetter?: (newTarget: Partial) => unknown, - extraPropsGenerator?: (fieldName: keyof TState) => TExtraProps, -): TBindings { - const bindingRef = useRef>(); - - if (!bindingRef.current) { - // create binding - bindingRef.current = createBinding(stateGetter, stateSetter, extraPropsGenerator); - } - - // make dependencies reactive - useSelector(() => { - const binding = getDefined(bindingRef.current); - const dependentFields = Object.keys(binding._binding.dependencies); - const result = {}; - dependentFields.forEach(fieldName => { - result[fieldName] = binding[fieldName]; - }); - return result; - }); - - return bindingRef.current; -} - -export interface IReduxModule { - state: TState; - name?: string; - init?: (initParams: TInitParams) => unknown; - destroy?: () => unknown; -} - -interface IReduxModuleMetadata { - componentIds: string[]; - module: IReduxModule; - watchers: IWatcher[]; -} diff --git a/app/components-react/widgets/AlertBox.tsx b/app/components-react/widgets/AlertBox.tsx index 7b18eafb87e5..c4bbdc12895a 100644 --- a/app/components-react/widgets/AlertBox.tsx +++ b/app/components-react/widgets/AlertBox.tsx @@ -24,14 +24,13 @@ import { WidgetLayout } from './common/WidgetLayout'; import { CaretRightOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { TAlertType } from '../../services/widgets/alerts-config'; import { useAlertBox } from './useAlertBox'; -import { useForceUpdate } from '../hooks'; -import electron from 'electron'; import { Services } from '../service-provider'; import { ButtonGroup } from '../shared/ButtonGroup'; import { LayoutInput } from './common/LayoutInput'; import InputWrapper from '../shared/inputs/InputWrapper'; import * as remote from '@electron/remote'; import { assertIsDefined } from '../../util/properties-type-guards'; +import { useForceUpdate } from 'slap'; /** * Root component diff --git a/app/components-react/widgets/DonationTicker.tsx b/app/components-react/widgets/DonationTicker.tsx index a319c9dac1b4..2e290e304ac4 100644 --- a/app/components-react/widgets/DonationTicker.tsx +++ b/app/components-react/widgets/DonationTicker.tsx @@ -4,7 +4,6 @@ import { WidgetLayout } from './common/WidgetLayout'; import { $t } from '../../services/i18n'; import { ColorInput, - createBinding, FontFamilyInput, FontSizeInput, FontWeightInput, @@ -96,10 +95,6 @@ export function DonationTicker() { } export class DonationTickerModule extends WidgetModule { - bind = createBinding( - () => this.settings, - statePatch => this.updateSettings(statePatch), - ); patchAfterFetch(data: any): IDonationTickerState { // backend accepts and returns some numerical values as strings diff --git a/app/components-react/widgets/EmoteWall.tsx b/app/components-react/widgets/EmoteWall.tsx index 275408164bf2..e7f61c71e8a1 100644 --- a/app/components-react/widgets/EmoteWall.tsx +++ b/app/components-react/widgets/EmoteWall.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { IWidgetState, useWidget, WidgetModule } from './common/useWidget'; +import { IWidgetCommonState, useWidget, WidgetModule } from './common/useWidget'; import { WidgetLayout } from './common/WidgetLayout'; import InputWrapper from '../shared/inputs/InputWrapper'; import { $t } from '../../services/i18n'; -import { createBinding, SliderInput, SwitchInput } from '../shared/inputs'; -import { IEmoteWallSettings } from 'services/widgets/settings/emote-wall'; +import { SliderInput, SwitchInput } from '../shared/inputs'; import { metadata } from '../shared/inputs/metadata'; -interface IEmoteWallState extends IWidgetState { +interface IEmoteWallState extends IWidgetCommonState { data: { settings: { combo_count: number; @@ -58,11 +57,6 @@ export function EmoteWall() { } export class EmoteWallModule extends WidgetModule { - bind = createBinding( - () => this.settings, - statePatch => this.updateSettings(statePatch), - ); - get isComboRequired() { return this.settings?.combo_required; } diff --git a/app/components-react/widgets/GameWidget.tsx b/app/components-react/widgets/GameWidget.tsx index fd0b4f239274..09a413071191 100644 --- a/app/components-react/widgets/GameWidget.tsx +++ b/app/components-react/widgets/GameWidget.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; -import { IWidgetState, useWidget, WidgetModule } from './common/useWidget'; +import { IWidgetCommonState, useWidget, WidgetModule } from './common/useWidget'; import { WidgetLayout } from './common/WidgetLayout'; import { $t } from 'services/i18n'; import { metadata } from 'components-react/shared/inputs/metadata'; -import { TextInput, ColorInput, createBinding, SliderInput } from 'components-react/shared/inputs'; +import { TextInput, ColorInput, SliderInput } from 'components-react/shared/inputs'; import Form from 'components-react/shared/inputs/Form'; import { Menu } from 'antd'; @@ -27,7 +27,7 @@ interface ITicTacToeOptions { cannot_play_here: string; } -interface IGameWidgetState extends IWidgetState { +interface IGameWidgetState extends IWidgetCommonState { data: { settings: { decision_poll_timer: number; @@ -44,18 +44,16 @@ interface IGameWidgetState extends IWidgetState { } export function GameWidget() { - const { isLoading, bind } = useGameWidget(); - - const [activeTab, setActiveTab] = useState('general'); + const { isLoading, bind, selectedTab, setSelectedTab } = useGameWidget(); return ( - setActiveTab(e.key)} selectedKeys={[activeTab]}> + setSelectedTab(e.key)} selectedKeys={[selectedTab]}> {$t('General Settings')} {$t('Game Settings')}
- {!isLoading && activeTab === 'general' && ( + {!isLoading && selectedTab === 'general' && ( <> )} - {!isLoading && activeTab === 'game' && } + {!isLoading && selectedTab === 'game' && } ); } -export class GameWidgetModule extends WidgetModule { - bind = createBinding( - () => this.settings, - statePatch => this.updateSettings(statePatch), - ); -} +export class GameWidgetModule extends WidgetModule {} function useGameWidget() { return useWidget(); diff --git a/app/components-react/widgets/ViewerCount.tsx b/app/components-react/widgets/ViewerCount.tsx index 1eba657468d4..225314928d56 100644 --- a/app/components-react/widgets/ViewerCount.tsx +++ b/app/components-react/widgets/ViewerCount.tsx @@ -1,17 +1,11 @@ import React from 'react'; -import { IWidgetState, useWidget, WidgetModule } from './common/useWidget'; +import { IWidgetCommonState, useWidget, WidgetModule } from './common/useWidget'; import { WidgetLayout } from './common/WidgetLayout'; import InputWrapper from '../shared/inputs/InputWrapper'; import { $t } from '../../services/i18n'; -import { - CheckboxInput, - ColorInput, - createBinding, - FontFamilyInput, - FontSizeInput, -} from '../shared/inputs'; +import { CheckboxInput, ColorInput, FontFamilyInput, FontSizeInput } from '../shared/inputs'; -interface IViewerCountState extends IWidgetState { +interface IViewerCountState extends IWidgetCommonState { data: { settings: { font: string; @@ -50,11 +44,6 @@ export function ViewerCount() { } export class ViewerCountModule extends WidgetModule { - bind = createBinding( - () => this.settings, - statePatch => this.updateSettings(statePatch), - ); - patchAfterFetch(data: any): IViewerCountState { // transform platform types to simple booleans return { diff --git a/app/components-react/widgets/common/CustomCode.tsx b/app/components-react/widgets/common/CustomCode.tsx index 0aabe41aecbf..07b79c7097a3 100644 --- a/app/components-react/widgets/common/CustomCode.tsx +++ b/app/components-react/widgets/common/CustomCode.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { Alert, Button, Collapse, Spin, Tabs } from 'antd'; import { $t } from '../../../services/i18n'; import { CodeInput, SwitchInput } from '../../shared/inputs'; -import { useWidget, useWidgetRoot } from './useWidget'; +import { useWidget } from './useWidget'; import Form from '../../shared/inputs/Form'; -import { useOnCreate } from '../../hooks'; +import { useOnCreate } from 'slap'; import { Services } from '../../service-provider'; import { getDefined } from '../../../util/properties-type-guards'; import { ModalLayout } from '../../shared/ModalLayout'; @@ -19,20 +19,18 @@ const { TabPane } = Tabs; */ export function CustomCodeWindow() { // take the source id from the window's params - const { sourceId, WidgetModule, widgetSelectedTab } = useOnCreate(() => { + const { sourceId, widgetSelectedTab } = useOnCreate(() => { const { WindowsService } = Services; - const { sourceId, widgetType } = getDefined(WindowsService.state.child.queryParams); + const { sourceId } = getDefined(WindowsService.state.child.queryParams); const { selectedTab } = getDefined(WindowsService.state[Utils.getWindowId()].queryParams); - const [, WidgetModule] = components[widgetType]; - return { sourceId, WidgetModule, widgetSelectedTab: selectedTab }; + return { sourceId, widgetSelectedTab: selectedTab }; }); - useWidgetRoot(WidgetModule, { + const { selectedTab, selectTab, tabs, isLoading } = useCodeEditor({ sourceId, shouldCreatePreviewSource: false, selectedTab: widgetSelectedTab, }); - const { selectedTab, selectTab, tabs, isLoading } = useCodeEditor(); return ( }> diff --git a/app/components-react/widgets/common/WidgetWindow.tsx b/app/components-react/widgets/common/WidgetWindow.tsx index 501942a6dd2c..4cc7ace86be2 100644 --- a/app/components-react/widgets/common/WidgetWindow.tsx +++ b/app/components-react/widgets/common/WidgetWindow.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { useOnCreate } from '../../hooks'; +import { useOnCreate } from 'slap'; import { ModalLayout } from '../../shared/ModalLayout'; import { Services } from '../../service-provider'; import { AlertBox } from '../AlertBox'; import { AlertBoxModule } from '../useAlertBox'; -import { useWidgetRoot } from './useWidget'; -import { getDefined } from '../../../util/properties-type-guards'; +import { useWidgetRoot, WidgetModule } from './useWidget'; // TODO: import other widgets here to avoid merge conflicts // BitGoal // DonationGoal @@ -64,15 +63,15 @@ export function WidgetWindow() { const { WindowsService, WidgetsService } = Services; // take the source id and widget's component from the window's params - const { sourceId, WidgetModule, WidgetSettingsComponent } = useOnCreate(() => { + const { sourceId, Module, WidgetSettingsComponent } = useOnCreate(() => { const { sourceId, widgetType } = WindowsService.getChildWindowQueryParams(); - const [WidgetSettingsComponent, WidgetModule] = components[widgetType]; - return { sourceId, WidgetModule, WidgetSettingsComponent }; + const [WidgetSettingsComponent, Module] = components[widgetType]; + return { sourceId, Module, WidgetSettingsComponent }; }); // initialize the Redux module for the widget // so all children components can use it via `useWidget()` call - const { reload } = useWidgetRoot(WidgetModule, { sourceId }); + const { reload } = useWidgetRoot(Module as typeof WidgetModule, { sourceId }); useSubscription(WidgetsService.settingsInvalidated, reload); diff --git a/app/components-react/widgets/common/useCodeEditor.tsx b/app/components-react/widgets/common/useCodeEditor.tsx index 619bcd1f297d..97fa561573f5 100644 --- a/app/components-react/widgets/common/useCodeEditor.tsx +++ b/app/components-react/widgets/common/useCodeEditor.tsx @@ -1,11 +1,10 @@ -import { WidgetModule } from './useWidget'; -import { getModuleManager, mutation, watch } from '../../store'; +import {WidgetModule, WidgetParams} from './useWidget'; import { message } from 'antd'; import Utils from '../../../services/utils'; import { Services } from '../../service-provider'; import { DEFAULT_CUSTOM_FIELDS } from './CustomFields'; -import { useModule } from '../../hooks/useModule'; import { getDefined } from '../../../util/properties-type-guards'; +import {inject, injectChild, injectState, injectWatch, mutation, useModule} from 'slap'; type TLang = 'json' | 'js' | 'css' | 'html'; @@ -13,6 +12,9 @@ type TLang = 'json' | 'js' | 'css' | 'html'; * Manages the state for Code Editor window */ class CodeEditorModule { + + constructor(public widgetParams: WidgetParams) {} + tabs = [ { label: 'Custom Fields', key: 'json' }, { label: 'HTML', key: 'html' }, @@ -20,7 +22,7 @@ class CodeEditorModule { { label: 'JS', key: 'js' }, ]; - state = { + state = injectState({ selectedTab: 'json' as TLang, canSave: false, isLoading: true, @@ -31,18 +33,13 @@ class CodeEditorModule { custom_css: '', custom_js: '', }, - }; + }); - private widgetModule: WidgetModule = getModuleManager().getModule('WidgetModule'); + private widgetModule = injectChild(WidgetModule, this.widgetParams); - init() { - // wait for the WidgetModule to load to get the custom code data from it - watch( - this, - () => this.widgetModule.state.isLoading, - () => this.reset(), - ); - } + + // wait for the WidgetModule to load to get the custom code data from it + watchWidgetModule = injectWatch(() => this.widgetModule.state.isLoading, () => this.reset()); /** * Save the custom code on the server @@ -111,15 +108,15 @@ class CodeEditorModule { @mutation() reset() { const customCode = getDefined(this.widgetModule.customCode); - this.state = { - ...this.state, + this.state.update({ + ...this.state.getters, isLoading: false, customCode: { ...customCode, custom_json: customCode.custom_json ? JSON.stringify(customCode.custom_json, null, 2) : '', }, canSave: false, - }; + }); } @mutation() @@ -146,6 +143,7 @@ class CodeEditorModule { } } -export function useCodeEditor() { - return useModule(CodeEditorModule).select(); +export function useCodeEditor(widgetParams?: WidgetParams) { + const params = widgetParams ? [widgetParams] : false; + return useModule(CodeEditorModule, params as any); // TODO fix types } diff --git a/app/components-react/widgets/common/useWidget.tsx b/app/components-react/widgets/common/useWidget.tsx index 2dbcf449c27b..522ac597e179 100644 --- a/app/components-react/widgets/common/useWidget.tsx +++ b/app/components-react/widgets/common/useWidget.tsx @@ -1,7 +1,5 @@ -import { useModuleByName, useModuleRoot } from '../../hooks/useModule'; import { WidgetType } from '../../../services/widgets'; import { Services } from '../../service-provider'; -import { mutation } from '../../store'; import { throttle } from 'lodash-decorators'; import { assertIsDefined, getDefined } from '../../../util/properties-type-guards'; import { TObsFormData } from '../../../components/obs/inputs/ObsInput'; @@ -12,11 +10,17 @@ import { TAlertType } from '../../../services/widgets/alerts-config'; import { alertAsync } from '../../modals'; import { onUnload } from 'util/unload'; import merge from 'lodash/merge'; +import { + GetUseModuleResult, injectFormBinding, + injectState, + useModule, +} from 'slap'; +import { IWidgetConfig } from '../../../services/widgets/widgets-config'; /** * Common state for all widgets */ -export interface IWidgetState { +export interface IWidgetCommonState { isLoading: boolean; sourceId: string; shouldCreatePreviewSource: boolean; @@ -26,58 +30,67 @@ export interface IWidgetState { browserSourceProps: TObsFormData; prevSettings: any; canRevert: boolean; + widgetData: IWidgetState; +} + +/** + * Common state for all widgets + */ +export interface IWidgetState { data: { - settings: Record; - }; + settings: any; + } } + /** * Default state for all widgets */ -export const DEFAULT_WIDGET_STATE = ({ +export const DEFAULT_WIDGET_STATE: IWidgetCommonState = { isLoading: true, sourceId: '', shouldCreatePreviewSource: true, previewSourceId: '', isPreviewVisible: false, selectedTab: 'general', - type: '', - data: {}, + type: '' as any as WidgetType, + widgetData: { + data: { + settings: {}, + }, + }, prevSettings: {}, canRevert: false, - browserSourceProps: null, -} as unknown) as IWidgetState; + browserSourceProps: null as any as TObsFormData, +} as IWidgetCommonState; /** * A base Redux module for all widget components */ export class WidgetModule { + constructor(public params: WidgetParams) {} + // init default state - state: TWidgetState = { - ...((DEFAULT_WIDGET_STATE as unknown) as TWidgetState), - }; + state = injectState({ + ...DEFAULT_WIDGET_STATE, + sourceId: this.params.sourceId, + shouldCreatePreviewSource: this.params.shouldCreatePreviewSource ?? true, + selectedTab: this.params.selectedTab ?? 'general', + } as IWidgetCommonState); // create shortcuts for widgetsConfig and eventsInfo public widgetsConfig = this.widgetsService.widgetsConfig; public eventsConfig = this.widgetsService.alertsConfig; + bind = injectFormBinding( + () => this.settings, + statePatch => this.updateSettings(statePatch), + ); + cancelUnload: () => void; // init module - async init(params: { - sourceId: string; - shouldCreatePreviewSource?: boolean; - selectedTab?: string; - }) { - // init state from params - this.state.sourceId = params.sourceId; - if (params.shouldCreatePreviewSource === false) { - this.state.shouldCreatePreviewSource = false; - } - if (params.selectedTab) { - this.state.selectedTab = params.selectedTab; - } - + async init() { // save browser source settings into store const widget = this.widget; this.setBrowserSourceProps(widget.getSource()!.getPropertiesFormData()); @@ -95,30 +108,37 @@ export class WidgetModule { const data = await this.fetchData(); this.setData(data); this.setPrevSettings(data); - this.setIsLoading(false); + this.state.setIsLoading(false); } - // de-init module destroy() { if (this.state.previewSourceId) this.widget.destroyPreviewSource(); this.cancelUnload(); } async reload() { - this.setIsLoading(true); + this.state.setIsLoading(true); this.setData(await this.fetchData()); - this.setIsLoading(false); + this.state.setIsLoading(false); } close() { Services.WindowsService.actions.closeChildWindow(); } + get widgetState() { + return getDefined(this.state.widgetData) as TWidgetState; + } + + get widgetData(): TWidgetState['data'] { + return this.widgetState.data; + } + /** * returns widget's settings from the store */ get settings(): TWidgetState['data']['settings'] { - return getDefined(this.state.data).settings; + return this.widgetData.settings; } get availableAlerts(): TAlertType[] { @@ -186,7 +206,7 @@ export class WidgetModule { return this.widgetsService.getWidgetSource(this.state.sourceId); } - get config() { + get config(): IWidgetConfig { return this.widgetsConfig[this.state.type]; } @@ -195,7 +215,7 @@ export class WidgetModule { } public onMenuClickHandler(e: { key: string }) { - this.setSelectedTab(e.key); + this.state.setSelectedTab(e.key); } public playAlert(type: TAlertType) { @@ -274,47 +294,32 @@ export class WidgetModule { } async revertChanges() { - this.setIsLoading(true); + this.state.setIsLoading(true); await this.updateSettings(this.state.prevSettings); - this.setCanRevert(false); + this.state.setCanRevert(false); await this.reload(); } // DEFINE MUTATIONS - @mutation() - private setIsLoading(isLoading: boolean) { - this.state.isLoading = isLoading; - } - - @mutation() - private setSelectedTab(name: string) { - this.state.selectedTab = name; - } - - @mutation() - protected setData(data: TWidgetState['data']) { - this.state.data = data; - } - - @mutation() private setPrevSettings(data: TWidgetState['data']) { - this.state.prevSettings = cloneDeep(data.settings); + this.state.setPrevSettings(cloneDeep(data.settings)); } - @mutation() - private setCanRevert(canRevert: boolean) { - this.state.canRevert = canRevert; + protected setData(data: TWidgetState['data']) { + this.state.mutate(state => { + state.widgetData.data = data; + }); } - @mutation() private setSettings(settings: TWidgetState['data']['settings']) { - assertIsDefined(this.state.data); - this.state.data.settings = settings; - this.state.canRevert = true; + assertIsDefined(this.state.widgetData.data); + this.state.mutate(state => { + state.widgetData.data.settings = settings; + state.canRevert = true; + }); } - @mutation() private setBrowserSourceProps(props: TObsFormData) { const propsOrder = [ 'width', @@ -328,27 +333,27 @@ export class WidgetModule { 'fps', ]; const sortedProps = propsOrder.map(propName => props.find(p => p.name === propName)!); - this.state.browserSourceProps = sortedProps; + this.state.setBrowserSourceProps(sortedProps); } } +export type WidgetParams = { sourceId?: string; shouldCreatePreviewSource?: boolean; selectedTab?: string } + /** - * Initializes a context with a Redux module for a given widget * Have to be called in the root widget component - * all widget components can access the initialized module via `useWidget` hook */ export function useWidgetRoot( Module: T, - params: { sourceId?: string; shouldCreatePreviewSource?: boolean; selectedTab?: string }, + params: WidgetParams, ) { - return useModuleRoot(Module, params, 'WidgetModule').select(); + return useModule(Module, [params] as any, 'WidgetModule'); } /** * Returns the widget's module from the existing context and selects requested fields */ export function useWidget() { - return useModuleByName('WidgetModule').select(); + return useModule('WidgetModule') as GetUseModuleResult; } /** diff --git a/app/components-react/widgets/useAlertBox.tsx b/app/components-react/widgets/useAlertBox.tsx index ad356d326367..6cf966afb957 100644 --- a/app/components-react/widgets/useAlertBox.tsx +++ b/app/components-react/widgets/useAlertBox.tsx @@ -9,14 +9,13 @@ import { values, cloneDeep, intersection } from 'lodash'; import { IAlertConfig, TAlertType } from '../../services/widgets/alerts-config'; import { createBinding } from '../shared/inputs'; import { Services } from '../service-provider'; -import { mutation } from '../store'; import { metadata } from '../shared/inputs/metadata'; import { $t } from '../../services/i18n'; -import * as electron from 'electron'; import { getDefined } from '../../util/properties-type-guards'; import { TPlatform } from '../../services/platforms'; import * as remote from '@electron/remote'; import { IListOption } from '../shared/inputs/ListInput'; +import { injectFormBinding, mutation } from 'slap'; interface IAlertBoxState extends IWidgetState { data: { @@ -45,7 +44,7 @@ export class AlertBoxModule extends WidgetModule { * config for all events supported by users' platforms */ get alerts() { - return this.state.availableAlerts.map(alertType => this.eventsConfig[alertType]); + return this.widgetState.availableAlerts.map(alertType => this.eventsConfig[alertType]); } /** @@ -62,15 +61,17 @@ export class AlertBoxModule extends WidgetModule { * returns settings for a given variation from the state */ getVariationSettings(alertType: T, variationId = 'default') { - return this.state.data.variations[alertType][variationId]; + const variations = this.widgetData.variations; + if (!variations) return null; + return this.widgetData.variations[alertType][variationId]; } /** * 2-way bindings for general settings inputs */ - bind = createBinding( + bind = injectFormBinding( // define source of values - () => this.settings, + () => this.settings as IAlertBoxState['data']['settings'], // define onChange handler statePatch => this.updateSettings(statePatch), // pull additional metadata like tooltip, label, min, max, etc... @@ -113,8 +114,8 @@ export class AlertBoxModule extends WidgetModule { * list of enabled alerts */ get enabledAlerts() { - return Object.keys(this.state.data.variations).filter( - alertType => this.state.data.variations[alertType].default.enabled, + return Object.keys(this.widgetData.variations).filter( + alertType => this.widgetData.variations[alertType].default.enabled, ); } @@ -122,7 +123,7 @@ export class AlertBoxModule extends WidgetModule { * available animations */ get animationOptions() { - return this.state.data.animationOptions; + return this.widgetData.animationOptions; } /** @@ -143,10 +144,9 @@ export class AlertBoxModule extends WidgetModule { } /** - * @override * Patch and sanitize the AlertBox settings after fetching data from the server */ - protected patchAfterFetch(data: any): any { + protected override patchAfterFetch(data: any): any { const settings = data.settings; // sanitize general settings @@ -182,37 +182,38 @@ export class AlertBoxModule extends WidgetModule { return data; } - /** - * @override - */ - setData(data: IAlertBoxState['data']) { + override setData(data: IAlertBoxState['data']) { // save widget data instate and calculate additional state variables super.setData(data); - const settings = data.settings; const allAlerts = values(this.eventsConfig) as IAlertConfig[]; // group alertbox settings by alert types and store them in `state.data.variations` - allAlerts.map(alertEvent => { - const apiKey = alertEvent.apiKey || alertEvent.type; - const alertFields = Object.keys(settings).filter(key => key.startsWith(`${apiKey}_`)); - const variationSettings = {} as any; - alertFields.forEach(key => { - let value = settings[key]; - const targetKey = key.replace(`${apiKey}_`, ''); - - // sanitize the variation value - value = this.sanitizeValue( - value, - targetKey, - this.variationsMetadata[alertEvent.type][targetKey], - ); - - settings[key] = value; - variationSettings[targetKey] = value; + this.state.mutate(state => { + const settings = this.state.widgetData.data.settings; + + allAlerts.map(alertEvent => { + const apiKey = alertEvent.apiKey || alertEvent.type; + const alertFields = Object.keys(settings).filter(key => key.startsWith(`${apiKey}_`)); + const variationSettings = {} as any; + alertFields.forEach(key => { + let value = settings[key]; + const targetKey = key.replace(`${apiKey}_`, ''); + + // sanitize the variation value + value = this.sanitizeValue( + value, + targetKey, + this.variationsMetadata[alertEvent.type][targetKey], + ); + + settings[key] = value; + variationSettings[targetKey] = value; + }); + this.setVariationSettings(alertEvent.type, 'default', variationSettings as any); }); - this.setVariationSettings(alertEvent.type, 'default', variationSettings as any); }); + // define available alerts const userPlatforms = Object.keys(Services.UserService.views.platforms!) as TPlatform[]; const availableAlerts = allAlerts @@ -220,7 +221,7 @@ export class AlertBoxModule extends WidgetModule { if (alertConfig.platforms && !intersection(alertConfig.platforms, userPlatforms).length) { return false; } - return !!this.state.data.variations[alertConfig.type]; + return !!this.widgetData.variations[alertConfig.type]; }) .map(alertConfig => alertConfig.type); this.setAvailableAlerts(availableAlerts); @@ -293,7 +294,7 @@ export class AlertBoxModule extends WidgetModule { ) { const event = this.eventsConfig[type]; const apiKey = event.apiKey || event.type; - const currentVariationSettings = this.getVariationSettings(type); + const currentVariationSettings = getDefined(this.getVariationSettings(type)); // save current settings to the state const newVariationSettings = { @@ -310,7 +311,7 @@ export class AlertBoxModule extends WidgetModule { // set the same message template for all Cheer variations if (type === 'twCheer') { - const newBitsVariations = this.state.data.settings.bit_variations.map((variation: any) => { + const newBitsVariations = this.widgetData.settings.bit_variations.map((variation: any) => { const newVariation = cloneDeep(variation); newVariation.settings.text.format = newVariationSettings.message_template; return newVariation; @@ -319,7 +320,7 @@ export class AlertBoxModule extends WidgetModule { } // save flatten setting in store and save them on the server - this.updateSettings({ ...this.state.data.settings, ...settingsPatch }); + this.updateSettings({ ...this.widgetData.settings, ...settingsPatch }); } openAlertInfo(alertType: TAlertType) { @@ -335,13 +336,11 @@ export class AlertBoxModule extends WidgetModule { return null; } - /** - * @override - */ - get customCode() { + override get customCode() { // get custom code from the selected variation if (!this.selectedAlert) return null; const variationSettings = this.getVariationSettings(this.selectedAlert); + if (!variationSettings) return null; const { custom_html_enabled, custom_html, @@ -358,10 +357,7 @@ export class AlertBoxModule extends WidgetModule { }; } - /** - * @override - */ - updateCustomCode(patch: Partial) { + override updateCustomCode(patch: Partial) { // save custom code from the selected variation const selectedAlert = getDefined(this.selectedAlert); const newPatch = cloneDeep(patch) as Partial & { custom_html_enabled?: boolean }; @@ -381,7 +377,7 @@ export class AlertBoxModule extends WidgetModule { variationId: string, settings: TVariationsSettings[TAlertType], ) { - const state = this.state; + const state = this.widgetState; if (!state.data.variations) state.data.variations = {} as any; if (!state.data.variations[type]) state.data.variations[type] = {} as any; state.data.variations[type][variationId] = settings; @@ -399,7 +395,7 @@ export class AlertBoxModule extends WidgetModule { // TODO: fbSupportGift is impossible to enable on backend alerts = alerts.filter(alert => alert !== 'fbSupportGift'); - this.state.availableAlerts = alerts; + this.widgetState.availableAlerts = alerts; } } diff --git a/app/components-react/windows/NameFolder.tsx b/app/components-react/windows/NameFolder.tsx index 871949b826e0..a612851c7d65 100644 --- a/app/components-react/windows/NameFolder.tsx +++ b/app/components-react/windows/NameFolder.tsx @@ -2,7 +2,7 @@ import { ModalLayout } from '../shared/ModalLayout'; import { $t } from '../../services/i18n'; import React, { useState } from 'react'; import { Services } from '../service-provider'; -import { useOnCreate } from '../hooks'; +import { useOnCreate } from 'slap'; import { assertIsDefined } from '../../util/properties-type-guards'; import { TextInput } from '../shared/inputs/TextInput'; import { Button } from 'antd'; diff --git a/app/components-react/windows/SourceProperties.tsx b/app/components-react/windows/SourceProperties.tsx index f19e6cd1f46c..de67be074e28 100644 --- a/app/components-react/windows/SourceProperties.tsx +++ b/app/components-react/windows/SourceProperties.tsx @@ -2,11 +2,11 @@ import React, { useMemo, useState } from 'react'; import { Services } from '../service-provider'; import { IObsFormProps, ObsForm } from '../obs/ObsForm'; import { TObsFormData } from '../../components/obs/inputs/ObsInput'; -import { useSelector } from '../store'; import { ModalLayout } from '../shared/ModalLayout'; import Display from '../shared/Display'; import { assertIsDefined } from '../../util/properties-type-guards'; import { useSubscription } from '../hooks/useSubscription'; +import { useVuex } from '../hooks'; export default function SourceProperties() { const { @@ -26,7 +26,7 @@ export default function SourceProperties() { const [properties, setProperties] = useState(() => source ? source.getPropertiesFormData() : [], ); - const hideStyleBlockers = useSelector(() => WindowsService.state.child.hideStyleBlockers); + const hideStyleBlockers = useVuex(() => WindowsService.state.child.hideStyleBlockers); // close the window if the source has been deleted useSubscription(SourcesService.sourceRemoved, removedSource => { diff --git a/app/components-react/windows/advanced-audio/GlobalSettings.tsx b/app/components-react/windows/advanced-audio/GlobalSettings.tsx index 3b48fba1f22d..8e0ce69bda45 100644 --- a/app/components-react/windows/advanced-audio/GlobalSettings.tsx +++ b/app/components-react/windows/advanced-audio/GlobalSettings.tsx @@ -1,5 +1,4 @@ -import React, { useState, useRef, useMemo } from 'react'; -import { Provider } from 'react-redux'; +import React from 'react'; import { BoolButtonInput, ListInput, SwitchInput } from 'components-react/shared/inputs'; import InputWrapper from 'components-react/shared/inputs/InputWrapper'; import { TObsValue, IObsListInput, IObsInput } from 'components/obs/inputs/ObsInput'; @@ -9,7 +8,6 @@ import { $t } from 'services/i18n'; import Utils from 'services/utils'; import styles from './AdvancedAudio.m.less'; import { ObsSettings, ObsSettingsSection } from '../../windows/settings/ObsSettings'; -import { store } from '../../store'; const trackOptions = [ { label: '1', value: 1 }, @@ -136,9 +134,8 @@ export default function GlobalSettings() { )} - - - + + ); } diff --git a/app/components-react/windows/go-live/DestinationSwitchers.tsx b/app/components-react/windows/go-live/DestinationSwitchers.tsx index 8c35cc69f40a..0ac109969bbe 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.tsx +++ b/app/components-react/windows/go-live/DestinationSwitchers.tsx @@ -22,8 +22,9 @@ export function DestinationSwitchers() { customDestinations, switchPlatforms, switchCustomDestination, - checkPrimaryPlatform, - } = useGoLiveSettings().select(); + isPrimaryPlatform, + componentView, + } = useGoLiveSettings(); const enabledPlatformsRef = useRef(enabledPlatforms); enabledPlatformsRef.current = enabledPlatforms; @@ -49,7 +50,7 @@ export function DestinationSwitchers() { destination={platform} enabled={isEnabled(platform)} onChange={enabled => togglePlatform(platform, enabled)} - isPrimary={checkPrimaryPlatform(platform)} + isPrimary={isPrimaryPlatform(platform)} /> ))} {customDestinations?.map((dest, ind) => ( diff --git a/app/components-react/windows/go-live/EditStreamWindow.tsx b/app/components-react/windows/go-live/EditStreamWindow.tsx index 05c5a235a306..aecb76aedbdb 100644 --- a/app/components-react/windows/go-live/EditStreamWindow.tsx +++ b/app/components-react/windows/go-live/EditStreamWindow.tsx @@ -1,12 +1,12 @@ import styles from './GoLive.m.less'; import { ModalLayout } from '../../shared/ModalLayout'; import { Button } from 'antd'; -import { useOnCreate } from '../../hooks'; +import { useOnCreate } from 'slap'; import { Services } from '../../service-provider'; import React from 'react'; import { $t } from '../../../services/i18n'; import GoLiveChecklist from './GoLiveChecklist'; -import Form, { useForm } from '../../shared/inputs/Form'; +import Form from '../../shared/inputs/Form'; import Animation from 'rc-animate'; import { SwitchInput } from '../../shared/inputs'; import { useGoLiveSettingsRoot } from './useGoLiveSettings'; @@ -27,7 +27,7 @@ export default function EditStreamWindow() { prepopulate, isLoading, form, - } = useGoLiveSettingsRoot({ isUpdateMode: true }).select(); + } = useGoLiveSettingsRoot({ isUpdateMode: true }); const shouldShowChecklist = lifecycle === 'runChecklist'; const shouldShowSettings = !shouldShowChecklist; diff --git a/app/components-react/windows/go-live/GameSelector.tsx b/app/components-react/windows/go-live/GameSelector.tsx index 1238c878267b..34636528f4eb 100644 --- a/app/components-react/windows/go-live/GameSelector.tsx +++ b/app/components-react/windows/go-live/GameSelector.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { getPlatformService, IGame, @@ -7,9 +7,9 @@ import { } from '../../../services/platforms'; import { ListInput, TSlobsInputProps } from '../../shared/inputs'; import { $t } from '../../../services/i18n'; -import { useFormState } from '../../hooks'; import { IListOption } from '../../shared/inputs/ListInput'; import { Services } from '../../service-provider'; +import { injectState, useModule } from 'slap'; type TProps = TSlobsInputProps<{ platform: TPlatform }, string>; @@ -23,19 +23,18 @@ export default function GameSelector(p: TProps) { selectedGameName = Services.TrovoService.state.channelInfo.gameName; } - function fetchGames(query: string): Promise { - return platformService.searchGames(query); - } - - const { s, updateState } = useFormState(() => { - return { + const { isSearching, setIsSearching, games, setGames } = useModule(() => ({ + state: injectState({ + isSearching: false, games: selectedGameId ? [{ label: selectedGameName, value: selectedGameId }] : ([] as IListOption[]), - }; - }); + }), + })); - const [isSearching, setIsSearching] = useState(false); + function fetchGames(query: string): Promise { + return platformService.searchGames(query); + } useEffect(() => { loadImageForSelectedGame(); @@ -47,11 +46,9 @@ export default function GameSelector(p: TProps) { if (!selectedGameName) return; const game = await platformService.fetchGame(selectedGameName); if (!game || game.name !== selectedGameName) return; - updateState({ - games: s.games.map(opt => - opt.value === selectedGameId ? { ...opt, image: game.image } : opt, - ), - }); + setGames(games.map(opt => + opt.value === selectedGameId ? { ...opt, image: game.image } : opt, + )); } async function onSearch(searchString: string) { @@ -61,7 +58,7 @@ export default function GameSelector(p: TProps) { label: g.name, image: g.image, })); - updateState({ games }); + setGames(games); setIsSearching(false); } @@ -77,7 +74,7 @@ export default function GameSelector(p: TProps) { twitch: $t('Twitch Category'), facebook: $t('Facebook Game'), trovo: $t('Trovo Category'), - }[platform]; + }[platform as string]; return ( ) { isUpdateMode, shouldShowOptimizedProfile, shouldPostTweet, - } = useGoLiveSettings().selectExtra(module => ({ - shouldShowOptimizedProfile: - VideoEncodingOptimizationService.state.useOptimizedProfile && !module.isUpdateMode, - shouldPostTweet: !module.isUpdateMode && TwitterService.state.tweetWhenGoingLive, + } = useGoLiveSettings().extend(module => ({ + + get shouldShowOptimizedProfile() { + return VideoEncodingOptimizationService.state.useOptimizedProfile && !module.isUpdateMode; + }, + + get shouldPostTweet() { + return !module.isUpdateMode && TwitterService.state.tweetWhenGoingLive; + }, })); const success = lifecycle === 'live'; diff --git a/app/components-react/windows/go-live/GoLiveSettings.tsx b/app/components-react/windows/go-live/GoLiveSettings.tsx index aa8bfe398a49..4a90b9d88563 100644 --- a/app/components-react/windows/go-live/GoLiveSettings.tsx +++ b/app/components-react/windows/go-live/GoLiveSettings.tsx @@ -23,36 +23,41 @@ const PlusIcon = PlusOutlined as Function; * - Extras settings **/ export default function GoLiveSettings() { - const { RestreamService, SettingsService, UserService, MagicLinkService } = Services; - const { + addDestination, isAdvancedMode, protectedModeEnabled, error, isLoading, canAddDestinations, - } = useGoLiveSettings().selectExtra(module => { - const linkedPlatforms = module.linkedPlatforms; - const customDestinations = module.customDestinations; + shouldShowPrimeLabel, + } = useGoLiveSettings().extend(module => { + const { RestreamService, SettingsService, UserService, MagicLinkService } = Services; + return { - canAddDestinations: linkedPlatforms.length + customDestinations.length < 5, + get canAddDestinations() { + const linkedPlatforms = module.state.linkedPlatforms; + const customDestinations = module.state.customDestinations; + return linkedPlatforms.length + customDestinations.length < 5; + }, + + addDestination() { + // open the stream settings or prime page + if (UserService.views.isPrime) { + SettingsService.actions.showSettings('Stream'); + } else { + MagicLinkService.linkToPrime('slobs-multistream'); + } + }, + + shouldShowPrimeLabel: !RestreamService.state.grandfathered, }; }); const shouldShowSettings = !error && !isLoading; - const shouldShowPrimeLabel = !RestreamService.state.grandfathered; const shouldShowLeftCol = protectedModeEnabled; const shouldShowAddDestButton = canAddDestinations; - function addDestination() { - // open the stream settings or prime page - if (UserService.views.isPrime) { - SettingsService.actions.showSettings('Stream'); - } else { - MagicLinkService.linkToPrime('slobs-multistream'); - } - } - return ( {/*LEFT COLUMN*/} diff --git a/app/components-react/windows/go-live/GoLiveWindow.tsx b/app/components-react/windows/go-live/GoLiveWindow.tsx index b511161432dc..804191416b3e 100644 --- a/app/components-react/windows/go-live/GoLiveWindow.tsx +++ b/app/components-react/windows/go-live/GoLiveWindow.tsx @@ -1,7 +1,7 @@ import styles from './GoLive.m.less'; +import { WindowsService } from 'app-services'; import { ModalLayout } from '../../shared/ModalLayout'; import { Button } from 'antd'; -import { useOnDestroy } from '../../hooks'; import { Services } from '../../service-provider'; import GoLiveSettings from './GoLiveSettings'; import React from 'react'; @@ -10,81 +10,26 @@ import GoLiveChecklist from './GoLiveChecklist'; import Form from '../../shared/inputs/Form'; import Animation from 'rc-animate'; import { SwitchInput } from '../../shared/inputs'; -import { useGoLiveSettingsRoot } from './useGoLiveSettings'; +import { useGoLiveSettings, useGoLiveSettingsRoot } from './useGoLiveSettings'; +import { inject } from 'slap'; export default function GoLiveWindow() { - const { StreamingService, WindowsService } = Services; - const { - error, - lifecycle, - checklist, - isMultiplatformMode, - goLive, - isAdvancedMode, - switchAdvancedMode, - prepopulate, - isLoading, - form, - } = useGoLiveSettingsRoot().select(); + const { lifecycle, form } = useGoLiveSettingsRoot().extend(module => ({ + destroy() { + // clear failed checks and warnings on window close + if (module.checklist.startVideoTransmission !== 'done') { + Services.StreamingService.actions.resetInfo(); + } + }, + })); - const shouldShowConfirm = ['prepopulate', 'waitForNewSettings'].includes(lifecycle); const shouldShowSettings = ['empty', 'prepopulate', 'waitForNewSettings'].includes(lifecycle); const shouldShowChecklist = ['runChecklist', 'live'].includes(lifecycle); - const shouldShowAdvancedSwitch = shouldShowConfirm && isMultiplatformMode; - const shouldShowGoBackButton = - lifecycle === 'runChecklist' && error && checklist.startVideoTransmission !== 'done'; - - // clear failed checks and warnings on window close - useOnDestroy(() => { - if (checklist.startVideoTransmission !== 'done') { - StreamingService.actions.resetInfo(); - } - }); - - function close() { - WindowsService.actions.closeChildWindow(); - } - - function goBackToSettings() { - prepopulate(); - } - - function renderFooter() { - return ( -
- {shouldShowAdvancedSwitch && ( - - )} - - {/* CLOSE BUTTON */} - - - {/* GO BACK BUTTON */} - {shouldShowGoBackButton && ( - - )} - - {/* GO LIVE BUTTON */} - {shouldShowConfirm && ( - - )} - - ); - } return ( - + }>
); } + +function ModalFooter() { + + const { + error, + lifecycle, + checklist, + isMultiplatformMode, + goLive, + isAdvancedMode, + switchAdvancedMode, + close, + goBackToSettings, + isLoading, + } = useGoLiveSettings().extend(module => ({ + windowsService: inject(WindowsService), + + close() { + this.windowsService.actions.closeChildWindow(); + }, + + goBackToSettings() { + module.prepopulate(); + }, + })); + + const shouldShowConfirm = ['prepopulate', 'waitForNewSettings'].includes(lifecycle); + const shouldShowAdvancedSwitch = shouldShowConfirm && isMultiplatformMode; + const shouldShowGoBackButton = + lifecycle === 'runChecklist' && error && checklist.startVideoTransmission !== 'done'; + + return ( + + {shouldShowAdvancedSwitch && ( + + )} + + {/* CLOSE BUTTON */} + + + {/* GO BACK BUTTON */} + {shouldShowGoBackButton && ( + + )} + + {/* GO LIVE BUTTON */} + {shouldShowConfirm && ( + + )} + + ); +} diff --git a/app/components-react/windows/go-live/OptimizedProfileSwitcher.tsx b/app/components-react/windows/go-live/OptimizedProfileSwitcher.tsx index 377ae76479e2..da5bf915b3ce 100644 --- a/app/components-react/windows/go-live/OptimizedProfileSwitcher.tsx +++ b/app/components-react/windows/go-live/OptimizedProfileSwitcher.tsx @@ -1,47 +1,59 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useGoLiveSettings } from './useGoLiveSettings'; import { $t } from '../../../services/i18n'; -import { useVuex } from '../../hooks'; import { CheckboxInput } from '../../shared/inputs'; import { Services } from '../../service-provider'; import InputWrapper from '../../shared/inputs/InputWrapper'; +import { inject, injectQuery } from 'slap'; +import { VideoEncodingOptimizationService } from '../../../app-services'; export default function OptimizedProfileSwitcher() { - const { VideoEncodingOptimizationService } = Services; - const { game, optimizedProfile, updateSettings } = useGoLiveSettings(); - const enabled = useVuex(() => VideoEncodingOptimizationService.state.useOptimizedProfile, false); - const actions = VideoEncodingOptimizationService.actions; - - function setEnabled(enabled: boolean) { - VideoEncodingOptimizationService.actions.return.useOptimizedProfile(enabled); - } - - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - loadAvailableProfiles(); - }, [game]); - - async function loadAvailableProfiles() { - setIsLoading(true); - const optimizedProfile = await actions.return.fetchOptimizedProfile(game); - updateSettings({ optimizedProfile }); - setIsLoading(false); - } - - const label = - optimizedProfile?.game && optimizedProfile.game !== 'DEFAULT' - ? $t('Use optimized encoder settings for %{game}', { game }) - : $t('Use optimized encoder settings'); - const tooltip = $t( - 'Optimized encoding provides better quality and/or lower cpu/gpu usage. Depending on the game, ' + - 'resolution may be changed for a better quality of experience', - ); + const { + game, + enabled, + setEnabled, + label, + tooltip, + optimizedProfileQuery, + } = useGoLiveSettings().extend(settings => { + const optimizationService = inject(VideoEncodingOptimizationService); + + async function fetchProfile(game: string) { + const optimizedProfile = await optimizationService.actions.return.fetchOptimizedProfile(game); + settings.updateSettings({ optimizedProfile }); + } + + const optimizedProfileQuery = injectQuery(fetchProfile, () => settings.game); + + return { + optimizedProfileQuery, + + get enabled() { + return optimizationService.state.useOptimizedProfile; + }, + + setEnabled(enabled: boolean) { + optimizationService.actions.useOptimizedProfile(enabled); + }, + + tooltip: $t( + 'Optimized encoding provides better quality and/or lower cpu/gpu usage. Depending on the game, ' + + 'resolution may be changed for a better quality of experience', + ), + + get label(): string { + return settings.state.optimizedProfile?.game && + settings.state.optimizedProfile?.game !== 'DEFAULT' + ? $t('Use optimized encoder settings for %{game}', { game }) + : $t('Use optimized encoder settings'); + }, + }; + }); return ( - {isLoading && $t('Checking optimized setting for %{game}', { game })} - {!isLoading && ( + {optimizedProfileQuery.isLoading && $t('Checking optimized setting for %{game}', { game })} + {!optimizedProfileQuery.isLoading && ( )} diff --git a/app/components-react/windows/go-live/PlatformSettings.tsx b/app/components-react/windows/go-live/PlatformSettings.tsx index 383dea650986..a3b671defa2f 100644 --- a/app/components-react/windows/go-live/PlatformSettings.tsx +++ b/app/components-react/windows/go-live/PlatformSettings.tsx @@ -27,11 +27,15 @@ export default function PlatformSettings() { descriptionIsRequired, getPlatformSettings, isUpdateMode, - } = useGoLiveSettings().selectExtra(settings => { - const fbSettings = settings.platforms['facebook']; - const descriptionIsRequired = fbSettings && fbSettings.enabled && !fbSettings.useCustomFields; - return { descriptionIsRequired }; - }); + } = useGoLiveSettings().extend(settings => ({ + + get descriptionIsRequired() { + const fbSettings = settings.state.platforms['facebook']; + const descriptionIsRequired = fbSettings && fbSettings.enabled && !fbSettings.useCustomFields; + return descriptionIsRequired; + } + + })); const shouldShowSettings = !error && !isLoading; diff --git a/app/components-react/windows/go-live/Twitter.tsx b/app/components-react/windows/go-live/Twitter.tsx index c3c010a1a6a2..b640a98e6c1c 100644 --- a/app/components-react/windows/go-live/Twitter.tsx +++ b/app/components-react/windows/go-live/Twitter.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import InputWrapper from '../../shared/inputs/InputWrapper'; import { Services } from '../../service-provider'; import cx from 'classnames'; @@ -7,39 +7,61 @@ import css from './Twitter.m.less'; import { CheckboxInput, SwitchInput, TextAreaInput, TextInput } from '../../shared/inputs'; import { Row, Col, Button } from 'antd'; import { useGoLiveSettings } from './useGoLiveSettings'; -import { useVuex } from '../../hooks'; +import { injectWatch } from 'slap'; export default function TwitterInput() { const { TwitterService, UserService } = Services; const { tweetText, updateSettings, - getTweetText, - getSettings, - streamTitle, tweetWhenGoingLive, linked, screenName, platform, useStreamlabsUrl, - } = useGoLiveSettings().selectExtra(module => { - const state = TwitterService.state; + } = useGoLiveSettings().extend(module => { + + function getTwitterState() { + return { + streamTitle: module.state.commonFields.title, + useStreamlabsUrl: TwitterService.state.creatorSiteOnboardingComplete, + }; + } + return { - streamTitle: module.commonFields.title, - tweetWhenGoingLive: state.tweetWhenGoingLive, - useStreamlabsUrl: state.creatorSiteOnboardingComplete, - linked: state.linked, - screenName: state.screenName, - platform: UserService.views.platform?.type, - url: TwitterService.views.url, + get streamTitle() { + return module.state.commonFields.title; + }, + get tweetWhenGoingLive() { + return TwitterService.state.tweetWhenGoingLive; + }, + get useStreamlabsUrl() { + return TwitterService.state.creatorSiteOnboardingComplete; + }, + + get linked() { + return TwitterService.state.linked; + }, + + get screenName() { + return TwitterService.state.screenName; + }, + + get platform() { + return UserService.views.platform?.type; + }, + + get url() { + return TwitterService.views.url; + }, + + tweetTextWatch: injectWatch(getTwitterState, () => { + const tweetText = module.getTweetText(getTwitterState().streamTitle); + module.updateSettings({ tweetText }); + }), }; }); - useEffect(() => { - const tweetText = getTweetText(streamTitle); - if (getSettings().tweetText !== tweetText) updateSettings({ tweetText }); - }, [streamTitle, useStreamlabsUrl]); - function unlink() { TwitterService.actions.return .unlinkTwitter() diff --git a/app/components-react/windows/go-live/platforms/FacebookEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/FacebookEditStreamInfo.tsx index 210baa582810..46a75ac5c464 100644 --- a/app/components-react/windows/go-live/platforms/FacebookEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/FacebookEditStreamInfo.tsx @@ -1,165 +1,152 @@ import css from './FacebookEditStreamInfo.m.less'; import { CommonPlatformFields } from '../CommonPlatformFields'; import React from 'react'; -import { Services } from '../../../service-provider'; +import { inject, injectFormBinding, injectState, useModule } from 'slap'; import Form from '../../../shared/inputs/Form'; -import { useOnCreate, useFormState } from '../../../hooks'; -import { EDismissable } from '../../../../services/dismissables'; -import { $t } from '../../../../services/i18n'; -import { createBinding, ListInput } from '../../../shared/inputs'; +import { DismissablesService, EDismissable } from 'services/dismissables'; +import { StreamingService } from 'services/streaming'; +import { UserService } from 'services/user'; +import { NavigationService } from 'services/navigation'; +import { ListInput } from '../../../shared/inputs'; import GameSelector from '../GameSelector'; import { + FacebookService, IFacebookLiveVideoExtended, IFacebookStartStreamOptions, TDestinationType, TFacebookStreamPrivacy, -} from '../../../../services/platforms/facebook'; +} from 'services/platforms/facebook'; import moment from 'moment'; import Translate from '../../../shared/Translate'; import { IListOption } from '../../../shared/inputs/ListInput'; import MessageLayout from '../MessageLayout'; -import PlatformSettingsLayout, { IPlatformComponentParams } from './PlatformSettingsLayout'; -import { useSelector } from '../../../store'; +import PlatformSettingsLayout, { + IPlatformComponentParams, + TLayoutMode +} from './PlatformSettingsLayout'; import { assertIsDefined } from '../../../../util/properties-type-guards'; import * as remote from '@electron/remote'; +import { $t } from 'services/i18n'; +import { Services } from '../../../service-provider'; -export default function FacebookEditStreamInfo(p: IPlatformComponentParams<'facebook'>) { - const fbSettings = p.value; - const { isUpdateMode, isScheduleMode } = p; - // inject services - const { - FacebookService, - DismissablesService, - StreamingService, - UserService, - NavigationService, - WindowsService, - } = Services; +class FacebookEditStreamInfoModule { - const { - pages, - groups, - canStreamToTimeline, - canStreamToGroup, - isPrimary, - shouldShowGamingWarning, - shouldShowPermissionWarn, - } = useSelector(() => { - const fbState = FacebookService.state; - const hasPages = !!fbState.facebookPages.length; - const canStreamToTimeline = fbState.grantedPermissions.includes('publish_video'); - const canStreamToGroup = fbState.grantedPermissions.includes('publish_to_groups'); - const view = StreamingService.views; - return { - canStreamToTimeline, - canStreamToGroup, - hasPages, - shouldShowGamingWarning: hasPages && fbSettings.game, - shouldShowPermissionWarn: - (!canStreamToTimeline || !canStreamToGroup) && - DismissablesService.views.shouldShow(EDismissable.FacebookNeedPermissionsTip), - groups: fbState.facebookGroups, - pages: fbState.facebookPages, - isPrimary: view.checkPrimaryPlatform('facebook'), - }; + fbService = inject(FacebookService); + dismissables = inject(DismissablesService); + streamingService = inject(StreamingService); + state = injectState({ + pictures: {} as Record, + scheduledVideos: [] as IFacebookLiveVideoExtended[], + scheduledVideosLoaded: false, }); - const shouldShowDestinationType = !fbSettings.liveVideoId; - const shouldShowGroups = - fbSettings.destinationType === 'group' && !isUpdateMode && !fbSettings.liveVideoId; - const shouldShowPages = - fbSettings.destinationType === 'page' && !isUpdateMode && !fbSettings.liveVideoId; - const shouldShowEvents = !isUpdateMode && !isScheduleMode; - const shouldShowPrivacy = fbSettings.destinationType === 'me'; - const shouldShowPrivacyWarn = - (!fbSettings.liveVideoId && fbSettings.privacy?.value !== 'SELF') || - (fbSettings.liveVideoId && fbSettings.privacy?.value); - const shouldShowGame = !isUpdateMode; - - function updateSettings(patch: Partial) { - p.onChange({ ...fbSettings, ...patch }); + fbState = this.fbService.state; + canStreamToTimeline = this.fbState.grantedPermissions.includes('publish_video'); + canStreamToGroup = this.fbState.grantedPermissions.includes('publish_to_groups'); + pages = this.fbState.facebookPages; + groups = this.fbState.facebookGroups; + isPrimary = this.streamingService.views.isPrimaryPlatform('facebook'); + isScheduleMode = false; + props: IPlatformComponentParams<'facebook'>; + + get settings() { + return this.props.value; } - const bind = createBinding(fbSettings, newFbSettings => updateSettings(newFbSettings)); + setProps(props: IPlatformComponentParams<'facebook'>) { + this.props = props; + if (!this.state.scheduledVideosLoaded) this.loadScheduledBroadcasts(); + if (this.settings.pageId) this.loadPicture(this.settings.pageId); + if (this.settings.groupId) this.loadPicture(this.settings.groupId); + } - // define the local state - const { s, setItem, updateState } = useFormState({ - pictures: {} as Record, - scheduledVideos: [] as IFacebookLiveVideoExtended[], - scheduledVideosLoaded: false, - }); + updateSettings(patch: Partial) { + this.props.onChange({ ...this.settings, ...patch }); + } - useOnCreate(() => { - loadScheduledBroadcasts(); - if (fbSettings.pageId) loadPicture(fbSettings.pageId); - if (fbSettings.groupId) loadPicture(fbSettings.groupId); - }); + setPrivacy(privacy: TFacebookStreamPrivacy) { + this.updateSettings({ privacy: { value: privacy } }); + } - function setPrivacy(privacy: TFacebookStreamPrivacy) { - updateSettings({ privacy: { value: privacy } }); + bind = injectFormBinding( + () => this.settings, + newFbSettings => this.updateSettings(newFbSettings), + ); + + get layoutMode() { + return this.props.layoutMode; } - async function loadScheduledBroadcasts() { - let destinationId = FacebookService.views.getDestinationId(fbSettings); - if (!destinationId) return; + get isUpdateMode() { + return this.props.isUpdateMode; + } - // by some unknown reason FB returns scheduled events for groups - // only if you request these events from the user's personal page - const destinationType = - fbSettings.destinationType === 'group' ? 'me' : fbSettings.destinationType; - if (destinationType === 'me') destinationId = 'me'; + get shouldShowGamingWarning() { + return this.pages.length && this.settings.game; + } - const scheduledVideos = await FacebookService.actions.return.fetchAllVideos(true); - const selectedVideoId = fbSettings.liveVideoId; - const shouldFetchSelectedVideo = - selectedVideoId && !scheduledVideos.find(v => v.id === selectedVideoId); + get shouldShowPermissionWarn() { + return (!this.canStreamToTimeline || !this.canStreamToGroup) && + this.dismissables.views.shouldShow(EDismissable.FacebookNeedPermissionsTip); + } - if (shouldFetchSelectedVideo) { - assertIsDefined(selectedVideoId); - const selectedVideo = await FacebookService.actions.return.fetchVideo( - selectedVideoId, - destinationType, - destinationId, - ); - scheduledVideos.push(selectedVideo); - } + get shouldShowDestinationType() { + return !this.settings.liveVideoId; + } - updateState({ - scheduledVideos, - scheduledVideosLoaded: true, - }); + get shouldShowGroups() { + return this.settings.destinationType === 'group' && !this.isUpdateMode && !this.settings.liveVideoId; } - async function loadPicture(objectId: string) { - if (s.pictures[objectId]) return s.pictures[objectId]; - setItem('pictures', objectId, await FacebookService.actions.return.fetchPicture(objectId)); + get shouldShowPages() { + return this.settings.destinationType === 'page' && !this.isUpdateMode && !this.settings.liveVideoId; } - function loadPictures(groupOrPage: IFacebookStartStreamOptions['destinationType']) { - const ids = - groupOrPage === 'group' - ? FacebookService.state.facebookGroups.map(item => item.id) - : FacebookService.state.facebookPages.map(item => item.id); - ids.forEach(id => loadPicture(id)); + get shouldShowEvents() { + return !this.isUpdateMode && !this.props.isScheduleMode; + } + + get shouldShowGame() { + return !this.isUpdateMode; } - function verifyGroup() { - const groupId = fbSettings.groupId; - remote.shell.openExternal(`https://www.facebook.com/groups/${groupId}/edit`); + get shouldShowPrivacy() { + return this.settings.destinationType === 'me'; } - function dismissWarning() { - DismissablesService.actions.dismiss(EDismissable.FacebookNeedPermissionsTip); + get shouldShowPrivacyWarn() { + const fbSettings = this.settings; + return !!((!fbSettings.liveVideoId && fbSettings.privacy?.value !== 'SELF') || + (fbSettings.liveVideoId && fbSettings.privacy?.value)); } - function reconnectFB() { - const platform = 'facebook'; - NavigationService.actions.navigate('PlatformMerge', { platform }); - WindowsService.actions.closeChildWindow(); + getDestinationOptions(): IListOption[] { + const options: IListOption[] = [ + { + value: 'me' as TDestinationType, + label: $t('Share to Your Timeline'), + image: this.fbState.userAvatar, + }, + { + value: 'page' as TDestinationType, + label: $t('Share to a Page You Manage'), + image: 'https://slobs-cdn.streamlabs.com/media/fb-page.png', + }, + { + value: 'group' as TDestinationType, + label: $t('Share in a Group'), + image: 'https://slobs-cdn.streamlabs.com/media/fb-group.png', + }, + ].filter(opt => { + if (opt.value === 'me' && !this.canStreamToTimeline) return false; + if (opt.value === 'group' && !this.canStreamToGroup) return false; + return true; + }); + return options; } - function getPrivacyOptions(): IListOption[] { + getPrivacyOptions(): IListOption[] { const options: any = [ { value: 'EVERYONE', @@ -179,22 +166,17 @@ export default function FacebookEditStreamInfo(p: IPlatformComponentParams<'face ]; // we cant read the privacy property of already created video - if (fbSettings.liveVideoId || isUpdateMode) { + if (this.settings.liveVideoId || this.isUpdateMode) { options.unshift({ value: '', label: $t('Do not change privacy settings') }); } return options; } - async function reLogin() { - await UserService.actions.return.reLogin(); - StreamingService.actions.showGoLiveWindow(); - } - - async function onEventChange(liveVideoId: string) { + async onEventChange(liveVideoId: string) { if (!liveVideoId) { // reset destination settings if event has been unselected - const { groupId, pageId } = FacebookService.state.settings; - updateSettings({ + const { groupId, pageId } = this.fbState.settings; + this.updateSettings({ liveVideoId, pageId, groupId, @@ -202,226 +184,304 @@ export default function FacebookEditStreamInfo(p: IPlatformComponentParams<'face return; } - const liveVideo = s.scheduledVideos.find(vid => vid.id === liveVideoId); + const liveVideo = this.state.scheduledVideos.find(vid => vid.id === liveVideoId); assertIsDefined(liveVideo); - const newSettings = await FacebookService.actions.return.fetchStartStreamOptionsForVideo( + const newSettings = await this.fbService.actions.return.fetchStartStreamOptionsForVideo( liveVideoId, liveVideo.destinationType, liveVideo.destinationId, ); - updateSettings(newSettings); + this.updateSettings(newSettings); } - function renderCommonFields() { - return ( - - ); - } + private async loadScheduledBroadcasts() { + let destinationId = this.fbService.views.getDestinationId(this.settings); + if (!destinationId) return; + const fbSettings = this.settings; + const fbService = this.fbService; - function renderRequiredFields() { - return ( -
- {!isUpdateMode && ( - <> - {shouldShowDestinationType && ( - - )} - {shouldShowPages && ( - shown && loadPictures('page')} - options={pages.map(page => ({ - value: page.id, - label: `${page.name} | ${page.category}`, - image: s.pictures[page.id], - }))} - /> - )} - {shouldShowGroups && ( - <> - ({ - value: group.id, - label: group.name, - image: s.pictures[group.id], - }))} - defaultActiveFirstOption - onDropdownVisibleChange={() => loadPictures('group')} - extra={ -

- {$t('Make sure the Streamlabs app is added to your Group.')} - {$t('Click here to verify.')} -

- } - /> - - )} - - )} -
- ); - } + // by some unknown reason FB returns scheduled events for groups + // only if you request these events from the user's personal page + const destinationType = + fbSettings.destinationType === 'group' ? 'me' : fbSettings.destinationType; + if (destinationType === 'me') destinationId = 'me'; - function renderEvents() { - return ( -
- {shouldShowEvents && ( - ({ - label: `${v.title} ${ - v.planned_start_time ? moment(new Date(v.planned_start_time)).calendar() : '' - }`, - value: v.id, - })), - ]} - /> - )} -
- ); + const scheduledVideos = await fbService.actions.return.fetchAllVideos(true); + const selectedVideoId = fbSettings.liveVideoId; + const shouldFetchSelectedVideo = + selectedVideoId && !scheduledVideos.find(v => v.id === selectedVideoId); + + if (shouldFetchSelectedVideo) { + assertIsDefined(selectedVideoId); + const selectedVideo = await fbService.actions.return.fetchVideo( + selectedVideoId, + destinationType, + destinationId, + ); + scheduledVideos.push(selectedVideo); + } + + this.state.update({ + scheduledVideos, + scheduledVideosLoaded: true, + }); } - function renderOptionalFields() { - return ( - - ); + loadPictures(groupOrPage: IFacebookStartStreamOptions['destinationType']) { + const ids = + groupOrPage === 'group' + ? this.fbState.facebookGroups.map(item => item.id) + : this.fbState.facebookPages.map(item => item.id); + ids.forEach(id => this.loadPicture(id)); } - function renderMissedPermissionsWarning() { - return ( - - {isPrimary && ( -
-
{$t('Please log-out and log-in again to get these new features')}
- - -
- )} - {!isPrimary && ( -
-
{$t('Please reconnect Facebook to get these new features')}
- - -
- )} -
- ); + async loadPicture(objectId: string) { + const state = this.state; + if (state.pictures[objectId]) return state.pictures[objectId]; + const picture = await this.fbService.actions.return.fetchPicture(objectId); + state.setPictures({ ...state.pictures, [objectId]: picture }); } - function getDestinationOptions(): IListOption[] { - const options: IListOption[] = [ - { - value: 'me' as TDestinationType, - label: $t('Share to Your Timeline'), - image: FacebookService.state.userAvatar, - }, - { - value: 'page' as TDestinationType, - label: $t('Share to a Page You Manage'), - image: 'https://slobs-cdn.streamlabs.com/media/fb-page.png', - }, - { - value: 'group' as TDestinationType, - label: $t('Share in a Group'), - image: 'https://slobs-cdn.streamlabs.com/media/fb-group.png', - }, - ].filter(opt => { - if (opt.value === 'me' && !canStreamToTimeline) return false; - if (opt.value === 'group' && !canStreamToGroup) return false; - return true; - }); - return options; + verifyGroup() { + remote.shell.openExternal(`https://www.facebook.com/groups/${this.settings.groupId}/edit`); } +} + +export default function FacebookEditStreamInfo(p: IPlatformComponentParams<'facebook'>) { + const { shouldShowPermissionWarn, setProps } = useModule(FacebookEditStreamInfoModule, true); + setProps(p); return (
- {shouldShowPermissionWarn && renderMissedPermissionsWarning()} + {shouldShowPermissionWarn && } } + requiredFields={} + optionalFields={} + essentialOptionalFields={} /> ); } + +function CommonFields() { + const { settings, updateSettings, layoutMode } = useFacebook(); + + return ; +} + +function RequiredFields() { + const { + isUpdateMode, + shouldShowDestinationType, + bind, + shouldShowPages, + pages, + groups, + shouldShowGroups, + pictures, + loadPictures, + getDestinationOptions, + verifyGroup, + } = useFacebook(); + + return ( +
+ ); +} + + +function OptionalFields() { + const { shouldShowPrivacy, settings, setPrivacy, getPrivacyOptions, shouldShowPrivacyWarn, shouldShowGame, shouldShowGamingWarning, bind, fbService } = useFacebook(); + return ( + + ); +} + + +function Events() { + const { bind, shouldShowEvents, onEventChange, scheduledVideosLoaded, scheduledVideos } = useFacebook(); + return ( +
+ {shouldShowEvents && ( + ({ + label: `${v.title} ${ + v.planned_start_time ? moment(new Date(v.planned_start_time)).calendar() : '' + }`, + value: v.id, + })), + ]} + /> + )} +
+ ); +} + + +function PermissionsWarning() { + const { isPrimary, reLogin, dismissWarning, reconnectFB } = useFacebook().extend(module => ({ + + user: inject(UserService), + navigation: inject(NavigationService), + windows: Services.WindowsService, + + async reLogin() { + await this.user.actions.return.reLogin(); + module.streamingService.actions.showGoLiveWindow(); + }, + + dismissWarning() { + module.dismissables.actions.dismiss(EDismissable.FacebookNeedPermissionsTip); + }, + + reconnectFB() { + const platform = 'facebook'; + this.navigation.actions.navigate('PlatformMerge', { platform }); + this.windows.actions.closeChildWindow(); + }, + })); + + return ( + + {isPrimary && ( +
+
{$t('Please log-out and log-in again to get these new features')}
+ + +
+ )} + {!isPrimary && ( +
+
{$t('Please reconnect Facebook to get these new features')}
+ + +
+ )} +
+ ); +} + + +function useFacebook() { + return useModule(FacebookEditStreamInfoModule); +} diff --git a/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx index 111648f3585b..ce3b4c44c99f 100644 --- a/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/TiktokEditStreamInfo.tsx @@ -1,4 +1,3 @@ -import { useGoLiveSettings } from '../useGoLiveSettings'; import React from 'react'; import { createBinding, TextInput } from '../../../shared/inputs'; import Form from '../../../shared/inputs/Form'; diff --git a/app/components-react/windows/go-live/platforms/TwitchTagsInput.tsx b/app/components-react/windows/go-live/platforms/TwitchTagsInput.tsx index eb5d9362ff88..163c43c9be54 100644 --- a/app/components-react/windows/go-live/platforms/TwitchTagsInput.tsx +++ b/app/components-react/windows/go-live/platforms/TwitchTagsInput.tsx @@ -1,5 +1,5 @@ import { TagsInput, TSlobsInputProps } from '../../../shared/inputs'; -import { useOnCreate } from '../../../hooks'; +import { useOnCreate } from 'slap'; import { Services } from '../../../service-provider'; import { prepareOptions, TTwitchTag } from '../../../../services/platforms/twitch/tags'; import React from 'react'; diff --git a/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx index 6f485bf4fbd9..688d4b4c9f0b 100644 --- a/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/YoutubeEditStreamInfo.tsx @@ -10,19 +10,19 @@ import React, { useEffect, useState } from 'react'; import { Services } from '../../../service-provider'; import { $t } from '../../../../services/i18n'; import BroadcastInput from './BroadcastInput'; -import { useAsyncState } from '../../../hooks'; import InputWrapper from '../../../shared/inputs/InputWrapper'; import Form from '../../../shared/inputs/Form'; -import { IYoutubeStartStreamOptions } from '../../../../services/platforms/youtube'; +import {IYoutubeStartStreamOptions, YoutubeService} from '../../../../services/platforms/youtube'; import PlatformSettingsLayout, { IPlatformComponentParams } from './PlatformSettingsLayout'; import { assertIsDefined } from '../../../../util/properties-type-guards'; import * as remote from '@electron/remote'; +import { inject, injectQuery, useModule } from 'slap'; /*** * Stream Settings for YT */ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams<'youtube'>) => { - const { YoutubeService, StreamingService } = Services; + const { StreamingService } = Services; const { isScheduleMode, isUpdateMode } = p; const isMidStreamMode = StreamingService.views.isMidStreamMode; @@ -40,30 +40,32 @@ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams fieldName => ({ disabled: fieldIsDisabled(fieldName as keyof IYoutubeStartStreamOptions) }), ); - const [{ broadcastLoading, broadcasts }] = useAsyncState( - { broadcastLoading: true, broadcasts: [] }, - async () => { - const broadcasts = await YoutubeService.actions.return.fetchEligibleBroadcasts(); + const { broadcastsQuery } = useModule(() => { + + const youtube = inject(YoutubeService); + + async function fetchBroadcasts() { + const broadcasts = await youtube.actions.return.fetchEligibleBroadcasts(); const shouldFetchSelectedBroadcast = broadcastId && !broadcasts.find(b => b.id === broadcastId); if (shouldFetchSelectedBroadcast) { assertIsDefined(broadcastId); - const selectedBroadcast = await YoutubeService.actions.return.fetchBroadcast(broadcastId); + const selectedBroadcast = await youtube.actions.return.fetchBroadcast(broadcastId); broadcasts.push(selectedBroadcast); } + return broadcasts; + } - return { - broadcastLoading: false, - broadcasts, - }; - }, - ); + const broadcastsQuery = injectQuery([], fetchBroadcasts); + + return { broadcastsQuery }; + }); // re-fill form when the broadcastId selected useEffect(() => { if (!broadcastId) return; - YoutubeService.actions.return + Services.YoutubeService.actions.return .fetchStartStreamOptionsForBroadcast(broadcastId) .then(newYtSettings => { updateSettings(newYtSettings); @@ -83,7 +85,7 @@ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams } if (!isMidStreamMode) return false; - return !YoutubeService.updatableSettings.includes(fieldName); + return !Services.YoutubeService.updatableSettings.includes(fieldName); } function projectionChangeHandler(enable360: boolean) { @@ -108,8 +110,8 @@ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams {!isScheduleMode && ( @@ -142,7 +144,7 @@ export const YoutubeEditStreamInfo = InputComponent((p: IPlatformComponentParams {...bind.categoryId} label={$t('Category')} showSearch - options={YoutubeService.state.categories.map(category => ({ + options={Services.YoutubeService.state.categories.map(category => ({ value: category.id, label: category.snippet.title, }))} diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts index d38f7293e86e..fd66ae866b4d 100644 --- a/app/components-react/windows/go-live/useGoLiveSettings.ts +++ b/app/components-react/windows/go-live/useGoLiveSettings.ts @@ -5,8 +5,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { FormInstance } from 'antd/lib/form'; import { message } from 'antd'; import { $t } from '../../../services/i18n'; -import { mutation } from '../../store'; -import { useModule, useModuleRoot } from '../../hooks/useModule'; +import { injectState, useModule } from 'slap'; import { useForm } from '../../shared/inputs/Form'; import { getDefined } from '../../../util/properties-type-guards'; import { isEqual } from 'lodash'; @@ -16,121 +15,32 @@ type TCommonFieldName = 'title' | 'description'; export type TModificators = { isUpdateMode?: boolean; isScheduleMode?: boolean }; export type IGoLiveSettingsState = IGoLiveSettings & TModificators & { needPrepopulate: boolean }; -/** - * Extend GoLiveSettingsModule from StreamInfoView - * So all getters from StreamInfoView will be available in GoLiveSettingsModule - */ -export class GoLiveSettingsModule extends StreamInfoView { - // antd form instance - public form: FormInstance; - - // define initial state +class GoLiveSettingsState extends StreamInfoView { state: IGoLiveSettingsState = { - isUpdateMode: false, - platforms: {}, - customDestinations: [], - tweetText: '', optimizedProfile: undefined, - advancedMode: false, + tweetText: '', + isUpdateMode: false, needPrepopulate: true, prepopulateOptions: undefined, + ...this.savedSettings, }; - // initial setup - init(params: { isUpdateMode: boolean; form: FormInstance }) { - this.form = params.form; - this.state.isUpdateMode = params.isUpdateMode; - - // take prefill options from the windows' `queryParams` - const windowParams = Services.WindowsService.state.child.queryParams as unknown; - if (!isEqual(windowParams, {})) { - this.state.prepopulateOptions = windowParams as IGoLiveSettingsState['prepopulateOptions']; - } - this.prepopulate(); - } - - /** - * Fetch settings for each platform - */ - async prepopulate() { - const { StreamingService } = Services; - await StreamingService.actions.return.prepopulateInfo(); - const prepopulateOptions = this.state.prepopulateOptions; - const view = new StreamInfoView({}); - const settings = { - ...view.savedSettings, // copy saved stream settings - tweetText: view.getTweetText(view.commonFields.title), // generate a default tweet text - needPrepopulate: false, - }; - // if stream has not been started than we allow to change settings only for a primary platform - // so delete other platforms from the settings object - if (this.isUpdateMode && !view.isMidStreamMode) { - Object.keys(settings.platforms).forEach((platform: TPlatform) => { - if (!this.checkPrimaryPlatform(platform)) delete settings.platforms[platform]; - }); - } - - // prefill the form if `prepopulateOptions` provided - if (prepopulateOptions) { - Object.keys(prepopulateOptions).forEach(platform => { - Object.assign(settings.platforms[platform], prepopulateOptions[platform]); - }); - - // disable non-primary platforms - Object.keys(settings.platforms).forEach((platform: TPlatform) => { - if (!view.checkPrimaryPlatform(platform)) settings.platforms[platform]!.enabled = false; - }); - } - - this.updateSettings(settings); - } - - getView(state: IGoLiveSettingsState) { - return new StreamInfoView(state); - } - get settings() { + get settings(): IGoLiveSettingsState { return this.state; } - getSettings() { - return this.state; - } - - get isLoading() { - const state = this.state; - return state.needPrepopulate || this.getView(this.state).isLoading; - } - - get customDestinations() { - return this.state.customDestinations; - } - - get optimizedProfile() { - return this.state.optimizedProfile; - } - - get tweetText() { - return this.state.tweetText; - } - - get isUpdateMode() { - return this.state.isUpdateMode; - } - /** * Update top level settings */ - @mutation() updateSettings(patch: Partial) { const newSettings = { ...this.state, ...patch }; // we should re-calculate common fields before applying new settings - const platforms = this.getView(newSettings).applyCommonFields(newSettings.platforms); + const platforms = this.getViewFromState(newSettings).applyCommonFields(newSettings.platforms); Object.assign(this.state, { ...newSettings, platforms }); } /** * Update settings for a specific platforms */ - @mutation() updatePlatform(platform: TPlatform, patch: Partial) { const updated = { platforms: { @@ -140,48 +50,130 @@ export class GoLiveSettingsModule extends StreamInfoView { }; this.updateSettings(updated); } + + switchPlatforms(enabledPlatforms: TPlatform[]) { + this.linkedPlatforms.forEach(platform => { + this.updatePlatform(platform, { enabled: enabledPlatforms.includes(platform) }); + }); + } /** * Enable/disable a custom ingest destinations */ - @mutation() switchCustomDestination(destInd: number, enabled: boolean) { - const customDestinations = cloneDeep(this.getView(this.state).customDestinations); + const customDestinations = cloneDeep(this.getView().customDestinations); customDestinations[destInd].enabled = enabled; this.updateSettings({ customDestinations }); } /** * Switch Advanced or Simple mode */ - - @mutation() switchAdvancedMode(enabled: boolean) { this.updateSettings({ advancedMode: enabled }); // reset common fields for all platforms in simple mode - if (!enabled) this.updateCommonFields(this.commonFields); + if (!enabled) this.updateCommonFields(this.getView().commonFields); } /** * Set a common field like title or description for all eligible platforms **/ - - @mutation() updateCommonFields( fields: { title: string; description: string }, shouldChangeAllPlatforms = false, ) { Object.keys(fields).forEach((fieldName: TCommonFieldName) => { + const view = this.getView(); const value = fields[fieldName]; const platforms = shouldChangeAllPlatforms - ? this.platformsWithoutCustomFields - : this.enabledPlatforms; + ? view.platformsWithoutCustomFields + : view.enabledPlatforms; platforms.forEach(platform => { - if (!this.supports(fieldName, [platform])) return; + if (!view.supports(fieldName, [platform])) return; const platformSettings = getDefined(this.state.platforms[platform]); platformSettings[fieldName] = value; }); }); } + get isLoading() { + const state = this.state; + return state.needPrepopulate || this.getViewFromState(state).isLoading; + } + + getView() { + return this; + } + + getViewFromState(state: IGoLiveSettingsState) { + return new StreamInfoView(state); + } +} + +/** + * Extend GoLiveSettingsModule from StreamInfoView + * So all getters from StreamInfoView will be available in GoLiveSettingsModule + */ +export class GoLiveSettingsModule { + // define initial state + state = injectState(GoLiveSettingsState); + + constructor(public form: FormInstance, public isUpdateMode: boolean) {} + + // initial setup + async init() { + // take prefill options from the windows' `queryParams` + const windowParams = Services.WindowsService.state.child.queryParams as unknown; + if (windowParams && !isEqual(windowParams, {})) { + getDefined(this.state.setPrepopulateOptions)( + windowParams as IGoLiveSettings['prepopulateOptions'], + ); + } + await this.prepopulate(); + } + + /** + * Fetch settings for each platform + */ + async prepopulate() { + const { StreamingService } = Services; + this.state.setNeedPrepopulate(true); + await StreamingService.actions.return.prepopulateInfo(); + // TODO investigate mutation order issue + await new Promise(r => setTimeout(r, 100)); + + const prepopulateOptions = this.state.prepopulateOptions; + const view = new StreamInfoView({}); + const settings = { + ...view.savedSettings, // copy saved stream settings + tweetText: view.getTweetText(view.commonFields.title), // generate a default tweet text + needPrepopulate: false, + }; + // if stream has not been started than we allow to change settings only for a primary platform + // so delete other platforms from the settings object + if (this.state.isUpdateMode && !view.isMidStreamMode) { + Object.keys(settings.platforms).forEach((platform: TPlatform) => { + if (!this.state.isPrimaryPlatform(platform)) delete settings.platforms[platform]; + }); + } + + // prefill the form if `prepopulateOptions` provided + if (prepopulateOptions) { + Object.keys(prepopulateOptions).forEach(platform => { + Object.assign(settings.platforms[platform], prepopulateOptions[platform]); + }); + + // disable non-primary platforms + Object.keys(settings.platforms).forEach((platform: TPlatform) => { + if (!view.isPrimaryPlatform(platform)) settings.platforms[platform]!.enabled = false; + }); + } + + this.state.updateSettings(settings); + } + + getSettings() { + return this.state.settings; + } + /** * Save current settings so we can use it next time we open the GoLiveWindow */ @@ -194,10 +186,10 @@ export class GoLiveSettingsModule extends StreamInfoView { * If platform is enabled then prepopulate its settings */ switchPlatforms(enabledPlatforms: TPlatform[]) { - this.linkedPlatforms.forEach(platform => { - this.updatePlatform(platform, { enabled: enabledPlatforms.includes(platform) }); + this.state.linkedPlatforms.forEach(platform => { + this.state.updatePlatform(platform, { enabled: enabledPlatforms.includes(platform) }); }); - this.save(this.settings); + this.save(this.state.settings); this.prepopulate(); } @@ -206,7 +198,7 @@ export class GoLiveSettingsModule extends StreamInfoView { */ async validate() { try { - await this.form.validateFields(); + await getDefined(this.form).validateFields(); return true; } catch (e: unknown) { message.error($t('Invalid settings. Please check the form')); @@ -219,7 +211,7 @@ export class GoLiveSettingsModule extends StreamInfoView { */ async goLive() { if (await this.validate()) { - Services.StreamingService.actions.goLive(this.state); + Services.StreamingService.actions.goLive(this.state.settings); } } /** @@ -228,7 +220,7 @@ export class GoLiveSettingsModule extends StreamInfoView { async updateStream() { if ( (await this.validate()) && - (await Services.StreamingService.actions.return.updateStreamSettings(this.state)) + (await Services.StreamingService.actions.return.updateStreamSettings(this.state.settings)) ) { message.success($t('Successfully updated')); } @@ -242,8 +234,6 @@ export function useGoLiveSettings() { export function useGoLiveSettingsRoot(params?: { isUpdateMode: boolean }) { const form = useForm(); - return useModuleRoot(GoLiveSettingsModule, { - form, - isUpdateMode: params?.isUpdateMode, - }); + const useModuleResult = useModule(GoLiveSettingsModule, [form, !!params?.isUpdateMode]); + return useModuleResult; } diff --git a/app/components-react/windows/settings/Appearance.tsx b/app/components-react/windows/settings/Appearance.tsx index 59770258ffd0..4074ec528063 100644 --- a/app/components-react/windows/settings/Appearance.tsx +++ b/app/components-react/windows/settings/Appearance.tsx @@ -1,22 +1,26 @@ import React from 'react'; import { Services } from '../../service-provider'; import { $t } from '../../../services/i18n'; -import { useBinding } from '../../store'; import { CheckboxInput, ListInput, SliderInput } from '../../shared/inputs'; import { getDefined } from '../../../util/properties-type-guards'; import { ObsSettingsSection } from './ObsSettings'; -import { cloneDeep } from 'lodash'; import * as remote from '@electron/remote'; +import { injectFormBinding, useModule } from 'slap'; export function AppearanceSettings() { const { CustomizationService, WindowsService, UserService, MagicLinkService } = Services; - const bind = useBinding( - () => cloneDeep(CustomizationService.state), - newSettings => { + const { bind } = useModule(() => { + function getSettings() { + return CustomizationService.state; + } + + function setSettings(newSettings: typeof CustomizationService.state) { CustomizationService.actions.setSettings(newSettings); - }, - ); + } + + return { bind: injectFormBinding(getSettings, setSettings) }; + }); function openFFZSettings() { WindowsService.createOneOffWindow( diff --git a/app/components-react/windows/settings/General.tsx b/app/components-react/windows/settings/General.tsx index 0b2164a43853..bd24dd6b4729 100644 --- a/app/components-react/windows/settings/General.tsx +++ b/app/components-react/windows/settings/General.tsx @@ -6,7 +6,6 @@ import { CheckboxInput, ListInput } from '../../shared/inputs'; import { Services } from '../../service-provider'; import fs from 'fs'; import path from 'path'; -import { useBinding } from '../../store'; import { getDefined } from '../../../util/properties-type-guards'; import { useVuex } from 'components-react/hooks'; @@ -60,9 +59,10 @@ function ExtraSettings() { const protectedMode = StreamSettingsService.state.protectedModeEnabled; const disableHAFilePath = path.join(AppService.appDataDirectory, 'HADisable'); const [disableHA, setDisableHA] = useState(() => fs.existsSync(disableHAFilePath)); - const { isRecordingOrStreaming, recordingMode } = useVuex(() => ({ + const { isRecordingOrStreaming, recordingMode, updateStreamInfoOnLive } = useVuex(() => ({ isRecordingOrStreaming: StreamingService.isStreaming || StreamingService.isRecording, recordingMode: RecordingModeService.views.isRecordingModeEnabled, + updateStreamInfoOnLive: CustomizationService.state.updateStreamInfoOnLive, })); const canRunOptimizer = isTwitch && !isRecordingOrStreaming && protectedMode; @@ -104,21 +104,13 @@ function ExtraSettings() { } } - const bind = useBinding({ - get streamInfoUpdate() { - return CustomizationService.state.updateStreamInfoOnLive; - }, - set streamInfoUpdate(value) { - CustomizationService.setUpdateStreamInfoOnLive(value); - }, - }); - return ( <> {isLoggedIn && !isFacebook && !isYoutube && ( CustomizationService.setUpdateStreamInfoOnLive(val)} label={$t('Confirm stream title and game before going live')} name="stream_info_udpate" /> diff --git a/app/components-react/windows/settings/RemoteControl.tsx b/app/components-react/windows/settings/RemoteControl.tsx index d120325cf022..e3574ee5631a 100644 --- a/app/components-react/windows/settings/RemoteControl.tsx +++ b/app/components-react/windows/settings/RemoteControl.tsx @@ -2,18 +2,17 @@ import React, { CSSProperties } from 'react'; import { ObsSettingsSection } from './ObsSettings'; import { $t } from '../../../services/i18n'; import QRCode from 'qrcode.react'; -import { mutation } from '../../store'; -import { useModule } from '../../hooks/useModule'; import { Services } from '../../service-provider'; import Form from '../../shared/inputs/Form'; import { TextInput } from '../../shared/inputs'; import { Button, Col, Row, Space } from 'antd'; import Utils from '../../../services/utils'; +import { injectState, mutation, useModule } from 'slap'; const QRCODE_SIZE = 350; class RemoteControlModule { - state = { + state = injectState({ qrcodeIsVisible: false, detailsIsVisible: false, qrCodeData: { @@ -22,7 +21,7 @@ class RemoteControlModule { token: '', version: '', }, - }; + }); private updateNetworkInterval: number; @@ -45,10 +44,9 @@ class RemoteControlModule { return Services.TcpServerService; } - @mutation() showQrCode() { this.TcpServerService.enableWebsoketsRemoteConnections(); - this.state.qrcodeIsVisible = true; + this.state.setQrcodeIsVisible(true); } @mutation() @@ -60,19 +58,18 @@ class RemoteControlModule { this.TcpServerService.actions.generateToken(); } - @mutation() private refreshQrcodeData() { const settings = this.TcpServerService.state; const addresses = this.TcpServerService.getIPAddresses() .filter(address => !address.internal) .map(address => address.address); - this.state.qrCodeData = { + this.state.setQrCodeData({ addresses, token: settings.token, port: settings.websockets.port, version: Utils.env.SLOBS_VERSION, - }; + }); } } @@ -85,7 +82,7 @@ export function RemoteControlSettings() { showQrCode, showDetails, generateToken, - } = useModule(RemoteControlModule).select(); + } = useModule(RemoteControlModule); const colStyle: CSSProperties = { width: `${QRCODE_SIZE}px`, diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index d9a536a9b4a9..93d43d49fcee 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useModule } from '../../hooks/useModule'; import { $t } from '../../../services/i18n'; import { ICustomStreamDestination } from '../../../services/settings/streaming'; import { EStreamingState } from '../../../services/streaming'; @@ -8,22 +7,24 @@ import cloneDeep from 'lodash/cloneDeep'; import namingHelpers from '../../../util/NamingHelpers'; import { Services } from '../../service-provider'; import { ObsGenericSettingsForm } from './ObsSettings'; -import { mutation } from '../../store'; import css from './Stream.m.less'; import cx from 'classnames'; import { Button, message, Tooltip } from 'antd'; import PlatformLogo from '../../shared/PlatformLogo'; import Form, { useForm } from '../../shared/inputs/Form'; -import { createBinding, TextInput } from '../../shared/inputs'; +import { TextInput } from '../../shared/inputs'; import { ButtonGroup } from '../../shared/ButtonGroup'; import { FormInstance } from 'antd/lib/form'; +import { injectFormBinding, injectState, mutation, useModule } from 'slap'; /** * A Redux module for components in the StreamSetting window */ class StreamSettingsModule { + constructor(private form: FormInstance) {} + // DEFINE A STATE - state = { + state = injectState({ // false = edit mode off // true = add custom destination mode // number = edit custom destination mode where number is the index of the destination @@ -36,7 +37,12 @@ class StreamSettingsModule { streamKey: '', enabled: false, } as ICustomStreamDestination, - }; + }); + + bind = injectFormBinding( + () => this.state.customDestForm, + patch => this.updateCustomDestForm(patch), + ); // INJECT SERVICES @@ -82,7 +88,6 @@ class StreamSettingsModule { this.state.editCustomDestMode = true; } - @mutation() removeCustomDest(ind: number) { const destinations = cloneDeep(this.customDestinations); destinations.splice(ind, 1); @@ -159,11 +164,6 @@ class StreamSettingsModule { return this.streamingView.savedSettings.customDestinations; } - private form: FormInstance; - setForm(form: FormInstance) { - this.form = form; - } - platformMerge(platform: TPlatform) { this.navigationService.navigate('PlatformMerge', { platform }); this.windowsService.actions.closeChildWindow(); @@ -206,13 +206,14 @@ class StreamSettingsModule { // wrap the module into a React hook function useStreamSettings() { - return useModule(StreamSettingsModule).select(); + return useModule(StreamSettingsModule); } /** * A root component for StreamSettings */ export function StreamSettings() { + const form = useForm(); const { platforms, protectedModeEnabled, @@ -220,7 +221,7 @@ export function StreamSettings() { disableProtectedMode, needToShowWarning, enableProtectedMode, - } = useStreamSettings(); + } = useModule(StreamSettingsModule, [form]); return (
@@ -281,10 +282,10 @@ function Platform(p: { platform: TPlatform }) { const platform = p.platform; const { UserService, StreamingService } = Services; const { canEditSettings, platformMerge, platformUnlink } = useStreamSettings(); - const isMerged = StreamingService.views.checkPlatformLinked(platform); + const isMerged = StreamingService.views.isPlatformLinked(platform); const username = UserService.state.auth!.platforms[platform]?.username; const platformName = getPlatformService(platform).displayName; - const isPrimary = StreamingService.views.checkPrimaryPlatform(platform); + const isPrimary = StreamingService.views.isPrimaryPlatform(platform); const shouldShowPrimaryBtn = isPrimary; const shouldShowConnectBtn = !isMerged && canEditSettings; const shouldShowUnlinkBtn = !isPrimary && isMerged && canEditSettings; @@ -345,6 +346,7 @@ function CustomDestinationList() { const isEditMode = editCustomDestMode !== false; const shouldShowAddForm = editCustomDestMode === true; const canAddMoreDestinations = destinations.length < 2; + return (
{destinations.map((dest, ind) => ( @@ -413,16 +415,7 @@ function CustomDestination(p: { destination: ICustomStreamDestination; ind: numb * Renders an ADD/EDIT form for the custom destination */ function CustomDestForm() { - const { - saveCustomDest, - stopEditing, - customDestForm, - updateCustomDestForm, - setForm, - } = useStreamSettings(); - const form = useForm(); - setForm(form); - const bind = createBinding(customDestForm, updateCustomDestForm); + const { saveCustomDest, stopEditing, bind } = useStreamSettings(); return (
diff --git a/app/components-react/windows/settings/useObsSettings.tsx b/app/components-react/windows/settings/useObsSettings.tsx index 38051d5df936..d15a67e4824e 100644 --- a/app/components-react/windows/settings/useObsSettings.tsx +++ b/app/components-react/windows/settings/useObsSettings.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { useModule } from '../../hooks/useModule'; -import { mutation } from '../../store'; +import { useModule, injectState } from 'slap'; import { Services } from '../../service-provider'; import { ISettingsSubCategory } from '../../../services/settings'; @@ -8,24 +7,20 @@ import { ISettingsSubCategory } from '../../../services/settings'; * A module for components in the SettingsWindow */ class ObsSettingsModule { - state = { + state = injectState({ page: '', - }; + }); init() { // init page const { WindowsService } = Services; if (WindowsService.state.child.queryParams) { - this.state.page = WindowsService.state.child.queryParams.categoryName || 'General'; + this.state.setPage(WindowsService.state.child.queryParams.categoryName || 'General'); } else { - this.state.page = 'General'; + this.state.setPage('General'); } } - @mutation() - setPage(page: string) { - this.state.page = page; - } private get settingsService() { return Services.SettingsService; @@ -42,5 +37,5 @@ class ObsSettingsModule { // wrap the module in a hook export function useObsSettings() { - return useModule(ObsSettingsModule).select(); + return useModule(ObsSettingsModule); } diff --git a/app/components-react/windows/sharedComponentsLibrary/DemoForm.tsx b/app/components-react/windows/sharedComponentsLibrary/DemoForm.tsx index 9ee356e75cd5..d6f2762ae656 100644 --- a/app/components-react/windows/sharedComponentsLibrary/DemoForm.tsx +++ b/app/components-react/windows/sharedComponentsLibrary/DemoForm.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Example, useSharedComponentsLibrary } from './SharedComponentsLibrary'; -import { useFormState } from '../../hooks'; import Utils from '../../../services/utils'; import { IListOption, ListInput } from '../../shared/inputs/ListInput'; import Form from '../../shared/inputs/Form'; @@ -15,53 +14,60 @@ import { TextInput, } from '../../shared/inputs'; import InputWrapper from '../../shared/inputs/InputWrapper'; +import { injectQuery, injectState } from 'slap'; /** * A component that renders a form with a different variations of input components * We need it for testing input components in automated tests */ export function DemoForm() { - const { layout } = useSharedComponentsLibrary(); - const { s, bind } = useFormState({ - name: '', - gender: '', - age: 0, - colors: [] as number[], - city: '', - weight: 65, - addIntroduction: false, - introduction: '', - plusOneName: '', - confirm1: false, - confirm2: false, - saveFilePath: '', - }); + const { + layout, + formState, + citiesQuery, + colorOptions, + genderOptions, + addIntroduction, + setSearchStr, + } = useSharedComponentsLibrary().extend(module => { + const formState = injectState({ + name: '', + gender: '', + age: 0, + colors: [] as number[], + city: '', + weight: 65, + addIntroduction: false, + introduction: '', + plusOneName: '', + confirm1: false, + confirm2: false, + saveFilePath: '', + searchStr: '', + }); - const genderOptions = [ - { value: 'male', label: 'Male' }, - { value: 'female', label: 'Female' }, - { value: 'other', label: 'other' }, - ]; + const citiesQuery = injectQuery(fetchCities, () => formState.searchStr); - const colorOptions = [ - { value: 1, label: 'Red' }, - { value: 2, label: 'Green' }, - { value: 3, label: 'Blue' }, - { value: 4, label: 'Orange' }, - ]; + return { + formState, + citiesQuery, - const availableCities = ['Tokyo', 'Delhi', 'Shanghai', 'MexicoCity', 'Cairo']; - const [cityOptions, setCityOptions] = useState[]>([]); - const [isSearching, setIsSearching] = useState(false); + genderOptions: [ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, + { value: 'other', label: 'other' }, + ], + + colorOptions: [ + { value: 1, label: 'Red' }, + { value: 2, label: 'Green' }, + { value: 3, label: 'Blue' }, + { value: 4, label: 'Orange' }, + ], + }; + }); - async function onCitySearch(searchStr: string) { - setIsSearching(true); - // add a fake loading time - await Utils.sleep(1000); - const cities = availableCities.filter(cityName => cityName.startsWith(searchStr)); - setCityOptions(cities.map(cityName => ({ value: cityName.charAt(0), label: cityName }))); - setIsSearching(false); - } + const bind = formState.bind; return ( @@ -69,19 +75,21 @@ export function DemoForm() { + {/*Selected city {bind.city.value}*/} setSearchStr(search)} + loading={citiesQuery.isLoading} /> - {s.addIntroduction && } + {addIntroduction && } @@ -90,3 +98,13 @@ export function DemoForm() { ); } + +async function fetchCities(searchStr: string) { + const availableCities = ['Tokyo', 'Delhi', 'Shanghai', 'MexicoCity', 'Cairo']; + await Utils.sleep(1000); + if (!searchStr) return []; + const cities = availableCities.filter(cityName => + cityName.toLowerCase().startsWith(searchStr.toLowerCase()), + ); + return cities.map(cityName => ({ label: cityName, value: cityName })); +} diff --git a/app/components-react/windows/sharedComponentsLibrary/SharedComponentsLibrary.tsx b/app/components-react/windows/sharedComponentsLibrary/SharedComponentsLibrary.tsx index b70497713ae2..a98e22fd6f77 100644 --- a/app/components-react/windows/sharedComponentsLibrary/SharedComponentsLibrary.tsx +++ b/app/components-react/windows/sharedComponentsLibrary/SharedComponentsLibrary.tsx @@ -1,10 +1,8 @@ import React, { HTMLAttributes } from 'react'; -import { useFormState } from '../../hooks'; import { ModalLayout } from '../../shared/ModalLayout'; import Form from '../../shared/inputs/Form'; import { CheckboxInput, - createBinding, DateInput, FileInput, ImageInput, @@ -26,12 +24,10 @@ import PlatformLogo from '../../shared/PlatformLogo'; import { DownloadOutlined } from '@ant-design/icons'; import { alertAsync, confirmAsync } from '../../modals'; import { I18nService, WHITE_LIST } from '../../../services/i18n'; -import { mutation } from '../../store'; import { pick } from 'lodash'; -import { useModule } from '../../hooks/useModule'; -import { merge } from '../../../util/merge'; import { DemoForm } from './DemoForm'; import { CodeInput } from '../../shared/inputs/CodeInput'; +import { injectState, merge, useModule, injectFormBinding } from 'slap'; const { TabPane } = Tabs; @@ -67,42 +63,50 @@ function Examples() { hasTooltips, disabled, size, - } = useSharedComponentsLibrary(); - const { s, bind } = useFormState({ - textVal: '', - textAreaVal: '', - switcherVal: false, - numberVal: 0, - sliderVal: 5, - imageVal: '', - galleryImage: '', - galleryAudio: '', - javascript: 'alert("Hello World!")', - saveFilePathVal: '', - checkboxVal: false, - dateVal: undefined as Date | undefined, - listVal: 1, - listOptions: [ - { value: 1, label: 'Red' }, - { value: 2, label: 'Green' }, - { value: 3, label: 'Blue' }, - { value: 4, label: 'Orange' }, - ], - listVal2: '', - listOptions2: [ - { value: '', label: 'Please Select the option' }, - { value: 'foo', label: 'Foo' }, - { value: 'bar', label: 'Bar' }, - ], - tagsVal: [1, 2, 3], - tagsOptions: [ - { value: 1, label: 'Red' }, - { value: 2, label: 'Green' }, - { value: 3, label: 'Blue' }, - { value: 4, label: 'Orange' }, - ], + formState, + } = useSharedComponentsLibrary().extend(module => { + const formState = injectState({ + textVal: '', + textAreaVal: '', + switcherVal: false, + numberVal: 0, + sliderVal: 5, + imageVal: '', + galleryImage: '', + galleryAudio: '', + javascript: 'alert("Hello World!")', + saveFilePathVal: '', + checkboxVal: false, + dateVal: undefined as Date | undefined, + listVal: 1, + listOptions: [ + { value: 1, label: 'Red' }, + { value: 2, label: 'Green' }, + { value: 3, label: 'Blue' }, + { value: 4, label: 'Orange' }, + ], + listVal2: '', + listOptions2: [ + { value: '', label: 'Please Select the option' }, + { value: 'foo', label: 'Foo' }, + { value: 'bar', label: 'Bar' }, + ], + tagsVal: [1, 2, 3], + tagsOptions: [ + { value: 1, label: 'Red' }, + { value: 2, label: 'Green' }, + { value: 3, label: 'Blue' }, + { value: 4, label: 'Orange' }, + ], + }); + + return { + formState, + }; }); + const s = formState; + const bind = formState.bind; const globalProps: Record = {}; if (hasTooltips) globalProps.tooltip = 'This is tooltip'; if (required) globalProps.required = true; @@ -456,11 +460,11 @@ function SettingsPanel() { } export function useSharedComponentsLibrary() { - return useModule(SharedComponentsModule).select(); + return useModule(SharedComponentsModule); } class SharedComponentsModule { - state: ISharedComponentsState = { + state = injectState({ layout: 'horizontal', hasTooltips: false, required: false, @@ -469,7 +473,7 @@ class SharedComponentsModule { size: 'middle', background: 'section', locales: WHITE_LIST, - }; + } as ISharedComponentsState); private globalState = { get theme() { @@ -488,21 +492,15 @@ class SharedComponentsModule { }, }; - private mergedState = merge( - () => this.state, - () => this.globalState, - ); - - @mutation() - private updateState(statePatch: Partial) { - Object.assign(this.state, statePatch); + get mergedState() { + return merge(this.state.getters, this.globalState); } - bind = createBinding( + bind = injectFormBinding( () => this.mergedState, statePatch => { const localStatePatch = pick(statePatch, Object.keys(this.state)); - this.updateState(localStatePatch); + this.state.update(localStatePatch); const globalStatePatch = pick(statePatch, Object.keys(this.globalState)); Object.assign(this.globalState, globalStatePatch); }, diff --git a/app/components-react/windows/source-showcase/useSourceShowcase.tsx b/app/components-react/windows/source-showcase/useSourceShowcase.tsx index 8d18f6f388a8..b4d1c7c93a62 100644 --- a/app/components-react/windows/source-showcase/useSourceShowcase.tsx +++ b/app/components-react/windows/source-showcase/useSourceShowcase.tsx @@ -1,7 +1,6 @@ import React from 'react'; import omit from 'lodash/omit'; -import { useModule } from '../../hooks/useModule'; -import { mutation } from '../../store'; +import { injectState, mutation, useModule } from 'slap'; import { Services } from '../../service-provider'; import { TPropertiesManager, TSourceType } from 'services/sources'; import { WidgetType } from 'services/widgets'; @@ -21,13 +20,13 @@ type TInspectableSource = TSourceType | WidgetType | 'streamlabel' | 'app_source * A module for components in the SourceShowcase window */ class SourceShowcaseModule { - state = { + state = injectState({ inspectedSource: (Services.UserService.views.isLoggedIn ? 'AlertBox' : 'ffmpeg_source') as TInspectableSource, inspectedAppId: '', inspectedAppSourceId: '', - }; + }); private get sourcesService() { return Services.SourcesService; @@ -112,5 +111,5 @@ class SourceShowcaseModule { // wrap the module in a hook export function useSourceShowcaseSettings() { - return useModule(SourceShowcaseModule).select(); + return useModule(SourceShowcaseModule); } diff --git a/app/components/shared/PlatformLogo.tsx b/app/components/shared/PlatformLogo.tsx index cd514670d83a..5fbe28d2dd8d 100644 --- a/app/components/shared/PlatformLogo.tsx +++ b/app/components/shared/PlatformLogo.tsx @@ -20,6 +20,7 @@ export default class PlatformLogo extends TsxComponent { dlive: 'dlive', nimotv: 'nimotv', streamlabs: 'icon-streamlabs', + trovo: 'trovo', }[this.props.platform]; } diff --git a/app/services/core/service.ts b/app/services/core/service.ts index 7bb31ac9d525..9869fcf84823 100644 --- a/app/services/core/service.ts +++ b/app/services/core/service.ts @@ -143,7 +143,9 @@ export abstract class Service { } constructor(enforcer: Symbol) { - if (enforcer !== singletonEnforcer) throw new Error('Cannot construct singleton'); + if (enforcer !== singletonEnforcer) { + throw new Error('Cannot construct singleton'); + } } /** diff --git a/app/services/core/stateful-service.ts b/app/services/core/stateful-service.ts index 6bc13c938555..a2b3a7d8e626 100644 --- a/app/services/core/stateful-service.ts +++ b/app/services/core/stateful-service.ts @@ -113,6 +113,7 @@ export function inheritMutations(target: any) { */ export abstract class StatefulService extends Service { static store: Store; + static onStateRead: ((serviceName: string) => unknown) | null = null; static setupVuexStore(store: Store) { this.store = store; @@ -128,6 +129,7 @@ export abstract class StatefulService extends Service { } get state(): TState { + StatefulService.onStateRead && StatefulService.onStateRead(this.serviceName); return this.store.state[this.serviceName]; } diff --git a/app/services/hotkeys.ts b/app/services/hotkeys.ts index 4ecbaacdf61a..2a3ec286fdc4 100644 --- a/app/services/hotkeys.ts +++ b/app/services/hotkeys.ts @@ -776,7 +776,7 @@ const getMigrationMapping = (actionName: string) => { return { MUTE: 'TOGGLE_MUTE', UNMUTE: 'TOGGLE_UNMUTE', - }[normalizeActionName(actionName)]; + }[normalizeActionName(actionName)] as string; }; const getActionFromName = (actionName: string) => ({ diff --git a/app/services/obs-importer.ts b/app/services/obs-importer.ts index 12cb4dddccf4..c5e0ff84666c 100644 --- a/app/services/obs-importer.ts +++ b/app/services/obs-importer.ts @@ -223,7 +223,9 @@ export class ObsImporterService extends StatefulService<{ progress: number; tota if (sourceJSON.id === 'browser_source') { sourceJSON.settings.shutdown = true; - const widgetType = this.widgetsService.getWidgetTypeByUrl(sourceJSON.settings.url); + const widgetType: number = this.widgetsService.getWidgetTypeByUrl( + sourceJSON.settings.url, + ); if (widgetType !== -1) { propertiesManager = 'widget'; propertiesManagerSettings = { widgetType }; diff --git a/app/services/onboarding.ts b/app/services/onboarding.ts index 7263239cc083..87ceb5fd4917 100644 --- a/app/services/onboarding.ts +++ b/app/services/onboarding.ts @@ -3,11 +3,10 @@ import { NavigationService } from 'services/navigation'; import { UserService } from 'services/user'; import { Inject, ViewHandler } from 'services/core/'; import { SceneCollectionsService } from 'services/scene-collections'; -import TsxComponent from 'components/tsx-component'; import { OS } from 'util/operating-systems'; import { $t } from './i18n'; -import { handleResponse, jfetch } from 'util/requests'; -import { getPlatformService, IPlatformCapabilityResolutionPreset } from './platforms'; +import { jfetch } from 'util/requests'; +import { getPlatformService } from './platforms'; import { OutputSettingsService } from './settings'; import { ObsImporterService } from './obs-importer'; import Utils from './utils'; diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts index b41a443eb6a7..f9c45613942d 100644 --- a/app/services/streaming/streaming-view.ts +++ b/app/services/streaming/streaming-view.ts @@ -98,7 +98,7 @@ export class StreamInfoView extends ViewHandler { */ get allPlatforms(): TPlatform[] { const allPlatforms: TPlatform[] = ['twitch', 'facebook', 'youtube', 'tiktok', 'trovo']; - return this.sortPlatforms(allPlatforms); + return this.getSortedPlatforms(allPlatforms); } /** @@ -109,7 +109,7 @@ export class StreamInfoView extends ViewHandler { if (!this.restreamView.canEnableRestream || !this.protectedModeEnabled) { return [this.userView.auth!.primaryPlatform]; } - return this.allPlatforms.filter(p => this.checkPlatformLinked(p)); + return this.allPlatforms.filter(p => this.isPlatformLinked(p)); } get protectedModeEnabled() { @@ -267,12 +267,12 @@ export class StreamInfoView extends ViewHandler { * - linked platforms are always on the top of the list * - the rest has an alphabetic sort */ - sortPlatforms(platforms: TPlatform[]): TPlatform[] { + getSortedPlatforms(platforms: TPlatform[]): TPlatform[] { platforms = platforms.sort(); return [ - ...platforms.filter(p => this.checkPrimaryPlatform(p)), - ...platforms.filter(p => !this.checkPrimaryPlatform(p) && this.checkPlatformLinked(p)), - ...platforms.filter(p => !this.checkPlatformLinked(p)), + ...platforms.filter(p => this.isPrimaryPlatform(p)), + ...platforms.filter(p => !this.isPrimaryPlatform(p) && this.isPlatformLinked(p)), + ...platforms.filter(p => !this.isPlatformLinked(p)), ]; } @@ -294,12 +294,12 @@ export class StreamInfoView extends ViewHandler { return false; } - checkPlatformLinked(platform: TPlatform): boolean { + isPlatformLinked(platform: TPlatform): boolean { if (!this.userView.auth?.platforms) return false; return !!this.userView.auth?.platforms[platform]; } - checkPrimaryPlatform(platform: TPlatform) { + isPrimaryPlatform(platform: TPlatform) { return platform === this.userView.auth?.primaryPlatform; } @@ -364,7 +364,7 @@ export class StreamInfoView extends ViewHandler { return { ...settings, useCustomFields, - enabled: enabled || this.checkPrimaryPlatform(platform), + enabled: enabled || this.isPrimaryPlatform(platform), }; } diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index c2cb487f8c74..0252885d5e45 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -175,7 +175,7 @@ export class StreamingService // primary platform is always available to stream into // prime users are eligeble for streaming to any platform let primeRequired = false; - if (!this.views.checkPrimaryPlatform(platform) && !this.userService.isPrime) { + if (!this.views.isPrimaryPlatform(platform) && !this.userService.isPrime) { const primaryPlatform = this.userService.state.auth?.primaryPlatform; // grandfathared users allowed to stream primary + FB diff --git a/app/services/widgets/settings/event-list.ts b/app/services/widgets/settings/event-list.ts index 534238059be6..c40723f5d178 100644 --- a/app/services/widgets/settings/event-list.ts +++ b/app/services/widgets/settings/event-list.ts @@ -9,6 +9,7 @@ import { metadata } from 'components/widgets/inputs/index'; import { WIDGET_INITIAL_STATE } from './widget-settings'; import { InheritMutations } from 'services/core/stateful-service'; import { $t } from 'services/i18n'; +import { TPlatform } from '../../platforms'; export interface IEventListSettings extends IWidgetSettings { animation_speed: number; @@ -95,7 +96,7 @@ export class EventListService extends WidgetSettingsService { } eventsByPlatform(): { key: string; title: string }[] { - const platform = this.userService.platform.type; + const platform = this.userService.platform.type as Exclude; return { twitch: [ { key: 'show_follows', title: $t('Follows') }, diff --git a/app/services/widgets/settings/stream-boss.ts b/app/services/widgets/settings/stream-boss.ts index d0160cbba2ad..3fbb5cef106e 100644 --- a/app/services/widgets/settings/stream-boss.ts +++ b/app/services/widgets/settings/stream-boss.ts @@ -5,6 +5,7 @@ import { metadata } from 'components/widgets/inputs/index'; import { InheritMutations } from 'services/core/stateful-service'; import { BaseGoalService } from './base-goal'; import { formMetadata } from 'components/shared/inputs'; +import { TPlatform } from '../../platforms'; export interface IStreamBossSettings extends IWidgetSettings { background_color: string; @@ -166,7 +167,7 @@ export class StreamBossService extends BaseGoalService; return { twitch: [ { key: 'bit_multiplier', title: $t('Damage Per Bit'), isInteger: true }, diff --git a/app/util/isDeepEqual.ts b/app/util/isDeepEqual.ts deleted file mode 100644 index 110f1854b989..000000000000 --- a/app/util/isDeepEqual.ts +++ /dev/null @@ -1,38 +0,0 @@ -import isPlainObject from 'lodash/isPlainObject'; - -/** - * Compare 2 object with limited depth - */ -export function isDeepEqual(obj1: any, obj2: any, currentDepth: number, maxDepth: number): boolean { - if (obj1 === obj2) return true; - if (currentDepth === maxDepth) return false; - if (Array.isArray(obj1) && Array.isArray(obj2)) return isArrayEqual(obj1, obj2); - if (isPlainObject(obj1) && isPlainObject(obj2)) { - const [keys1, keys2] = [Object.keys(obj1), Object.keys(obj2)]; - if (keys1.length !== keys2.length) return false; - for (const key of keys1) { - if (!isDeepEqual(obj1[key], obj2[key], currentDepth + 1, maxDepth)) return false; - } - return true; - } - return false; -} - -/** - * consider isSimilar as isDeepEqual with depth 2 - */ -export function isSimilar(obj1: any, obj2: any) { - return isDeepEqual(obj1, obj2, 0, 2); -} - -/** - * Shallow comparison of 2 arrays - */ -function isArrayEqual(a: any[], b: any[]) { - if (a === b) return true; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} diff --git a/app/util/lockThis.ts b/app/util/lockThis.ts deleted file mode 100644 index 93703d7c9fb5..000000000000 --- a/app/util/lockThis.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { traverseClassInstance } from './traverseClassInstance'; - -/** - * Re-bind this for all object's methods to ensure `this` is always defined - * This method is useful if we extract methods from an objet this way: - * - * const { action1, action2 } = actions; - */ -export function lockThis(instance: T): T { - const result = {}; - - traverseClassInstance(instance, (propName, descriptor) => { - if (descriptor.get || typeof instance[propName] !== 'function') { - Object.defineProperty(result, propName, { - get: () => { - return instance[propName]; - }, - }); - } else { - result[propName] = instance[propName].bind(instance); - } - }); - - return result as T; -} diff --git a/app/util/merge.ts b/app/util/merge.ts deleted file mode 100644 index 71dc9b3bd2fa..000000000000 --- a/app/util/merge.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Merges multiple sources of data into a single Proxy object - * The result object is read-only - * - * @example - * - * const mergedObject = merge( - * () => ({ foo: 1 }), - * () => ({ bar: 2 }), - * () => ({ bar: 3 }), - * ) - * - * mergedObject.bar // 3 - * mergedObject.foo // 1 - */ -export function merge< - T1 extends Object, - T2 extends Object, - T3 extends Object, - T4 extends Object, - FN3 extends () => T3, - FN4 extends () => T4, - TReturnType = FN4 extends undefined - ? FN3 extends undefined - ? TMerge - : TMerge3 - : TMerge4 ->(...functions: [() => T1, () => T2, FN3?, FN4?]): TReturnType { - const result = functions.reduce((a, val) => mergeTwo(a as unknown, val as unknown)); - return (result as unknown) as TReturnType; -} - -/** - * This function is used by the `.merge()` function to merge 2 sources of data - */ -function mergeTwo>( - target1: (() => T1) | T1, - target2: (() => T2) | T2, -): TReturnType { - const proxyMetadata = { - _proxyName: 'MergeResult', - get _mergedObjects() { - return [target1, target2]; - }, - }; - - function hasOwnProperty(propName: string) { - const obj = getObject(propName); - return obj && propName in obj; - } - - function getObject(propName: string) { - if (target2['_proxyName'] === 'MergeResult' && propName in target2) { - return target2; - } else if (typeof target2 === 'function') { - const obj2 = (target2 as Function)(); - if (propName in obj2) return obj2; - } - - if (target1['_proxyName'] === 'MergeResult' && propName in target1) { - return target1; - } else if (typeof target1 === 'function') { - const obj1 = (target1 as Function)(); - if (propName in obj1) return obj1; - } - } - - return (new Proxy(proxyMetadata, { - get(t, propName: string) { - if (propName === 'hasOwnProperty') return hasOwnProperty; - if (propName in proxyMetadata) return proxyMetadata[propName]; - const obj = getObject(propName); - if (obj) return obj[propName]; - }, - - has(oTarget, propName: string) { - return hasOwnProperty(propName); - }, - }) as unknown) as TReturnType; -} - -export type TMerge< - T1, - T2, - TObj1 = T1 extends (...args: any[]) => infer R1 ? R1 : T1, - TObj2 = T2 extends (...args: any[]) => infer R2 ? R2 : T2, - R extends object = Omit & TObj2 -> = R; - -export type TMerge3 = TMerge, T3>; -export type TMerge4 = TMerge, T4>; diff --git a/app/util/requests.ts b/app/util/requests.ts index 4efb21afcc42..e39824e9bd32 100644 --- a/app/util/requests.ts +++ b/app/util/requests.ts @@ -66,7 +66,7 @@ export async function downloadFile( const fileStream = fs.createWriteStream(dstPath); let bytesWritten = 0; - const readStream = ({ done, value }: { done: boolean; value: Uint8Array }) => { + const readStream = ({ done, value }: { done: boolean; value?: Uint8Array }) => { if (done) { fileStream.end((err: Error) => { if (err) { diff --git a/app/util/traverseClassInstance.ts b/app/util/traverseClassInstance.ts deleted file mode 100644 index 97e234007a44..000000000000 --- a/app/util/traverseClassInstance.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Travers class methods and props - */ -export function traverseClassInstance( - instance: T, - cb: (propName: string, descriptor: PropertyDescriptor) => unknown, -) { - let entity = instance; - const prototypes = []; - while (entity.constructor.name !== 'Object') { - prototypes.push(entity); - entity = Object.getPrototypeOf(entity); - } - - const alreadyTraversed: Record = {}; - - prototypes.forEach(proto => { - Object.getOwnPropertyNames(proto).forEach(propName => { - if (propName in alreadyTraversed) return; - alreadyTraversed[propName] = true; - const descriptor = Object.getOwnPropertyDescriptor(proto, propName); - if (!descriptor) return; - cb(propName, descriptor); - }); - }); -} diff --git a/package.json b/package.json index fe06a48c681c..78bd58387fd7 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ }, "dependencies": { "@electron/remote": "^2.0.1", - "@reduxjs/toolkit": "^1.5.1", "abort-controller": "^3.0.0", "archiver": "2.1.1", "aws-sdk": "^2.344.0", @@ -86,7 +85,6 @@ "raven": "^2.6.4", "react-colorful": "^5.5.0", "react-devtools-electron": "^4.7.0", - "react-redux": "^7.2.4", "react-transition-group": "^4.4.1", "request": "^2.88.0", "rimraf": "^2.6.1", @@ -105,7 +103,6 @@ "@octokit/app": "3.0.2", "@octokit/request": "4.1.1", "@octokit/rest": "16.43.2", - "@reduxjs/toolkit": "1.5.1", "@sentry/browser": "5.21.4", "@sentry/integrations": "5.21.4", "@types/archiver": "2.1.3", @@ -184,9 +181,8 @@ "qrcode.react": "1.0.1", "raw-loader": "2.0.0", "rc-animate": "3.1.1", - "react": "17.0.1", - "react-dom": "17.0.1", - "react-redux": "7.2.4", + "react": "17.0.2", + "react-dom": "17.0.2", "react-sortablejs": "6.0.0", "recursive-readdir": "2.2.2", "rxjs": "6.3.3", @@ -194,6 +190,7 @@ "shelljs": "0.8.5", "signtool": "1.0.0", "sl-vue-tree": "https://github.com/stream-labs/sl-vue-tree.git", + "slap": "git+https://github.com/stream-labs/slap.git#v0.1.83", "sockjs": "0.3.20", "sockjs-client": "1.1.5", "sortablejs": "1.13.0", @@ -210,7 +207,7 @@ "ts-loader": "8.0.11", "ts-node": "7.0.1", "typedoc": "0.22.10", - "typescript": "4.0.7", + "typescript": "4.5.5", "urijs": "1.19.7", "v-selectpage": "2.0.2", "v-tooltip": "v2.0.3", diff --git a/test/helpers/modules/forms/base.ts b/test/helpers/modules/forms/base.ts index a9cf5277b5db..a6e2b6556c17 100644 --- a/test/helpers/modules/forms/base.ts +++ b/test/helpers/modules/forms/base.ts @@ -20,12 +20,12 @@ export abstract class BaseInputController { /** * Set the input value */ - abstract async setValue(value: TValue): Promise; + abstract setValue(value: TValue): Promise; /** * Get the current input value */ - abstract async getValue(): Promise; + abstract getValue(): Promise; /** * Set the display value diff --git a/test/helpers/spectron/runner-utils.ts b/test/helpers/spectron/runner-utils.ts index 5310e981ac9d..39527d43e1a1 100644 --- a/test/helpers/spectron/runner-utils.ts +++ b/test/helpers/spectron/runner-utils.ts @@ -178,6 +178,6 @@ export async function waitForElectronInstancesExist() { tasks = await getElectronInstances(); timeleft -= interval; } while (tasks.length || timeleft < 0); - resolve(); + resolve(null); }); } diff --git a/test/regular/shared-components/components.ts b/test/regular/shared-components/components.ts index 9d93554825bc..03c70d8a3e3b 100644 --- a/test/regular/shared-components/components.ts +++ b/test/regular/shared-components/components.ts @@ -63,7 +63,7 @@ test('Form inputs', async t => { { name: 'name', title: 'Name', value: 'John Doe', displayValue: 'John Doe' }, { name: 'gender', title: 'Gender', value: 'male', displayValue: 'Male' }, { name: 'age', title: 'Age', value: '20', displayValue: '20' }, - { name: 'city', title: 'City', value: 'C', displayValue: 'Cairo' }, + { name: 'city', title: 'City', value: 'Cairo', displayValue: 'Cairo' }, { name: 'weight', title: 'Weight', value: 100, displayValue: 100 }, { name: 'colors', diff --git a/yarn.lock b/yarn.lock index 3df05456e2d7..d0a953e7eb60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1569,7 +1569,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.14.0 resolution: "@babel/runtime@npm:7.14.0" dependencies: @@ -2070,18 +2070,6 @@ __metadata: languageName: node linkType: hard -"@reduxjs/toolkit@npm:1.5.1": - version: 1.5.1 - resolution: "@reduxjs/toolkit@npm:1.5.1" - dependencies: - immer: ^8.0.1 - redux: ^4.0.0 - redux-thunk: ^2.3.0 - reselect: ^4.0.0 - checksum: 5cdabdf04c3da3b7c5b86136e44325f7b185fb1b812bc432026781c888c3031730429282382eddeefc8bae1f38f6db01e84cbbc51c2d3f0c7a800a35f129dc18 - languageName: node - linkType: hard - "@sentry/browser@npm:5.21.4": version: 5.21.4 resolution: "@sentry/browser@npm:5.21.4" @@ -2340,16 +2328,6 @@ __metadata: languageName: node linkType: hard -"@types/hoist-non-react-statics@npm:^3.3.0": - version: 3.3.1 - resolution: "@types/hoist-non-react-statics@npm:3.3.1" - dependencies: - "@types/react": "*" - hoist-non-react-statics: ^3.3.0 - checksum: 2c0778570d9a01d05afabc781b32163f28409bb98f7245c38d5eaf082416fdb73034003f5825eb5e21313044e8d2d9e1f3fe2831e345d3d1b1d20bcd12270719 - languageName: node - linkType: hard - "@types/http-cache-semantics@npm:*": version: 4.0.0 resolution: "@types/http-cache-semantics@npm:4.0.0" @@ -2536,18 +2514,6 @@ __metadata: languageName: node linkType: hard -"@types/react-redux@npm:^7.1.16": - version: 7.1.16 - resolution: "@types/react-redux@npm:7.1.16" - dependencies: - "@types/hoist-non-react-statics": ^3.3.0 - "@types/react": "*" - hoist-non-react-statics: ^3.3.0 - redux: ^4.0.0 - checksum: c07ee677be781df70aa6209d4152373ec470f6c99b50051e302add8b57d49e89322250959bf9e192738cccc5dbbe05c77514339a45b46ec973c8326227408f26 - languageName: node - linkType: hard - "@types/react@npm:*, @types/react@npm:17.0.8": version: 17.0.8 resolution: "@types/react@npm:17.0.8" @@ -8420,15 +8386,6 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": - version: 3.3.2 - resolution: "hoist-non-react-statics@npm:3.3.2" - dependencies: - react-is: ^16.7.0 - checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 - languageName: node - linkType: hard - "hosted-git-info@npm:^2.1.4": version: 2.5.0 resolution: "hosted-git-info@npm:2.5.0" @@ -8673,10 +8630,10 @@ __metadata: languageName: node linkType: hard -"immer@npm:^8.0.1": - version: 8.0.4 - resolution: "immer@npm:8.0.4" - checksum: 9d3b28df1ac5bf6918c611e71c15bdb136588c21a3431100448f21325fef9b055cc9a44fe8b023f0c5ecbc66a2ba38f403c9a67d581f613b49d0e4ff15564f79 +"immer@npm:^9.0.12": + version: 9.0.12 + resolution: "immer@npm:9.0.12" + checksum: bcbec6d76dac65e49068eb67ece4d407116e62b8cde3126aa89c801e408f5047763ba0aeb62f1938c1aa704bb6612f1d8302bb2a86fa1fc60fcc12d8b25dc895 languageName: node linkType: hard @@ -12339,7 +12296,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.10, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2": +"prop-types@npm:^15.5.10, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2": version: 15.7.2 resolution: "prop-types@npm:15.7.2" dependencies: @@ -13139,20 +13096,20 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:17.0.1": - version: 17.0.1 - resolution: "react-dom@npm:17.0.1" +"react-dom@npm:17.0.2": + version: 17.0.2 + resolution: "react-dom@npm:17.0.2" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 - scheduler: ^0.20.1 + scheduler: ^0.20.2 peerDependencies: - react: 17.0.1 - checksum: df2af300dd4f49a5daaccc38f5a307def2a9ae2b7ebffa3dce8fb9986129057696b86a2c94e5ae36133057c69428c500e4ee3bf5884eb44e5632ace8b7ace41f + react: 17.0.2 + checksum: 1c1eaa3bca7c7228d24b70932e3d7c99e70d1d04e13bb0843bbf321582bc25d7961d6b8a6978a58a598af2af496d1cedcfb1bf65f6b0960a0a8161cb8dab743c languageName: node linkType: hard -"react-is@npm:^16.12.0, react-is@npm:^16.13.1, react-is@npm:^16.7.0, react-is@npm:^16.8.1": +"react-is@npm:^16.12.0, react-is@npm:^16.8.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f @@ -13166,27 +13123,6 @@ __metadata: languageName: node linkType: hard -"react-redux@npm:7.2.4": - version: 7.2.4 - resolution: "react-redux@npm:7.2.4" - dependencies: - "@babel/runtime": ^7.12.1 - "@types/react-redux": ^7.1.16 - hoist-non-react-statics: ^3.3.2 - loose-envify: ^1.4.0 - prop-types: ^15.7.2 - react-is: ^16.13.1 - peerDependencies: - react: ^16.8.3 || ^17 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - checksum: 214fa1a4811f95090b77d96ec7114c322adf388b6d086ebebf50cdaf03549758037283f953d2b920cf6ee2f6ffad8f35e92f1ab959b3f2c957f5075d00e16d0a - languageName: node - linkType: hard - "react-sortablejs@npm:6.0.0": version: 6.0.0 resolution: "react-sortablejs@npm:6.0.0" @@ -13217,13 +13153,13 @@ __metadata: languageName: node linkType: hard -"react@npm:17.0.1": - version: 17.0.1 - resolution: "react@npm:17.0.1" +"react@npm:17.0.2, react@npm:^17.0.2": + version: 17.0.2 + resolution: "react@npm:17.0.2" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 - checksum: 83b9df9529a2b489f00a4eaa608fc7d55518b258e046c100344ae068713e43ae64e477a140f87e38cfe75489bcfd26d27fce5818f89f4ec41bdbda7ead4bb426 + checksum: b254cc17ce3011788330f7bbf383ab653c6848902d7936a87b09d835d091e3f295f7e9dd1597c6daac5dc80f90e778c8230218ba8ad599f74adcc11e33b9d61b languageName: node linkType: hard @@ -13382,22 +13318,6 @@ __metadata: languageName: node linkType: hard -"redux-thunk@npm:^2.3.0": - version: 2.3.0 - resolution: "redux-thunk@npm:2.3.0" - checksum: d13f442ffc91249b534bf14884c33feff582894be2562169637dc9d4d70aec6423bfe6d66f88c46ac027ac1c0cd07d6c2dd4a61cf7695b8e43491de679df9bcf - languageName: node - linkType: hard - -"redux@npm:^4.0.0": - version: 4.1.0 - resolution: "redux@npm:4.1.0" - dependencies: - "@babel/runtime": ^7.9.2 - checksum: 322d5f4b49cbbdb3f64f04e9279cabbdea9a698024b530dc98563eb598b6bd55ff8a715208e3ee09db9802a2f426c991c78906b1c6491ebb52e7310e55ee5cdf - languageName: node - linkType: hard - "regenerate-unicode-properties@npm:^10.0.1": version: 10.0.1 resolution: "regenerate-unicode-properties@npm:10.0.1" @@ -13708,13 +13628,6 @@ __metadata: languageName: node linkType: hard -"reselect@npm:^4.0.0": - version: 4.0.0 - resolution: "reselect@npm:4.0.0" - checksum: ac7dfc9ef2cdb42b6fc87a856f3ce904c2e4363a2bc1e6fb7eea5f78902a6f506e4388e6509752984877c6dbfe501100c076671d334799eb5a1bfe9936cb2c12 - languageName: node - linkType: hard - "resize-observer-polyfill@npm:^1.5.0, resize-observer-polyfill@npm:^1.5.1": version: 1.5.1 resolution: "resize-observer-polyfill@npm:1.5.1" @@ -13993,13 +13906,13 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.20.1": - version: 0.20.1 - resolution: "scheduler@npm:0.20.1" +"scheduler@npm:^0.20.2": + version: 0.20.2 + resolution: "scheduler@npm:0.20.2" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 - checksum: ace896fff8ccc516a4b5a249c712fc88d4e2456587d991acc220b54690362d6a2b9d426a7f030454cb439f6f5ff2706b13ee8c44ccb953884c4756304a2f8ad6 + checksum: c4b35cf967c8f0d3e65753252d0f260271f81a81e427241295c5a7b783abf4ea9e905f22f815ab66676f5313be0a25f47be582254db8f9241b259213e999b8fc languageName: node linkType: hard @@ -14306,6 +14219,16 @@ __metadata: languageName: node linkType: hard +"slap@git+https://github.com/stream-labs/slap.git#v0.1.83": + version: 0.1.83 + resolution: "slap@https://github.com/stream-labs/slap.git#commit=17549e49d3235081bc474a3a49831e79e95e5ea6" + dependencies: + immer: ^9.0.12 + react: ^17.0.2 + checksum: cbc49167d9eead0fb0e7fd44320aa27e27a1b4158383aa7b3dd2d86bb45ad6f8dbf7ddfbebcac68bc129abdeacedb3cb56d36f6770de673805a21ce14f28e13a + languageName: node + linkType: hard + "slash@npm:^2.0.0": version: 2.0.0 resolution: "slash@npm:2.0.0" @@ -14375,7 +14298,6 @@ __metadata: "@octokit/app": 3.0.2 "@octokit/request": 4.1.1 "@octokit/rest": 16.43.2 - "@reduxjs/toolkit": 1.5.1 "@sentry/browser": 5.21.4 "@sentry/integrations": 5.21.4 "@types/archiver": 2.1.3 @@ -14475,11 +14397,10 @@ __metadata: raven: ^2.6.4 raw-loader: 2.0.0 rc-animate: 3.1.1 - react: 17.0.1 + react: 17.0.2 react-colorful: ^5.5.0 react-devtools-electron: ^4.7.0 - react-dom: 17.0.1 - react-redux: 7.2.4 + react-dom: 17.0.2 react-sortablejs: 6.0.0 react-transition-group: ^4.4.1 recursive-readdir: 2.2.2 @@ -14491,6 +14412,7 @@ __metadata: shelljs: 0.8.5 signtool: 1.0.0 sl-vue-tree: "https://github.com/stream-labs/sl-vue-tree.git" + slap: "git+https://github.com/stream-labs/slap.git#v0.1.83" socket.io-client: 2.1.1 sockjs: 0.3.20 sockjs-client: 1.1.5 @@ -14509,7 +14431,7 @@ __metadata: ts-loader: 8.0.11 ts-node: 7.0.1 typedoc: 0.22.10 - typescript: 4.0.7 + typescript: 4.5.5 urijs: 1.19.7 uuid: ^3.0.1 v-selectpage: 2.0.2 @@ -15894,13 +15816,13 @@ __metadata: languageName: node linkType: hard -"typescript@npm:4.0.7": - version: 4.0.7 - resolution: "typescript@npm:4.0.7" +"typescript@npm:4.5.5": + version: 4.5.5 + resolution: "typescript@npm:4.5.5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: dcdea680ae7ad95b3ebc154867b38a5f855b5e8375b7069c5536081076cc3fce9fc977b96f1212446fa55f65746e1a7ea5bbc9d2984968349af0fafffd957a1e + checksum: 506f4c919dc8aeaafa92068c997f1d213b9df4d9756d0fae1a1e7ab66b585ab3498050e236113a1c9e57ee08c21ec6814ca7a7f61378c058d79af50a4b1f5a5e languageName: node linkType: hard @@ -15914,13 +15836,13 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@4.0.7#~builtin": - version: 4.0.7 - resolution: "typescript@patch:typescript@npm%3A4.0.7#~builtin::version=4.0.7&hash=493e53" +"typescript@patch:typescript@4.5.5#~builtin": + version: 4.5.5 + resolution: "typescript@patch:typescript@npm%3A4.5.5#~builtin::version=4.5.5&hash=493e53" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 713faf8e7ed7e9ac5484bc41e62aab59107cd7ae84371c14fd5d42cec84efd8c4fc4bb2ab455cf2149c56c89073e23158dffa2bba3879fd9c4a876b77b6731a8 + checksum: c05c318d79c690f101d7ffb34cd6c7d6bbd884d3af9cefe7749ad0cd6be43c7082f098280982ca945dcba23fde34a08fed9602bb26540936baf8c0520727d3ba languageName: node linkType: hard