diff --git a/app/components-react/root/Chat.tsx b/app/components-react/root/Chat.tsx index 7f398b6aa705..37492adb0123 100644 --- a/app/components-react/root/Chat.tsx +++ b/app/components-react/root/Chat.tsx @@ -1,41 +1,43 @@ import * as remote from '@electron/remote'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useCallback, useMemo } from 'react'; import { Services } from '../service-provider'; import styles from './Chat.m.less'; import { OS, getOS } from '../../util/operating-systems'; import { onUnload } from 'util/unload'; -import { debounce } from 'lodash'; +import { useVuex } from 'components-react/hooks'; export default function Chat(props: { restream: boolean; visibleChat: string; setChat: (key: string) => void; }) { - const { ChatService, RestreamService } = Services; + const { ChatService, RestreamService, WindowsService } = Services; const chatEl = useRef(null); - let currentPosition: IVec2 | null; - let currentSize: IVec2 | null; + const currentPosition = useRef(null); + const currentSize = useRef(null); + const mountedRef = useRef(true); + const service = useMemo(() => (props.restream ? RestreamService : ChatService), [props.restream]); + const windowId = useMemo(() => remote.getCurrentWindow().id, []); - let leaveFullScreenTrigger: Function; + const { hideStyleBlockers } = useVuex(() => ({ + hideStyleBlockers: WindowsService.state.main.hideStyleBlockers, + })); + + const leaveFullScreenTrigger = useCallback(() => { + setTimeout(() => { + setupChat(); + checkResize(); + }, 1000); + }, []); useEffect(() => { - const service = props.restream ? RestreamService : ChatService; const cancelUnload = onUnload(() => service.actions.unmountChat(remote.getCurrentWindow().id)); - window.addEventListener('resize', debounce(checkResize, 100)); - // Work around an electron bug on mac where chat is not interactable // after leaving fullscreen until chat is remounted. if (getOS() === OS.Mac) { - leaveFullScreenTrigger = () => { - setTimeout(() => { - setupChat(); - checkResize(); - }, 1000); - }; - remote.getCurrentWindow().on('leave-full-screen', leaveFullScreenTrigger); } @@ -44,52 +46,55 @@ export default function Chat(props: { setTimeout(checkResize, 100); return () => { - window.removeEventListener('resize', debounce(checkResize, 100)); - if (getOS() === OS.Mac) { remote.getCurrentWindow().removeListener('leave-full-screen', leaveFullScreenTrigger); } service.actions.unmountChat(remote.getCurrentWindow().id); cancelUnload(); - }; - }, [props.restream]); - - function setupChat() { - const service = props.restream ? RestreamService : ChatService; - const windowId = remote.getCurrentWindow().id; - - ChatService.actions.unmountChat(); - RestreamService.actions.unmountChat(windowId); - - service.actions.mountChat(windowId); - currentPosition = null; - currentSize = null; - } - function checkResize() { - const service = props.restream ? RestreamService : ChatService; + mountedRef.current = false; + }; + }, []); - if (!chatEl.current) return; + const checkResize = useCallback(() => { + if (!chatEl.current || !mountedRef.current) return; const rect = chatEl.current.getBoundingClientRect(); - if (currentPosition == null || currentSize == null || rectChanged(rect)) { - currentPosition = { x: rect.left, y: rect.top }; - currentSize = { x: rect.width, y: rect.height }; + if (currentPosition.current == null || currentSize == null || rectChanged(rect)) { + currentPosition.current = { x: rect.left, y: rect.top }; + currentSize.current = { x: rect.width, y: rect.height }; - service.actions.setChatBounds(currentPosition, currentSize); + service.actions.setChatBounds(currentPosition.current, currentSize.current); } - } + }, [service, hideStyleBlockers]); - function rectChanged(rect: ClientRect) { + const rectChanged = useCallback((rect: DOMRect) => { + if (!currentPosition.current || !currentSize.current) return; return ( - rect.left !== currentPosition?.x || - rect.top !== currentPosition?.y || - rect.width !== currentSize?.x || - rect.height !== currentSize?.y + rect.left !== currentPosition.current?.x || + rect.top !== currentPosition.current?.y || + rect.width !== currentSize.current?.x || + rect.height !== currentSize.current?.y ); - } + }, []); + + useEffect(() => { + if (!hideStyleBlockers && mountedRef.current) { + // Small delay to ensure DOM has updated after style blockers removed + setTimeout(() => checkResize(), 50); + } + }, [hideStyleBlockers, checkResize]); + + const setupChat = useCallback(() => { + ChatService.actions.unmountChat(); + RestreamService.actions.unmountChat(windowId); + + service.actions.mountChat(windowId); + currentPosition.current = null; + currentSize.current = null; + }, [service, checkResize]); return
; } diff --git a/app/components-react/shared/AuthModal.tsx b/app/components-react/shared/AuthModal.tsx index aef02d9fc75b..883a65cc6a73 100644 --- a/app/components-react/shared/AuthModal.tsx +++ b/app/components-react/shared/AuthModal.tsx @@ -1,13 +1,14 @@ -import React, { CSSProperties } from 'react'; +import React, { CSSProperties, useMemo } from 'react'; import { Button, Form, Modal } from 'antd'; import styles from './AuthModal.m.less'; import { $t } from 'services/i18n'; import { Services } from 'components-react/service-provider'; import cx from 'classnames'; +import { useVuex } from 'components-react/hooks'; interface AuthModalProps { showModal: boolean; - prompt: string; + prompt?: string; handleAuth: () => void; handleShowModal: (status: boolean) => void; title?: string; @@ -19,11 +20,30 @@ interface AuthModalProps { } export function AuthModal(p: AuthModalProps) { - const title = p?.title || Services.UserService.isLoggedIn ? $t('Log Out') : $t('Login'); - const prompt = p?.prompt; + const { UserService } = Services; + + const { isLoggedIn, primaryPlatform, name } = useVuex(() => ({ + isLoggedIn: UserService.views.isLoggedIn, + primaryPlatform: UserService.views.auth?.primaryPlatform, + name: UserService.views.username, + })); + + const title = p?.title || isLoggedIn ? $t('Log Out') : $t('Login'); const confirm = p?.confirm || $t('Yes'); const cancel = p?.cancel || $t('No'); + const prompt = useMemo(() => { + if (p.prompt) return p.prompt; + + // Instagram doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it + const username = + isLoggedIn && primaryPlatform && primaryPlatform !== 'instagram' ? name : undefined; + + return username + ? $t('Are you sure you want to log out %{username}?', { username }) + : $t('Are you sure you want to log out?'); + }, [p.prompt, name, isLoggedIn, primaryPlatform]); + return ( { + lifecycle(); - function lifecycle() { - if (Utils.isDevMode()) { + return () => { + if (Utils.isDevMode() && Utils.isMainWindow()) { + ipcRenderer.removeAllListeners('unhandledErrorState'); + } + + if (Utils.isDevMode() && Utils.isChildWindow()) { + ipcRenderer.removeListener('unhandledErrorState', () => setErrorState(true)); + } + }; + }, []); + + const lifecycle = useCallback(() => { + if (Utils.isDevMode() && Utils.isMainWindow()) { ipcRenderer.on('unhandledErrorState', () => setErrorState(true)); } - } + }, []); - function minimize() { + const minimize = useCallback(() => { remote.getCurrentWindow().minimize(); - } + }, []); - function maximize() { + const maximize = useCallback(() => { const win = remote.getCurrentWindow(); if (win.isMaximized()) { @@ -50,15 +62,15 @@ export default function TitleBar(props: { windowId: string; className?: string } } else { win.maximize(); } - } + }, []); - function close() { + const close = useCallback(() => { if (Utils.isMainWindow() && StreamingService.isStreaming) { if (!confirm($t('Are you sure you want to exit while live?'))) return; } remote.getCurrentWindow().close(); - } + }, []); return ( <> diff --git a/app/components-react/sidebar/NavTools.tsx b/app/components-react/sidebar/NavTools.tsx index aba3cea61008..54b922abc1d3 100644 --- a/app/components-react/sidebar/NavTools.tsx +++ b/app/components-react/sidebar/NavTools.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import cx from 'classnames'; import electron from 'electron'; import Utils from 'services/utils'; @@ -63,52 +63,45 @@ export default function SideNav() { const [dashboardOpening, setDashboardOpening] = useState(false); const [showModal, setShowModal] = useState(false); - function openSettingsWindow(category?: TCategoryName) { + const openSettingsWindow = useCallback((category?: TCategoryName) => { SettingsService.actions.showSettings(category); - } + }, []); - function openDevTools() { + const openDevTools = useCallback(() => { electron.ipcRenderer.send('openDevTools'); - } + }, []); - async function openDashboard(page?: string) { - UsageStatisticsService.actions.recordClick('SideNav2', page || 'dashboard'); - if (dashboardOpening) return; - setDashboardOpening(true); + const openDashboard = useCallback( + async (page?: string) => { + UsageStatisticsService.actions.recordClick('SideNav2', page || 'dashboard'); + if (dashboardOpening) return; + setDashboardOpening(true); - try { - const link = await MagicLinkService.getDashboardMagicLink(page); - remote.shell.openExternal(link); - } catch (e: unknown) { - console.error('Error generating dashboard magic link', e); - } + try { + const link = await MagicLinkService.getDashboardMagicLink(page); + remote.shell.openExternal(link); + } catch (e: unknown) { + console.error('Error generating dashboard magic link', e); + } - setDashboardOpening(false); - } + setDashboardOpening(false); + }, + [dashboardOpening], + ); const throttledOpenDashboard = throttle(openDashboard, 2000, { trailing: false }); - // Instagram doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it - const username = - isLoggedIn && UserService.views.auth!.primaryPlatform !== 'instagram' - ? UserService.username - : undefined; - - const confirmMsg = username - ? $t('Are you sure you want to log out %{username}?', { username }) - : $t('Are you sure you want to log out?'); - - function openHelp() { + const openHelp = useCallback(() => { UsageStatisticsService.actions.recordClick('SideNav2', 'help'); remote.shell.openExternal(UrlService.supportLink); - } + }, []); - async function upgradeToPrime() { + const upgradeToPrime = useCallback(async () => { UsageStatisticsService.actions.recordClick('SideNav2', 'prime'); MagicLinkService.linkToPrime('slobs-side-nav'); - } + }, []); - const handleAuth = () => { + const handleAuth = useCallback(() => { if (isLoggedIn) { Services.DualOutputService.actions.setDualOutputModeIfPossible(false, true); UserService.actions.logOut(); @@ -116,12 +109,12 @@ export default function SideNav() { WindowsService.actions.closeChildWindow(); UserService.actions.showLogin(); } - }; + }, [isLoggedIn]); - const handleShowModal = (status: boolean) => { - updateStyleBlockers('main', status); + const handleShowModal = useCallback((status: boolean) => { setShowModal(status); - }; + updateStyleBlockers('main', status); + }, []); return ( <> @@ -222,7 +215,6 @@ export default function SideNav() { ({ currentMenuItem: SideNavService.views.currentMenuItem, setCurrentMenuItem: SideNavService.actions.setCurrentMenuItem, isOpen: SideNavService.views.isOpen, toggleMenuStatus: SideNavService.actions.toggleMenuStatus, updateStyleBlockers: WindowsService.actions.updateStyleBlockers, - hideStyleBlockers: WindowsService.state.main.hideStyleBlockers, })); - const sider = useRef(null); - const isMounted = useRef(false); - const leftDock = useRealmObject(CustomizationService.state).leftDock; - const siderMinWidth: number = 50; - const siderMaxWidth: number = 200; - - // We need to ignore resizeObserver entries for vertical resizing - let lastHeight = 0; - - const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => { - entries.forEach((entry: ResizeObserverEntry) => { - const width = Math.floor(entry?.contentRect?.width); - const height = Math.floor(entry?.contentRect?.height); - - if (lastHeight === height && (width === siderMinWidth || width === siderMaxWidth)) { - updateStyleBlockers('main', false); - } - lastHeight = height; - }); - }); - - useEffect(() => { - isMounted.current = true; - if (!sider || !sider.current) return; - - if (sider && sider?.current) { - resizeObserver.observe(sider?.current); - - if (hideStyleBlockers) { - updateStyleBlockers('main', false); - } - } - - return () => { - if (sider && sider?.current) { - resizeObserver.disconnect(); - } - - isMounted.current = false; - }; - }, [sider]); - const updateSubMenu = useCallback(() => { // when opening/closing the navbar swap the submenu current menu item // to correctly display selected color @@ -95,18 +50,24 @@ export default function SideNav() { } }, [currentMenuItem]); + const toggleSideNav = useCallback(() => { + updateStyleBlockers('main', true); + updateSubMenu(); + toggleMenuStatus(); + }, [isOpen, toggleMenuStatus, updateStyleBlockers, updateSubMenu]); + return ( {/* top navigation menu */} @@ -128,11 +89,8 @@ export default function SideNav() { isOpen && styles.siderOpen, leftDock && styles.leftDock, )} - onClick={() => { - updateSubMenu(); - toggleMenuStatus(); - updateStyleBlockers('main', true); // hide style blockers - }} + onClick={toggleSideNav} + onTransitionEnd={() => updateStyleBlockers('main', false)} > diff --git a/app/components-react/windows/Main.m.less b/app/components-react/windows/Main.m.less index 841a351d95e4..d2be9d138336 100644 --- a/app/components-react/windows/Main.m.less +++ b/app/components-react/windows/Main.m.less @@ -53,6 +53,12 @@ } } +.studio-footer-container { + display: flex; + min-width: 0px; + grid-row: 2 / span 1; +} + .main-page-container { /* Page always takes up remaining space */ flex-grow: 1; @@ -114,24 +120,24 @@ } .live-dock-collapsed { - width: 20px; - height: 100%; - padding: 0; - position: relative; - border-left: 1px solid var(--border); - box-sizing: border-box; - - &.left { - border-right: 1px solid var(--border); - - .live-dock-chevron.left { - left: 50%; - } - } + width: 20px; + height: 100%; + padding: 0; + position: relative; + border-left: 1px solid var(--border); + box-sizing: border-box; - .live-dock-chevron { - .center(); + &.left { + border-right: 1px solid var(--border); + + .live-dock-chevron.left { + left: 50%; } + } + + .live-dock-chevron { + .center(); + } } .live-dock-chevron { diff --git a/app/components-react/windows/Main.tsx b/app/components-react/windows/Main.tsx index 5d3c7057ee72..443027236ade 100644 --- a/app/components-react/windows/Main.tsx +++ b/app/components-react/windows/Main.tsx @@ -23,6 +23,9 @@ import { TApplicationTheme } from 'services/customization'; import styles from './Main.m.less'; import { StatefulService } from 'services'; import { useRealmObject } from 'components-react/hooks/realm'; +import { Layout } from 'antd'; + +const { Sider } = Layout; // TODO: this is technically deprecated as we have moved customizationService to Realm // but some users may still have this value @@ -47,12 +50,12 @@ async function isDirectory(path: string) { export default function Main() { const { AppService, - StreamingService, WindowsService, UserService, EditorCommandsService, ScenesService, CustomizationService, + NavigationService, } = Services; const mainWindowEl = useRef(null); const mainMiddleEl = useRef(null); @@ -69,12 +72,11 @@ export default function Main() { const uiReady = bulkLoadFinished && i18nReady; - const page = useRealmObject(Services.NavigationService.state).currentPage; - const params = useRealmObject(Services.NavigationService.state).params; - const realmDockWidth = useRealmObject(Services.CustomizationService.state).livedockSize; - const isDockCollapsed = useRealmObject(Services.CustomizationService.state).livedockCollapsed; - const realmTheme = useRealmObject(Services.CustomizationService.state).theme; - const leftDock = useRealmObject(Services.CustomizationService.state).leftDock; + const page = useRealmObject(NavigationService.state).currentPage; + const params = useRealmObject(NavigationService.state).params; + const realmDockWidth = useRealmObject(CustomizationService.state).livedockSize; + const realmTheme = useRealmObject(CustomizationService.state).theme; + const leftDock = useRealmObject(CustomizationService.state).leftDock; // Provides smooth chat resizing instead of writing to realm every tick while resizing const [dockWidth, setDockWidth] = useState(realmDockWidth); @@ -83,7 +85,6 @@ export default function Main() { errorAlert, applicationLoading, hideStyleBlockers, - streamingStatus, isLoggedIn, platform, activeSceneId, @@ -91,7 +92,6 @@ export default function Main() { errorAlert: AppService.state.errorAlert, applicationLoading: AppService.state.loading, hideStyleBlockers: WindowsService.state.main.hideStyleBlockers, - streamingStatus: StreamingService.state.streamingStatus, isLoggedIn: UserService.views.isLoggedIn, platform: UserService.views.platform, activeSceneId: ScenesService.views.activeSceneId, @@ -170,33 +170,38 @@ export default function Main() { if (dockWidth !== constrainedWidth) setDockWidth(dockWidth); }, []); - const setCollapsed = useCallback((livedockCollapsed: boolean) => { - WindowsService.actions.updateStyleBlockers('main', true); - CustomizationService.actions.setSettings({ livedockCollapsed }); - setTimeout(() => { - WindowsService.actions.updateStyleBlockers('main', false); - }, 300); - }, []); + const windowSizeHandler = useCallback(() => { + updateStyleBlockers(true); - function windowSizeHandler() { - if (!hideStyleBlockers) { - updateStyleBlockers(true); - } const windowWidth = window.innerWidth; if (windowResizeTimeout.current) clearTimeout(windowResizeTimeout.current); - setHasLiveDock(page === 'Studio' ? windowWidth >= minEditorWidth + 100 : windowWidth >= 1070); - windowResizeTimeout.current = window.setTimeout(() => { - updateStyleBlockers(false); - const appRect = mainWindowEl.current?.getBoundingClientRect(); - if (!appRect) return; - setMaxDockWidth(Math.min(appRect.width - minEditorWidth, appRect.width / 2)); - setMinDockWidth(Math.min(290, maxDockWidth)); + // To prevent infinite loop, only set hasLiveDock if it needs to change + if (page === 'Studio' && hasLiveDock && windowWidth < minEditorWidth + 100) { + setHasLiveDock(false); + } else if (page === 'Studio' && !hasLiveDock && windowWidth >= minEditorWidth + 100) { + setHasLiveDock(true); + } else if (page !== 'Studio' && !hasLiveDock && windowWidth >= 1070) { + setHasLiveDock(true); + } else if (page !== 'Studio' && hasLiveDock && windowWidth < 1070) { + setHasLiveDock(false); + } - updateLiveDockWidth(); - }, 200); - } + if (hasLiveDock) { + windowResizeTimeout.current = window.setTimeout(() => { + const appRect = mainWindowEl.current?.getBoundingClientRect(); + if (!appRect) return; + setMaxDockWidth(Math.min(appRect.width - minEditorWidth, appRect.width / 2)); + setMinDockWidth(Math.min(290, maxDockWidth)); + + if (hasLiveDock) { + updateLiveDockWidth(); + } + updateStyleBlockers(false); + }, 200); + } + }, [hasLiveDock, minEditorWidth, maxDockWidth, page]); useEffect(() => { const unsubscribe = StatefulService.store.subscribe((_, state) => { @@ -223,12 +228,6 @@ export default function Main() { }; }, []); - useEffect(() => { - if (streamingStatus === EStreamingState.Starting && isDockCollapsed) { - setCollapsed(false); - } - }, [streamingStatus]); - const oldTheme = useRef(null); useEffect(() => { if (!theme) return; @@ -273,7 +272,7 @@ export default function Main() { [styles.mainContentsOnboarding]: page === 'Onboarding', })} > - {page !== 'Onboarding' && !showLoadingSpinner && ( + {page !== 'Onboarding' && !showLoadingSpinner && uiReady && (
@@ -283,8 +282,8 @@ export default function Main() { max={maxDockWidth} min={minDockWidth} width={dockWidth} - setCollapsed={setCollapsed} setLiveDockWidth={setDockWidth} + hasLiveDock={hasLiveDock} onLeft /> )} @@ -311,8 +310,8 @@ export default function Main() { max={maxDockWidth} min={minDockWidth} width={dockWidth} - setCollapsed={setCollapsed} setLiveDockWidth={setDockWidth} + hasLiveDock={hasLiveDock} /> )}
@@ -332,19 +331,35 @@ interface ILiveDockContainerProps { max: number; min: number; width: number; - setCollapsed: (val: boolean) => void; setLiveDockWidth: (val: number) => void; + hasLiveDock: boolean; onLeft?: boolean; } function LiveDockContainer(p: ILiveDockContainerProps) { - const isDockCollapsed = useRealmObject(Services.CustomizationService.state).livedockCollapsed; + const { WindowsService, CustomizationService, StreamingService } = Services; + const isDockCollapsed = useRealmObject(CustomizationService.state).livedockCollapsed; + const { updateStyleBlockers, streamingStatus } = useVuex(() => ({ + streamingStatus: StreamingService.state.streamingStatus, + updateStyleBlockers: WindowsService.actions.updateStyleBlockers, + })); + + const setCollapsed = useCallback((livedockCollapsed: boolean) => { + updateStyleBlockers('main', true); + CustomizationService.actions.setSettings({ livedockCollapsed }); + }, []); + + useEffect(() => { + if (streamingStatus === EStreamingState.Starting && isDockCollapsed) { + setCollapsed(false); + } + }, [streamingStatus]); function Chevron() { return (
p.setCollapsed(!isDockCollapsed)} + onClick={() => setCollapsed(!isDockCollapsed)} > { - if ((p.onLeft && isDockCollapsed) || (!p.onLeft && !isDockCollapsed)) { - return 'ant-slide-right'; - } - return 'ant-slide-left'; - }, [p.onLeft, isDockCollapsed]); - return ( - - {isDockCollapsed && ( -
- -
- )} - {!isDockCollapsed && ( - p.setLiveDockWidth(val)} - max={p.max} - min={p.min} - value={p.width} - transformScale={1} - key="expanded" - > -
- + + updateStyleBlockers('main', false)} + hidden={!p.hasLiveDock} + > + {isDockCollapsed && ( +
- - )} - + )} + {!isDockCollapsed && ( + p.setLiveDockWidth(val)} + max={p.max} + min={p.min} + value={p.width} + transformScale={1} + key="expanded" + > +
+ + +
+
+ )} +
+
); } diff --git a/app/components-react/windows/go-live/GoLiveWindow.tsx b/app/components-react/windows/go-live/GoLiveWindow.tsx index a89284fae91d..0c340af16f68 100644 --- a/app/components-react/windows/go-live/GoLiveWindow.tsx +++ b/app/components-react/windows/go-live/GoLiveWindow.tsx @@ -1,8 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import styles from './GoLive.m.less'; -import { WindowsService, DualOutputService, IncrementalRolloutService } from 'app-services'; +import { WindowsService } from 'app-services'; import { ModalLayout } from '../../shared/ModalLayout'; -import { Button, message } from 'antd'; +import { Button, message, Modal } from 'antd'; import { Services } from '../../service-provider'; import GoLiveSettings from './GoLiveSettings'; import { $t } from '../../../services/i18n'; @@ -13,10 +13,9 @@ import { useGoLiveSettings, useGoLiveSettingsRoot } from './useGoLiveSettings'; import { inject } from 'slap'; import RecordingSwitcher from './RecordingSwitcher'; import { promptAction } from 'components-react/modals'; -import { EAvailableFeatures } from 'services/incremental-rollout'; export default function GoLiveWindow() { - const { lifecycle, form } = useGoLiveSettingsRoot().extend(module => ({ + const { lifecycle, form, destroy } = useGoLiveSettingsRoot().extend(module => ({ destroy() { // clear failed checks and warnings on window close if (module.checklist.startVideoTransmission !== 'done') { @@ -28,6 +27,14 @@ export default function GoLiveWindow() { const shouldShowSettings = ['empty', 'prepopulate', 'waitForNewSettings'].includes(lifecycle); const shouldShowChecklist = ['runChecklist', 'live'].includes(lifecycle); + useEffect(() => { + return () => { + destroy(); + // Note: the below will only destroy modals in the Go Live window and will not effect other windows + Modal.destroyAll(); + }; + }, []); + return ( } className={styles.dualOutputGoLive}>