desktop/flipper-ui-core/src/sandy-chrome/LeftRail.tsx (462 lines of code) (raw):

/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import React, {cloneElement, useState, useCallback, useMemo} from 'react'; import {Button, Divider, Badge, Tooltip, Avatar, Popover, Menu} from 'antd'; import { MobileFilled, AppstoreOutlined, BellOutlined, FileExclamationOutlined, LoginOutlined, SettingOutlined, MedicineBoxOutlined, RocketOutlined, } from '@ant-design/icons'; import {SidebarLeft, SidebarRight} from './SandyIcons'; import {useDispatch, useStore} from '../utils/useStore'; import { toggleLeftSidebarVisible, toggleRightSidebarVisible, } from '../reducers/application'; import { theme, Layout, withTrackingScope, Dialog, useTrackedCallback, NUX, } from 'flipper-plugin'; import SetupDoctorScreen, {checkHasNewProblem} from './SetupDoctorScreen'; import SettingsSheet from '../chrome/SettingsSheet'; import WelcomeScreen from './WelcomeScreen'; import {errorCounterAtom} from '../chrome/ConsoleLogs'; import {ToplevelProps} from './SandyApp'; import {useValue} from 'flipper-plugin'; import {logout} from '../reducers/user'; import config from '../fb-stubs/config'; import styled from '@emotion/styled'; import {showEmulatorLauncher} from './appinspect/LaunchEmulator'; import SupportRequestFormV2 from '../fb-stubs/SupportRequestFormV2'; import {setStaticView} from '../reducers/connections'; import {getLogger} from 'flipper-common'; import {SandyRatingButton} from '../chrome/RatingButton'; import {filterNotifications} from './notification/notificationUtils'; import {useMemoize} from 'flipper-plugin'; import isProduction from '../utils/isProduction'; import NetworkGraph from '../chrome/NetworkGraph'; import FpsGraph from '../chrome/FpsGraph'; import UpdateIndicator from '../chrome/UpdateIndicator'; import PluginManager from '../chrome/plugin-manager/PluginManager'; import {showLoginDialog} from '../chrome/fb-stubs/SignInSheet'; import SubMenu from 'antd/lib/menu/SubMenu'; import constants from '../fb-stubs/constants'; import { canFileExport, canOpenDialog, showOpenDialog, startFileExport, startLinkExport, } from '../utils/exportData'; import {openDeeplinkDialog} from '../deeplink'; import {css} from '@emotion/css'; import {getRenderHostInstance} from '../RenderHost'; import openSupportRequestForm from '../fb-stubs/openSupportRequestForm'; const LeftRailButtonElem = styled(Button)<{kind?: 'small'}>(({kind}) => ({ width: kind === 'small' ? 32 : 36, height: kind === 'small' ? 32 : 36, padding: '5px 0', border: 'none', boxShadow: 'none', })); LeftRailButtonElem.displayName = 'LeftRailButtonElem'; export function LeftRailButton({ icon, small, selected, toggled, count, title, onClick, disabled, }: { icon?: React.ReactElement; small?: boolean; toggled?: boolean; selected?: boolean; disabled?: boolean; count?: number | true; title?: string; onClick?: React.MouseEventHandler<HTMLElement>; }) { const iconElement = icon && cloneElement(icon, {style: {fontSize: small ? 16 : 24}}); let res = ( <LeftRailButtonElem title={title} kind={small ? 'small' : undefined} type={selected ? 'primary' : 'ghost'} icon={iconElement} onClick={onClick} disabled={disabled} style={{ color: toggled ? theme.primaryColor : undefined, background: toggled ? theme.backgroundWash : undefined, }} /> ); if (count !== undefined) { res = count === true ? ( <Badge dot offset={[-8, 8]} {...{onClick}}> {res} </Badge> ) : ( <Badge count={count} offset={[-6, 5]} {...{onClick}}> {res} </Badge> ); } if (title) { res = ( <Tooltip title={title} placement="right"> {res} </Tooltip> ); } return res; } const LeftRailDivider = styled(Divider)({ margin: `10px 0`, width: 32, minWidth: 32, }); LeftRailDivider.displayName = 'LeftRailDividier'; export const LeftRail = withTrackingScope(function LeftRail({ toplevelSelection, setToplevelSelection, }: ToplevelProps) { return ( <Layout.Container borderRight padv={12} width={48}> <Layout.Bottom style={{overflow: 'visible'}}> <Layout.Container center gap={10} padh={6}> <LeftRailButton icon={<MobileFilled />} title="App Inspect" selected={toplevelSelection === 'appinspect'} onClick={() => { setToplevelSelection('appinspect'); }} /> <LeftRailButton icon={<AppstoreOutlined />} title="Plugin Manager" onClick={() => { Dialog.showModal((onHide) => <PluginManager onHide={onHide} />); }} /> <NotificationButton toplevelSelection={toplevelSelection} setToplevelSelection={setToplevelSelection} /> <LeftRailDivider /> <DebugLogsButton toplevelSelection={toplevelSelection} setToplevelSelection={setToplevelSelection} /> </Layout.Container> <Layout.Container center gap={10} padh={6}> {!isProduction() && ( <div> <FpsGraph /> <NetworkGraph /> </div> )} <UpdateIndicator /> <SandyRatingButton /> <LaunchEmulatorButton /> <SetupDoctorButton /> <RightSidebarToggleButton /> <LeftSidebarToggleButton /> <ExtrasMenu /> {config.showLogin && <LoginButton />} </Layout.Container> </Layout.Bottom> </Layout.Container> ); }); const menu = css` border: none; `; const submenu = css` .ant-menu-submenu-title { width: 32px; height: 32px !important; line-height: 32px !important; padding: 0; margin: 0; } .ant-menu-submenu-arrow { display: none; } `; const MenuDividerPadded = styled(Menu.Divider)({ marginBottom: '8px !important', }); function ExtrasMenu() { const store = useStore(); const startFileExportTracked = useTrackedCallback( 'File export', () => startFileExport(store.dispatch), [store.dispatch], ); const startLinkExportTracked = useTrackedCallback( 'Link export', () => startLinkExport(store.dispatch), [store.dispatch], ); const startImportTracked = useTrackedCallback( 'File import', () => showOpenDialog(store), [store], ); const [showSettings, setShowSettings] = useState(false); const onSettingsClose = useCallback(() => setShowSettings(false), []); const settings = useStore((state) => state.settingsState); const {showWelcomeAtStartup} = settings; const [welcomeVisible, setWelcomeVisible] = useState(showWelcomeAtStartup); const fullState = useStore((state) => state); return ( <> <NUX title="Find import, export, deeplink, feedback, settings, and help (welcome) here" placement="right"> <Menu mode="vertical" className={menu} selectable={false} style={{backgroundColor: theme.backgroundDefault}}> <SubMenu popupOffset={[10, 0]} key="extras" title={<LeftRailButton icon={<SettingOutlined />} small />} className={submenu}> {canOpenDialog() ? ( <Menu.Item key="importFlipperFile" onClick={startImportTracked}> Import Flipper file </Menu.Item> ) : null} {canFileExport() ? ( <Menu.Item key="exportFile" onClick={startFileExportTracked}> Export Flipper file </Menu.Item> ) : null} {constants.ENABLE_SHAREABLE_LINK ? ( <Menu.Item key="exportShareableLink" onClick={startLinkExportTracked}> Export shareable link </Menu.Item> ) : null} <Menu.Item key="triggerDeeplink" onClick={() => openDeeplinkDialog(store)}> Trigger deeplink </Menu.Item> {config.isFBBuild ? ( <> <MenuDividerPadded /> <Menu.Item key="feedback" onClick={() => { getLogger().track('usage', 'support-form-source', { source: 'sidebar', group: undefined, }); if ( getRenderHostInstance().GK('flipper_support_entry_point') ) { openSupportRequestForm(fullState); } else { store.dispatch(setStaticView(SupportRequestFormV2)); } }}> Feedback </Menu.Item> </> ) : null} <MenuDividerPadded /> <Menu.Item key="settings" onClick={() => setShowSettings(true)}> Settings </Menu.Item> <Menu.Divider /> <Menu.Item key="help" onClick={() => setWelcomeVisible(true)}> Help </Menu.Item> </SubMenu> </Menu> </NUX> {showSettings && ( <SettingsSheet platform={ getRenderHostInstance().serverConfig.environmentInfo.os.platform } onHide={onSettingsClose} /> )} <WelcomeScreen visible={welcomeVisible} onClose={() => setWelcomeVisible(false)} showAtStartup={showWelcomeAtStartup} onCheck={(value) => store.dispatch({ type: 'UPDATE_SETTINGS', payload: {...settings, showWelcomeAtStartup: value}, }) } /> </> ); } function LeftSidebarToggleButton() { const dispatch = useDispatch(); const mainMenuVisible = useStore( (state) => state.application.leftSidebarVisible, ); return ( <LeftRailButton icon={<SidebarLeft />} small title="Left Sidebar Toggle" toggled={mainMenuVisible} onClick={() => { dispatch(toggleLeftSidebarVisible()); }} /> ); } function RightSidebarToggleButton() { const dispatch = useDispatch(); const rightSidebarAvailable = useStore( (state) => state.application.rightSidebarAvailable, ); const rightSidebarVisible = useStore( (state) => state.application.rightSidebarVisible, ); return ( <LeftRailButton icon={<SidebarRight />} small title="Right Sidebar Toggle" toggled={rightSidebarVisible} disabled={!rightSidebarAvailable} onClick={() => { dispatch(toggleRightSidebarVisible()); }} /> ); } function NotificationButton({ toplevelSelection, setToplevelSelection, }: ToplevelProps) { const notifications = useStore((state) => state.notifications); const activeNotifications = useMemoize(filterNotifications, [ notifications.activeNotifications, notifications.blocklistedPlugins, notifications.blocklistedCategories, ]); return ( <LeftRailButton icon={<BellOutlined />} title="Notifications" selected={toplevelSelection === 'notification'} count={activeNotifications.length} onClick={() => setToplevelSelection('notification')} /> ); } function DebugLogsButton({ toplevelSelection, setToplevelSelection, }: ToplevelProps) { const errorCount = useValue(errorCounterAtom); return ( <LeftRailButton icon={<FileExclamationOutlined />} title="Flipper Logs" selected={toplevelSelection === 'flipperlogs'} count={errorCount} onClick={() => { setToplevelSelection('flipperlogs'); }} /> ); } function LaunchEmulatorButton() { const store = useStore(); return ( <LeftRailButton icon={<RocketOutlined />} title="Start Emulator / Simulator" onClick={() => { showEmulatorLauncher(store); }} small /> ); } function SetupDoctorButton() { const [visible, setVisible] = useState(false); const result = useStore( (state) => state.healthchecks.healthcheckReport.result, ); const hasNewProblem = useMemo(() => checkHasNewProblem(result), [result]); const onClose = useCallback(() => setVisible(false), []); return ( <> <LeftRailButton icon={<MedicineBoxOutlined />} small title="Setup Doctor" count={hasNewProblem ? true : undefined} onClick={() => setVisible(true)} /> <SetupDoctorScreen visible={visible} onClose={onClose} /> </> ); } function LoginButton() { const dispatch = useDispatch(); const user = useStore((state) => state.user); const login = (user?.id ?? null) !== null; const profileUrl = user?.profile_picture?.uri; const [showLogout, setShowLogout] = useState(false); const onHandleVisibleChange = useCallback( (visible) => setShowLogout(visible), [], ); return login ? ( <Popover content={ <Button block style={{backgroundColor: theme.backgroundDefault}} onClick={() => { onHandleVisibleChange(false); dispatch(logout()); }}> Log Out </Button> } trigger="click" placement="right" visible={showLogout} overlayStyle={{padding: 0}} onVisibleChange={onHandleVisibleChange}> <Layout.Container padv={theme.inlinePaddingV}> <Avatar size="small" src={profileUrl} /> </Layout.Container> </Popover> ) : ( <> <LeftRailButton icon={<LoginOutlined />} title="Log In" onClick={() => showLoginDialog()} /> </> ); }