diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index b9685bd2ac..187370e848 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -118,9 +118,13 @@ type PersistableValues_4_15_0 = PersistableValues_4_14_0 & { e2ePdfPreviewSizeLimit: number; }; +type PersistableValues_4_16_0 = PersistableValues_4_15_0 & { + navigationLayout: 'tabs' | 'sidebar'; +}; + export type PersistableValues = Pick< - PersistableValues_4_15_0, - keyof PersistableValues_4_15_0 + PersistableValues_4_16_0, + keyof PersistableValues_4_16_0 >; export const migrations = { @@ -233,4 +237,9 @@ export const migrations = { ...before, e2ePdfPreviewSizeLimit: DEFAULT_E2E_PDF_PREVIEW_SIZE_LIMIT_MB, }), + '>=4.16.0': (before: PersistableValues_4_15_0): PersistableValues_4_16_0 => ({ + ...before, + navigationLayout: + (before as Partial).navigationLayout ?? 'tabs', + }), }; diff --git a/src/app/__tests__/PersistableValues.spec.ts b/src/app/__tests__/PersistableValues.spec.ts index 5988634746..467d7714f9 100644 --- a/src/app/__tests__/PersistableValues.spec.ts +++ b/src/app/__tests__/PersistableValues.spec.ts @@ -15,4 +15,24 @@ describe('PersistableValues migrations', () => { }, }); }); + + it('preserves a persisted navigationLayout value', () => { + const before = { + navigationLayout: 'sidebar', + } as unknown as Parameters<(typeof migrations)['>=4.16.0']>[0]; + + expect(migrations['>=4.16.0'](before)).toEqual( + expect.objectContaining({ navigationLayout: 'sidebar' }) + ); + }); + + it('defaults navigationLayout to tabs when absent', () => { + const before = {} as unknown as Parameters< + (typeof migrations)['>=4.16.0'] + >[0]; + + expect(migrations['>=4.16.0'](before)).toEqual( + expect.objectContaining({ navigationLayout: 'tabs' }) + ); + }); }); diff --git a/src/app/main/data.spec.ts b/src/app/main/data.spec.ts index d42fb57206..46d1d779af 100644 --- a/src/app/main/data.spec.ts +++ b/src/app/main/data.spec.ts @@ -1,9 +1,33 @@ import * as store from '../../store'; import { APP_SETTINGS_LOADED } from '../actions'; import { mergePersistableValues } from './data'; +import { getPersistedValues } from './persistence'; jest.mock('../../store'); +jest.mock('./persistence', () => ({ + getPersistedValues: jest.fn().mockReturnValue({}), + persistValues: jest.fn(), +})); + +jest.mock('fs', () => ({ + promises: { + readFile: jest.fn().mockRejectedValue(new Error('File not found')), + unlink: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn().mockReturnValue('/user/data'), + getVersion: jest.fn().mockReturnValue('0.0.0'), + }, +})); + +jest.mock('../../logging', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, +})); + const mockDispatch = jest.fn(); const mockSelect = jest.fn(); @@ -11,12 +35,14 @@ beforeEach(() => { jest.clearAllMocks(); (store.dispatch as jest.Mock).mockImplementation(mockDispatch); (store.select as jest.Mock).mockImplementation(mockSelect); + (getPersistedValues as jest.Mock).mockReturnValue({}); }); describe('mergePersistableValues', () => { const mockInitialValues = { isMenuBarEnabled: true, isSideBarEnabled: true, + navigationLayout: 'tabs' as const, rootWindowState: { focused: true, visible: true, @@ -30,33 +56,31 @@ describe('mergePersistableValues', () => { beforeEach(() => { mockSelect.mockReturnValue(mockInitialValues); - - jest.doMock('./persistence', () => ({ - getPersistedValues: jest.fn().mockReturnValue({}), - })); - - jest.doMock('fs', () => ({ - promises: { - readFile: jest.fn().mockRejectedValue(new Error('File not found')), - unlink: jest.fn().mockResolvedValue(undefined), - }, - })); - - jest.doMock('electron', () => ({ - app: { - getPath: jest.fn().mockReturnValue('/user/data'), - }, - })); }); - describe('menubar and sidebar recovery mechanism', () => { - it('should enable sidebar when both menubar and sidebar are disabled', async () => { + describe('menubar recovery mechanism', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + }); + + it('should enable menubar on Linux when in tabs layout with menubar disabled', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); const localStorage = {}; - mockSelect.mockReturnValueOnce({ + mockSelect.mockReturnValue({ ...mockInitialValues, isMenuBarEnabled: false, - isSideBarEnabled: false, + navigationLayout: 'tabs', }); await mergePersistableValues(localStorage); @@ -64,19 +88,23 @@ describe('mergePersistableValues', () => { expect(mockDispatch).toHaveBeenCalledWith({ type: APP_SETTINGS_LOADED, payload: expect.objectContaining({ - isMenuBarEnabled: false, - isSideBarEnabled: true, + isMenuBarEnabled: true, }), }); }); - it('should not modify settings when menubar is enabled and sidebar is disabled', async () => { + it('should not modify settings on Linux when in sidebar layout with menubar disabled', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); const localStorage = {}; - mockSelect.mockReturnValueOnce({ + mockSelect.mockReturnValue({ ...mockInitialValues, - isMenuBarEnabled: true, - isSideBarEnabled: false, + isMenuBarEnabled: false, + navigationLayout: 'sidebar', }); await mergePersistableValues(localStorage); @@ -84,19 +112,23 @@ describe('mergePersistableValues', () => { expect(mockDispatch).toHaveBeenCalledWith({ type: APP_SETTINGS_LOADED, payload: expect.objectContaining({ - isMenuBarEnabled: true, - isSideBarEnabled: false, + isMenuBarEnabled: false, }), }); }); - it('should not modify settings when sidebar is enabled and menubar is disabled', async () => { + it('should not modify settings on non-Linux platforms when in tabs layout with menubar disabled', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); const localStorage = {}; - mockSelect.mockReturnValueOnce({ + mockSelect.mockReturnValue({ ...mockInitialValues, isMenuBarEnabled: false, - isSideBarEnabled: true, + navigationLayout: 'tabs', }); await mergePersistableValues(localStorage); @@ -105,18 +137,22 @@ describe('mergePersistableValues', () => { type: APP_SETTINGS_LOADED, payload: expect.objectContaining({ isMenuBarEnabled: false, - isSideBarEnabled: true, }), }); }); - it('should not modify settings when both menubar and sidebar are enabled', async () => { + it('should not modify settings when menubar is already enabled', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); const localStorage = {}; - mockSelect.mockReturnValueOnce({ + mockSelect.mockReturnValue({ ...mockInitialValues, isMenuBarEnabled: true, - isSideBarEnabled: true, + navigationLayout: 'sidebar', }); await mergePersistableValues(localStorage); @@ -125,7 +161,6 @@ describe('mergePersistableValues', () => { type: APP_SETTINGS_LOADED, payload: expect.objectContaining({ isMenuBarEnabled: true, - isSideBarEnabled: true, }), }); }); @@ -137,10 +172,11 @@ describe('mergePersistableValues', () => { autohideMenu: 'true', }; - mockSelect.mockReturnValueOnce({ + mockSelect.mockReturnValue({ ...mockInitialValues, isMenuBarEnabled: false, isSideBarEnabled: false, + navigationLayout: 'sidebar', }); await mergePersistableValues(localStorage); @@ -149,7 +185,7 @@ describe('mergePersistableValues', () => { type: APP_SETTINGS_LOADED, payload: expect.objectContaining({ isMenuBarEnabled: false, - isSideBarEnabled: true, + isSideBarEnabled: false, }), }); }); @@ -160,10 +196,11 @@ describe('mergePersistableValues', () => { 'autohideMenu': 'true', }; - mockSelect.mockReturnValueOnce({ + mockSelect.mockReturnValue({ ...mockInitialValues, isMenuBarEnabled: false, isSideBarEnabled: false, + navigationLayout: 'sidebar', }); await mergePersistableValues(localStorage); @@ -172,7 +209,7 @@ describe('mergePersistableValues', () => { type: APP_SETTINGS_LOADED, payload: expect.objectContaining({ isMenuBarEnabled: false, - isSideBarEnabled: true, + isSideBarEnabled: false, }), }); }); diff --git a/src/app/main/data.ts b/src/app/main/data.ts index 4d36fa842c..b0befb3468 100644 --- a/src/app/main/data.ts +++ b/src/app/main/data.ts @@ -164,10 +164,14 @@ export const mergePersistableValues = async ( }, }; - if (!values.isMenuBarEnabled && !values.isSideBarEnabled) { + if ( + values.navigationLayout !== 'sidebar' && + !values.isMenuBarEnabled && + process.platform === 'linux' + ) { values = { ...values, - isSideBarEnabled: true, + isMenuBarEnabled: true, }; } diff --git a/src/app/selectors.ts b/src/app/selectors.ts index fa1f910a95..d7eb989935 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -1,8 +1,10 @@ -import { createStructuredSelector } from 'reselect'; +import { createSelector, createStructuredSelector } from 'reselect'; import type { RootState } from '../store/rootReducer'; -export const selectPersistableValues = createStructuredSelector({ +// Split into two structured selectors: a single one with 46+ keys exceeds +// TypeScript's type-instantiation depth limit under ts-jest (TS2589). +const selectPersistableValuesA = createStructuredSelector({ currentView: ({ currentView }: RootState) => currentView, doCheckForUpdatesOnStartup: ({ doCheckForUpdatesOnStartup }: RootState) => doCheckForUpdatesOnStartup, @@ -13,6 +15,7 @@ export const selectPersistableValues = createStructuredSelector({ isShowWindowOnUnreadChangedEnabled, }: RootState) => isShowWindowOnUnreadChangedEnabled, isSideBarEnabled: ({ isSideBarEnabled }: RootState) => isSideBarEnabled, + navigationLayout: ({ navigationLayout }: RootState) => navigationLayout, isTrayIconEnabled: ({ isTrayIconEnabled }: RootState) => isTrayIconEnabled, rootWindowState: ({ rootWindowState }: RootState) => rootWindowState, servers: ({ servers }: RootState) => servers, @@ -40,6 +43,9 @@ export const selectPersistableValues = createStructuredSelector({ }: RootState) => isInternalVideoChatWindowEnabled, isMinimizeOnCloseEnabled: ({ isMinimizeOnCloseEnabled }: RootState) => isMinimizeOnCloseEnabled, +}); + +const selectPersistableValuesB = createStructuredSelector({ isAddNewServersEnabled: ({ isAddNewServersEnabled }: RootState) => isAddNewServersEnabled, isDeveloperModeEnabled: ({ isDeveloperModeEnabled }: RootState) => @@ -92,3 +98,8 @@ export const selectPersistableValues = createStructuredSelector({ }: RootState) => telephonyGlobalShortcutConfig, isTelephonyEnabled: ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled, }); + +export const selectPersistableValues = createSelector( + [selectPersistableValuesA, selectPersistableValuesB], + (a, b) => ({ ...a, ...b }) +); diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 0f9bf7d179..f2289ea316 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -305,6 +305,13 @@ "description": "Show workspace list with downloads and settings buttons.", "disabledHint": "Cannot disable workspace bar when the menu bar is disabled. App settings would become inaccessible." }, + "navigation": { + "title": "Workspace switcher", + "description": "Choose how to switch between workspaces", + "workspaceTabs": "Workspace Tabs", + "workspaceBar": "Workspace Bar", + "disabledHint": "Enable the menu bar first to use Workspace Tabs." + }, "trayIcon": { "title": "Tray icon", "titleDarwin": "Menu bar extra", @@ -415,6 +422,7 @@ "documentation": "Documentation", "downloads": "Downloads", "settings": "App settings", + "checkForUpdates": "Check for updates", "editMenu": "&Edit", "fileMenu": "&File", "forward": "&Forward", @@ -441,6 +449,10 @@ "showServerList": "Server list", "showTrayIcon": "Tray icon", "toggleDevTools": "Toggle &DevTools", + "workspaceTabs": "Workspace Tabs", + "workspaceBar": "Workspace Bar", + "nextWorkspace": "Next Workspace", + "previousWorkspace": "Previous Workspace", "openConfigFolder": "Open &Configuration Folder", "openLogViewer": "Open &Log Viewer", "videoCallDevTools": "Open Video Call &DevTools", @@ -497,7 +509,8 @@ "copyCurrentUrl": "Copy current URL", "reloadClearingCache": "Force reload", "serverInfo": "Server Info", - "supportedVersionsInfo": "Supported Versions Info" + "supportedVersionsInfo": "Supported Versions Info", + "addWorkspace": "Add workspace" }, "tooltips": { "unreadMessage": "{{- count}} unread message", @@ -507,6 +520,20 @@ "settingsMenu": "Customize and control app" } }, + "tabBar": { + "workspaces": "Workspaces", + "addWorkspace": "Add workspace", + "unreadMessage_one": "{{- count}} message", + "unreadMessage_other": "{{- count}} messages", + "unreadMessages": "Unread messages", + "meatballMenu": "Application menu", + "windowControls": { + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close" + } + }, "touchBar": { "formatting": "Formatting", "selectServer": "Select server" diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index dbf8f26b4f..7eb70e1ff9 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -50,6 +50,7 @@ import { isVideoCallDevtoolsAutoOpenEnabled } from '../ui/reducers/isVideoCallDe import { isVideoCallScreenCaptureFallbackEnabled } from '../ui/reducers/isVideoCallScreenCaptureFallbackEnabled'; import { isVideoCallWindowPersistenceEnabled } from '../ui/reducers/isVideoCallWindowPersistenceEnabled'; import { lastSelectedServerUrl } from '../ui/reducers/lastSelectedServerUrl'; +import { navigationLayout } from '../ui/reducers/navigationLayout'; import { openDialog } from '../ui/reducers/openDialog'; import { rootWindowIcon } from '../ui/reducers/rootWindowIcon'; import { rootWindowState } from '../ui/reducers/rootWindowState'; @@ -88,6 +89,7 @@ export const rootReducer = combineReducers({ isMessageBoxFocused, isShowWindowOnUnreadChangedEnabled, isSideBarEnabled, + navigationLayout, isTrayIconEnabled, isMinimizeOnCloseEnabled, isUpdatingAllowed, diff --git a/src/ui/actions.ts b/src/ui/actions.ts index a144d7afbc..60979d1143 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -1,7 +1,7 @@ import type { WebContents } from 'electron'; import type { Server } from '../servers/common'; -import type { RootWindowIcon, WindowState } from './common'; +import type { NavigationLayout, RootWindowIcon, WindowState } from './common'; export const ABOUT_DIALOG_DISMISSED = 'about-dialog/dismissed'; export const ABOUT_DIALOG_TOGGLE_UPDATE_ON_START = @@ -33,6 +33,8 @@ export const MENU_BAR_TOGGLE_IS_DEVELOPER_MODE_ENABLED_CLICKED = 'menu-bar/toggle-is-developer-mode-enabled-clicked'; export const MENU_BAR_TOGGLE_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CLICKED = 'menu-bar/toggle-is-video-call-devtools-auto-open-enabled-clicked'; +export const MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED = + 'menu-bar/set-navigation-layout-clicked'; export const ROOT_WINDOW_ICON_CHANGED = 'root-window/icon-changed'; export const ROOT_WINDOW_STATE_CHANGED = 'root-window/state-changed'; export const VIDEO_CALL_WINDOW_STATE_CHANGED = @@ -133,10 +135,11 @@ export const SETTINGS_SET_DEBUG_LOGGING_CHANGED = 'settings/set-debug-logging-changed'; export const SETTINGS_SET_E2E_PDF_PREVIEW_SIZE_LIMIT_CHANGED = 'settings/set-e2e-pdf-preview-size-limit-changed'; +export const SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED = + 'settings/set-navigation-layout-changed'; export const SET_HAS_TRAY_MINIMIZE_NOTIFICATION_SHOWN = 'notifications/set-has-tray-minimize-notification-shown'; export const VIDEO_CALL_WINDOW_OPEN_URL = 'video-call-window/open-url'; -export const DOWNLOADS_BACK_BUTTON_CLICKED = 'downloads/back-button-clicked'; export const WEBVIEW_SERVER_SUPPORTED_VERSIONS_UPDATED = 'webview/server-supported-versions-updated'; export const WEBVIEW_SERVER_UNIQUE_ID_UPDATED = @@ -169,6 +172,12 @@ export const TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE = 'telephony-default-handler-prompt/close'; export const TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED = 'telephony-default-handler-prompt/open-settings-clicked'; +export const APP_MENU_TRIGGERED = 'app-menu/triggered'; +export const WINDOW_CONTROLS_MINIMIZE_CLICKED = + 'window-controls/minimize-clicked'; +export const WINDOW_CONTROLS_MAXIMIZE_CLICKED = + 'window-controls/maximize-clicked'; +export const WINDOW_CONTROLS_CLOSE_CLICKED = 'window-controls/close-clicked'; export type UiActionTypeToPayloadMap = { [ABOUT_DIALOG_DISMISSED]: void; @@ -189,6 +198,7 @@ export type UiActionTypeToPayloadMap = { [MENU_BAR_TOGGLE_IS_TRAY_ICON_ENABLED_CLICKED]: boolean; [MENU_BAR_TOGGLE_IS_DEVELOPER_MODE_ENABLED_CLICKED]: boolean; [MENU_BAR_TOGGLE_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CLICKED]: boolean; + [MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED]: NavigationLayout; [ROOT_WINDOW_ICON_CHANGED]: RootWindowIcon | null; [ROOT_WINDOW_STATE_CHANGED]: WindowState; [VIDEO_CALL_WINDOW_STATE_CHANGED]: WindowState; @@ -285,9 +295,9 @@ export type UiActionTypeToPayloadMap = { [SETTINGS_SET_DETAILED_EVENTS_LOGGING_CHANGED]: boolean; [SETTINGS_SET_DEBUG_LOGGING_CHANGED]: boolean; [SETTINGS_SET_E2E_PDF_PREVIEW_SIZE_LIMIT_CHANGED]: number; + [SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED]: NavigationLayout; [SET_HAS_TRAY_MINIMIZE_NOTIFICATION_SHOWN]: boolean; [VIDEO_CALL_WINDOW_OPEN_URL]: { url: string }; - [DOWNLOADS_BACK_BUTTON_CLICKED]: string; [WEBVIEW_SERVER_SUPPORTED_VERSIONS_UPDATED]: { url: Server['url']; supportedVersions: Server['supportedVersions']; @@ -338,4 +348,8 @@ export type UiActionTypeToPayloadMap = { [TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN]: void; [TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE]: void; [TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED]: void; + [APP_MENU_TRIGGERED]: { x: number; y: number }; + [WINDOW_CONTROLS_MINIMIZE_CLICKED]: void; + [WINDOW_CONTROLS_MAXIMIZE_CLICKED]: void; + [WINDOW_CONTROLS_CLOSE_CLICKED]: void; }; diff --git a/src/ui/common.ts b/src/ui/common.ts index bb47203eb6..ba1f102fbf 100644 --- a/src/ui/common.ts +++ b/src/ui/common.ts @@ -2,6 +2,8 @@ import type { AddRepresentationOptions } from 'electron'; export type IconRepresentation = Omit; +export type NavigationLayout = 'tabs' | 'sidebar'; + export type RootWindowIcon = { icon: IconRepresentation[]; overlay?: IconRepresentation[]; diff --git a/src/ui/components/DownloadsManagerView/index.spec.tsx b/src/ui/components/DownloadsManagerView/index.spec.tsx index 941de809e6..1dad41543d 100644 --- a/src/ui/components/DownloadsManagerView/index.spec.tsx +++ b/src/ui/components/DownloadsManagerView/index.spec.tsx @@ -1,6 +1,5 @@ import DownloadsManagerView from '.'; import type { Download } from '../../../downloads/common'; -import { DOWNLOADS_BACK_BUTTON_CLICKED } from '../../actions'; import { renderWithStore, screen, userEvent } from '../../test-utils'; // The filter values are persisted through fuselage's `useLocalStorage`, which @@ -64,8 +63,6 @@ const makeDownload = (overrides: Partial): Download => ({ const visibleState = { currentView: 'downloads', - isSideBarEnabled: false, - lastSelectedServerUrl: 'https://chat.example.com', downloads: {}, } as any; @@ -170,40 +167,4 @@ describe('DownloadsManagerView', () => { expect(screen.getByTestId('download-item-2')).toBeInTheDocument(); }); }); - - describe('back button', () => { - // The back IconButton renders only its `arrow-back` icon with no `title` / - // `aria-label`, so it has no accessible name and cannot be queried by role - // name. It is located through the rendered icon's button ancestor instead. - // (Component a11y gap: the back button should expose an accessible label — - // flagged, not changed here.) - const getBackButton = (container: HTMLElement): HTMLButtonElement | null => - container - .querySelector('.rcx-icon--name-arrow-back') - ?.closest('button') ?? null; - - it('renders the back button and dispatches on click when the sidebar is disabled', async () => { - const user = userEvent.setup(); - const { container } = renderWithStore(, { - preloadedState: { ...visibleState, isSideBarEnabled: false }, - }); - - const backButton = getBackButton(container); - expect(backButton).not.toBeNull(); - await user.click(backButton as HTMLButtonElement); - - expect(mockDispatch).toHaveBeenCalledWith({ - type: DOWNLOADS_BACK_BUTTON_CLICKED, - payload: 'https://chat.example.com', - }); - }); - - it('hides the back button when the sidebar is enabled', () => { - const { container } = renderWithStore(, { - preloadedState: { ...visibleState, isSideBarEnabled: true }, - }); - - expect(getBackButton(container)).toBeNull(); - }); - }); }); diff --git a/src/ui/components/DownloadsManagerView/index.tsx b/src/ui/components/DownloadsManagerView/index.tsx index 1c36420784..26caf2cc96 100644 --- a/src/ui/components/DownloadsManagerView/index.tsx +++ b/src/ui/components/DownloadsManagerView/index.tsx @@ -21,9 +21,7 @@ import { useSelector } from 'react-redux'; import type { Download } from '../../../downloads/common'; import { DownloadStatus } from '../../../downloads/common'; -import { dispatch } from '../../../store'; import type { RootState } from '../../../store/rootReducer'; -import { DOWNLOADS_BACK_BUTTON_CLICKED } from '../../actions'; import DownloadItem from './DownloadItem'; const DownloadsManagerView = () => { @@ -36,14 +34,6 @@ const DownloadsManagerView = () => { '' ); - const isSideBarEnabled = useSelector( - ({ isSideBarEnabled }: RootState) => isSideBarEnabled - ); - - const lastSelectedServerUrl = useSelector( - ({ lastSelectedServerUrl }: RootState) => lastSelectedServerUrl - ); - const handleSearchFilterChange = useCallback( (event: ChangeEvent) => { setSearchFilter(event.target.value); @@ -200,13 +190,6 @@ const DownloadsManagerView = () => { } }, [currentPagination, downloads.length]); - const handleBackButton = function (): void { - dispatch({ - type: DOWNLOADS_BACK_BUTTON_CLICKED, - payload: lastSelectedServerUrl, - }); - }; - return ( { flexWrap='nowrap' alignItems='center' > - {!isSideBarEnabled && ( - - )} {t('downloads.title')} diff --git a/src/ui/components/ServersView/ServerPane.tsx b/src/ui/components/ServersView/ServerPane.tsx index 9b2cc49a9b..9893af6282 100644 --- a/src/ui/components/ServersView/ServerPane.tsx +++ b/src/ui/components/ServersView/ServerPane.tsx @@ -10,6 +10,7 @@ import { WEBVIEW_ATTACHED, WEBVIEW_READY, } from '../../actions'; +import { getServerPanelId, getServerTabId } from '../utils/getServerDomId'; import DocumentViewer from './DocumentViewer'; import ErrorView from './ErrorView'; import UnsupportedServer from './UnsupportedServer'; @@ -26,6 +27,7 @@ type ServerPaneProps = { documentViewerOpenUrl: string | undefined; documentViewerFormat: string | undefined; userLoggedIn?: boolean; + isTabPanel?: boolean; }; export const ServerPane = ({ @@ -38,6 +40,7 @@ export const ServerPane = ({ documentViewerOpenUrl, documentViewerFormat, userLoggedIn, + isTabPanel = false, }: ServerPaneProps) => { const dispatch = useDispatch>(); @@ -204,7 +207,15 @@ export const ServerPane = ({ }, [serverUrl]); return ( - + { const servers = useServers(); + const navigationLayout = useSelector( + ({ navigationLayout }: RootState) => navigationLayout + ); return ( @@ -20,6 +26,7 @@ export const ServersView = () => { documentViewerOpenUrl={server.documentViewerOpenUrl} documentViewerFormat={server.documentViewerFormat} userLoggedIn={server.userLoggedIn} + isTabPanel={navigationLayout === 'tabs'} /> ))} diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index 00797a8f67..4bac822361 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -7,9 +7,9 @@ import { HardwareAcceleration } from './features/HardwareAcceleration'; import { MenuBar } from './features/MenuBar'; import { MinimizeOnClose } from './features/MinimizeOnClose'; import { NTLMCredentials } from './features/NTLMCredentials'; +import { NavigationLayout } from './features/NavigationLayout'; import { OutlookCalendarSyncInterval } from './features/OutlookCalendarSyncInterval'; import { ReportErrors } from './features/ReportErrors'; -import { SideBar } from './features/SideBar'; import { ThemeAppearance } from './features/ThemeAppearance'; import { TransparentWindow } from './features/TransparentWindow'; import { TrayIcon } from './features/TrayIcon'; @@ -22,7 +22,7 @@ export const GeneralTab = () => { - + @@ -30,7 +30,7 @@ export const GeneralTab = () => { {isDarwin && } {isWin32 && } - {!isDarwin && } + {!isDarwin && !isWin32 && } diff --git a/src/ui/components/SettingsView/SettingsView.tsx b/src/ui/components/SettingsView/SettingsView.tsx index 890cced05d..e1d2666717 100644 --- a/src/ui/components/SettingsView/SettingsView.tsx +++ b/src/ui/components/SettingsView/SettingsView.tsx @@ -1,12 +1,10 @@ -import { Box, IconButton, Scrollable, Tabs } from '@rocket.chat/fuselage'; +import { Box, Scrollable, Tabs } from '@rocket.chat/fuselage'; import '@rocket.chat/fuselage-polyfills'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { dispatch } from '../../../store'; import type { RootState } from '../../../store/rootReducer'; -import { DOWNLOADS_BACK_BUTTON_CLICKED } from '../../actions'; import { CertificatesTab } from './CertificatesTab'; import { DeveloperTab } from './DeveloperTab'; import { GeneralTab } from './GeneralTab'; @@ -20,14 +18,6 @@ export const SettingsView = () => { const [currentTab, setCurrentTab] = useState('general'); - const isSideBarEnabled = useSelector( - ({ isSideBarEnabled }: RootState) => isSideBarEnabled - ); - - const lastSelectedServerUrl = useSelector( - ({ lastSelectedServerUrl }: RootState) => lastSelectedServerUrl - ); - const isDeveloperModeEnabled = useSelector( ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled ); @@ -38,12 +28,6 @@ export const SettingsView = () => { } }, [isDeveloperModeEnabled, currentTab]); - const handleBackButton = function (): void { - dispatch({ - type: DOWNLOADS_BACK_BUTTON_CLICKED, - payload: lastSelectedServerUrl, - }); - }; return ( { fontScale='h1' color='default' > - {!isSideBarEnabled && ( - - )} {t('settings.title')} diff --git a/src/ui/components/SettingsView/features/MenuBar.tsx b/src/ui/components/SettingsView/features/MenuBar.tsx index 9d7878b2b0..dc59894ce1 100644 --- a/src/ui/components/SettingsView/features/MenuBar.tsx +++ b/src/ui/components/SettingsView/features/MenuBar.tsx @@ -17,8 +17,8 @@ export const MenuBar = (props: MenuBarProps) => { const isMenuBarEnabled = useSelector( ({ isMenuBarEnabled }: RootState) => isMenuBarEnabled ); - const isSideBarEnabled = useSelector( - ({ isSideBarEnabled }: RootState) => isSideBarEnabled + const navigationLayout = useSelector( + ({ navigationLayout }: RootState) => navigationLayout ); const dispatch = useDispatch>(); const { t } = useTranslation(); @@ -34,14 +34,14 @@ export const MenuBar = (props: MenuBarProps) => { ); const isMenuBarEnabledId = useId(); - const canToggle = !isMenuBarEnabled || isSideBarEnabled; + const canToggle = !isMenuBarEnabled || navigationLayout === 'sidebar'; return ( ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +describe('NavigationLayout', () => { + it('checks workspace tabs option when navigationLayout=tabs', () => { + const store = makeStore({ + navigationLayout: 'tabs', + isMenuBarEnabled: true, + }); + render( + + + + ); + const [workspaceTabs, workspaceBar] = screen.getAllByRole('radio'); + expect(workspaceTabs).toBeChecked(); + expect(workspaceBar).not.toBeChecked(); + }); + + it('checks workspace bar option when navigationLayout=sidebar', () => { + const store = makeStore({ + navigationLayout: 'sidebar', + isMenuBarEnabled: true, + }); + render( + + + + ); + const [workspaceTabs, workspaceBar] = screen.getAllByRole('radio'); + expect(workspaceTabs).not.toBeChecked(); + expect(workspaceBar).toBeChecked(); + }); + + it('dispatches SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED with "sidebar" when workspace bar is selected', () => { + const store = makeStore({ + navigationLayout: 'tabs', + isMenuBarEnabled: true, + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const [, workspaceBar] = screen.getAllByRole('radio'); + fireEvent.click(workspaceBar); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, + payload: 'sidebar', + }); + }); + + it('dispatches SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED with "tabs" when workspace tabs is selected', () => { + const store = makeStore({ + navigationLayout: 'sidebar', + isMenuBarEnabled: true, + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const [workspaceTabs] = screen.getAllByRole('radio'); + fireEvent.click(workspaceTabs); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, + payload: 'tabs', + }); + }); + + describe('on linux with the menu bar hidden', () => { + const originalPlatform = process.platform; + + beforeAll(() => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('disables the workspace tabs option when navigationLayout=sidebar', () => { + const store = makeStore({ + navigationLayout: 'sidebar', + isMenuBarEnabled: false, + }); + render( + + + + ); + const [workspaceTabs, workspaceBar] = screen.getAllByRole('radio'); + expect(workspaceTabs).toBeDisabled(); + expect(workspaceBar).not.toBeDisabled(); + }); + + it('keeps the workspace tabs option enabled when navigationLayout is already tabs', () => { + const store = makeStore({ + navigationLayout: 'tabs', + isMenuBarEnabled: false, + }); + render( + + + + ); + const [workspaceTabs] = screen.getAllByRole('radio'); + expect(workspaceTabs).not.toBeDisabled(); + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/NavigationLayout.tsx b/src/ui/components/SettingsView/features/NavigationLayout.tsx new file mode 100644 index 0000000000..2603abcb2c --- /dev/null +++ b/src/ui/components/SettingsView/features/NavigationLayout.tsx @@ -0,0 +1,91 @@ +import { + Field, + FieldLabel, + FieldDescription, + FieldRow, + RadioButton, + Box, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent } from 'react'; +import { useCallback, useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED } from '../../../actions'; +import type { NavigationLayout as NavigationLayoutValue } from '../../../common'; + +type NavigationLayoutProps = { + className?: string; +}; + +export const NavigationLayout = (props: NavigationLayoutProps) => { + const navigationLayout = useSelector( + ({ navigationLayout }: RootState) => navigationLayout + ); + const isMenuBarEnabled = useSelector( + ({ isMenuBarEnabled }: RootState) => isMenuBarEnabled + ); + const dispatch = useDispatch>(); + const { t } = useTranslation(); + + const handleChange = useCallback( + (value: NavigationLayoutValue) => + (event: ChangeEvent) => { + if (!event.currentTarget.checked) { + return; + } + dispatch({ + type: SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, + payload: value, + }); + }, + [dispatch] + ); + + const workspaceTabsId = useId(); + const workspaceBarId = useId(); + + const isWorkspaceTabsDisabled = + process.platform === 'linux' && + !isMenuBarEnabled && + navigationLayout !== 'tabs'; + + return ( + + + {t('settings.options.navigation.title')} + + + {isWorkspaceTabsDisabled + ? t('settings.options.navigation.disabledHint') + : t('settings.options.navigation.description')} + + + + + + {t('settings.options.navigation.workspaceTabs')} + + + + + + {t('settings.options.navigation.workspaceBar')} + + + + + ); +}; diff --git a/src/ui/components/SettingsView/features/SideBar.tsx b/src/ui/components/SettingsView/features/SideBar.tsx deleted file mode 100644 index 69f5e5981a..0000000000 --- a/src/ui/components/SettingsView/features/SideBar.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { ChangeEvent } from 'react'; -import { useCallback, useId } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux'; -import type { Dispatch } from 'redux'; - -import type { RootAction } from '../../../../store/actions'; -import type { RootState } from '../../../../store/rootReducer'; -import { SETTINGS_SET_IS_SIDE_BAR_ENABLED_CHANGED } from '../../../actions'; -import { ToggleField } from './ToggleField'; - -type SideBarProps = { - className?: string; -}; - -export const SideBar = (props: SideBarProps) => { - const isSideBarEnabled = useSelector( - ({ isSideBarEnabled }: RootState) => isSideBarEnabled - ); - const isMenuBarEnabled = useSelector( - ({ isMenuBarEnabled }: RootState) => isMenuBarEnabled - ); - const dispatch = useDispatch>(); - const { t } = useTranslation(); - const handleChange = useCallback( - (event: ChangeEvent) => { - const isChecked = event.currentTarget.checked; - dispatch({ - type: SETTINGS_SET_IS_SIDE_BAR_ENABLED_CHANGED, - payload: isChecked, - }); - }, - [dispatch] - ); - - const isSideBarEnabledId = useId(); - const canToggle = !isSideBarEnabled || isMenuBarEnabled; - - return ( - - ); -}; diff --git a/src/ui/components/Shell/index.spec.tsx b/src/ui/components/Shell/index.spec.tsx index 34fa8ce185..755984c422 100644 --- a/src/ui/components/Shell/index.spec.tsx +++ b/src/ui/components/Shell/index.spec.tsx @@ -52,6 +52,37 @@ jest.mock('../TopBar', () => ({ TopBar: () =>
, })); +jest.mock('../TabBar', () => ({ + __esModule: true, + TabBar: ({ + leadingSlot, + trailingSlot, + }: { + leadingSlot?: React.ReactNode; + trailingSlot?: React.ReactNode; + }) => ( +
+ {leadingSlot} + {trailingSlot} +
+ ), +})); + +jest.mock('../TabBar/MeatballMenuButton', () => ({ + __esModule: true, + MeatballMenuButton: () =>
, +})); + +jest.mock('../TabBar/WindowControls', () => ({ + __esModule: true, + WindowControls: () =>
, +})); + +jest.mock('../TabBar/WindowsTitleBar', () => ({ + __esModule: true, + WindowsTitleBar: () =>
, +})); + jest.mock('../AboutDialog', () => ({ __esModule: true, AboutDialog: () =>
, @@ -135,9 +166,23 @@ const buildState = (overrides: Record = {}) => machineTheme: 'light', userThemePreference: 'auto', isTransparentWindowEnabled: false, + navigationLayout: 'sidebar', ...overrides, }) as any; +const setPlatform = (platform: NodeJS.Platform): (() => void) => { + const original = process.platform; + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + return () => + Object.defineProperty(process, 'platform', { + value: original, + configurable: true, + }); +}; + describe('Shell', () => { it('renders the layout and its primary views without crashing', () => { renderWithStore(, { preloadedState: buildState() }); @@ -221,4 +266,81 @@ describe('Shell', () => { ); expect(link).not.toBeInTheDocument(); }); + + it('renders the sidebar layout (SideBar + TopBar, no TabBar) when navigationLayout is sidebar', () => { + const restorePlatform = setPlatform('darwin'); + + try { + renderWithStore(, { + preloadedState: buildState({ navigationLayout: 'sidebar' }), + }); + + expect(screen.getByTestId('side-bar')).toBeInTheDocument(); + expect(screen.getByTestId('top-bar')).toBeInTheDocument(); + expect(screen.queryByTestId('tab-bar')).not.toBeInTheDocument(); + } finally { + restorePlatform(); + } + }); + + it('renders the tabs layout (TabBar, no TopBar/WindowDragBar) when navigationLayout is tabs', () => { + renderWithStore(, { + preloadedState: buildState({ navigationLayout: 'tabs' }), + }); + + expect(screen.getByTestId('tab-bar')).toBeInTheDocument(); + expect(screen.queryByTestId('top-bar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('window-drag-bar')).not.toBeInTheDocument(); + }); + + describe('win32 chrome', () => { + let restorePlatform: () => void; + + afterEach(() => { + restorePlatform?.(); + }); + + it('mounts the meatball menu and window controls as TabBar slots when navigationLayout is tabs', () => { + restorePlatform = setPlatform('win32'); + + renderWithStore(, { + preloadedState: buildState({ navigationLayout: 'tabs' }), + }); + + expect(screen.getByTestId('tab-bar')).toBeInTheDocument(); + expect(screen.getByTestId('meatball-menu-button')).toBeInTheDocument(); + expect(screen.getByTestId('window-controls')).toBeInTheDocument(); + expect(screen.queryByTestId('windows-title-bar')).not.toBeInTheDocument(); + }); + + it('renders WindowsTitleBar (no meatball/window-controls slots, no TopBar) when navigationLayout is sidebar', () => { + restorePlatform = setPlatform('win32'); + + renderWithStore(, { + preloadedState: buildState({ navigationLayout: 'sidebar' }), + }); + + expect(screen.getByTestId('windows-title-bar')).toBeInTheDocument(); + expect(screen.queryByTestId('top-bar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tab-bar')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('meatball-menu-button') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument(); + }); + + it('does not mount win32 chrome on darwin', () => { + restorePlatform = setPlatform('darwin'); + + renderWithStore(, { + preloadedState: buildState({ navigationLayout: 'tabs' }), + }); + + expect( + screen.queryByTestId('meatball-menu-button') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('window-controls')).not.toBeInTheDocument(); + expect(screen.queryByTestId('windows-title-bar')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/ui/components/Shell/index.tsx b/src/ui/components/Shell/index.tsx index 7e942f9366..f855f0dbe0 100644 --- a/src/ui/components/Shell/index.tsx +++ b/src/ui/components/Shell/index.tsx @@ -17,6 +17,10 @@ import { ServersView } from '../ServersView'; import { SettingsView } from '../SettingsView'; import { SideBar } from '../SideBar'; import { SupportedVersionDialog } from '../SupportedVersionDialog'; +import { TabBar } from '../TabBar'; +import { MeatballMenuButton } from '../TabBar/MeatballMenuButton'; +import { WindowControls } from '../TabBar/WindowControls'; +import { WindowsTitleBar } from '../TabBar/WindowsTitleBar'; import { TelephonyDefaultHandlerPromptModal } from '../TelephonyDefaultHandlerPromptModal'; import { TelephonyServerSelectModal } from '../TelephonyServerSelectModal'; import { TopBar } from '../TopBar'; @@ -35,6 +39,9 @@ export const Shell = () => { const isTransparentWindowEnabled = useSelector( ({ isTransparentWindowEnabled }: RootState) => isTransparentWindowEnabled ); + const navigationLayout = useSelector( + ({ navigationLayout }: RootState) => navigationLayout + ); const [currentTheme, setCurrentTheme] = useState( machineTheme as Themes @@ -71,7 +78,9 @@ export const Shell = () => { // tagId='sidebar-palette' /> - {process.platform === 'darwin' && } + {navigationLayout === 'sidebar' && process.platform === 'darwin' && ( + + )} { height='100vh' flexDirection='column' > - {process.platform === 'darwin' && } + {navigationLayout === 'tabs' && process.platform === 'win32' && ( + } + trailingSlot={} + /> + )} + {navigationLayout === 'tabs' && process.platform !== 'win32' && ( + + )} + {navigationLayout === 'sidebar' && process.platform === 'darwin' && ( + + )} + {navigationLayout === 'sidebar' && process.platform === 'win32' && ( + + )} - title - ?.replace(url, new URL(url).hostname ?? '') - ?.split(/[^A-Za-z0-9]+/g) - ?.slice(0, 2) - ?.map((text) => text.slice(0, 1).toUpperCase()) - ?.join(''), - [title, url] - ); + const initials = useMemo(() => getServerInitials(title, url), [title, url]); const handleActionDropdownClick = ( action: ServerActionType, diff --git a/src/ui/components/SideBar/index.spec.tsx b/src/ui/components/SideBar/index.spec.tsx index 817b16c8a4..ef352d9cdf 100644 --- a/src/ui/components/SideBar/index.spec.tsx +++ b/src/ui/components/SideBar/index.spec.tsx @@ -43,9 +43,9 @@ const buildState = (overrides: Record = {}) => { url: 'https://b.rocket.chat/', title: 'Server B' }, ], currentView: { url: 'https://a.rocket.chat/' }, - isSideBarEnabled: true, isAddNewServersEnabled: true, isTransparentWindowEnabled: false, + navigationLayout: 'sidebar', ...overrides, }) as any; @@ -113,9 +113,9 @@ describe('SideBar', () => { expect(wrapper).toHaveStyle({ display: 'flex' }); }); - it('collapses the server column when the sidebar is disabled', () => { + it('collapses the server column when navigationLayout is tabs', () => { renderWithStore(, { - preloadedState: buildState({ isSideBarEnabled: false }), + preloadedState: buildState({ navigationLayout: 'tabs' }), }); const wrapper = document.querySelector('.rcx-sidebar--main') diff --git a/src/ui/components/SideBar/index.tsx b/src/ui/components/SideBar/index.tsx index 296584a3e0..3231d0c61d 100644 --- a/src/ui/components/SideBar/index.tsx +++ b/src/ui/components/SideBar/index.tsx @@ -18,16 +18,16 @@ import { SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, SIDE_BAR_SETTINGS_BUTTON_CLICKED, } from '../../actions'; +import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useServers } from '../hooks/useServers'; +import { useSorting } from '../hooks/useSorting'; import ServerButton from './ServerButton'; -import { useKeyboardShortcuts } from './useKeyboardShortcuts'; -import { useSorting } from './useSorting'; export const SideBar = () => { const servers = useServers(); - const isSideBarEnabled = useSelector( - ({ isSideBarEnabled }: RootState) => isSideBarEnabled + const navigationLayout = useSelector( + ({ navigationLayout }: RootState) => navigationLayout ); const isAddNewServersEnabled = useSelector( @@ -38,7 +38,7 @@ export const SideBar = () => { ({ isTransparentWindowEnabled }: RootState) => isTransparentWindowEnabled ); - const isVisible = servers.length > 0 && isSideBarEnabled; + const isVisible = servers.length > 0 && navigationLayout === 'sidebar'; const isEachShortcutVisible = useKeyboardShortcuts(); const { diff --git a/src/ui/components/TabBar/CloseGlyph.tsx b/src/ui/components/TabBar/CloseGlyph.tsx new file mode 100644 index 0000000000..9fb1ae7869 --- /dev/null +++ b/src/ui/components/TabBar/CloseGlyph.tsx @@ -0,0 +1,7 @@ +export const CloseGlyph = () => ( + +); + +export default CloseGlyph; diff --git a/src/ui/components/TabBar/MaximizeGlyph.tsx b/src/ui/components/TabBar/MaximizeGlyph.tsx new file mode 100644 index 0000000000..331643662c --- /dev/null +++ b/src/ui/components/TabBar/MaximizeGlyph.tsx @@ -0,0 +1,14 @@ +export const MaximizeGlyph = () => ( + +); + +export default MaximizeGlyph; diff --git a/src/ui/components/TabBar/MeatballMenuButton.spec.tsx b/src/ui/components/TabBar/MeatballMenuButton.spec.tsx new file mode 100644 index 0000000000..0591c7958f --- /dev/null +++ b/src/ui/components/TabBar/MeatballMenuButton.spec.tsx @@ -0,0 +1,92 @@ +import { APP_MENU_TRIGGERED } from '../../actions'; +import { renderWithStore, screen, userEvent } from '../../test-utils'; +import { MeatballMenuButton } from './MeatballMenuButton'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (action: unknown) => mockDispatch(action), +})); + +describe('MeatballMenuButton', () => { + beforeEach(() => { + mockDispatch.mockClear(); + }); + + it('dispatches APP_MENU_TRIGGERED with numeric coordinates derived from the button rect on click', async () => { + const user = userEvent.setup(); + renderWithStore(); + + const button = screen.getByRole('button', { name: 'tabBar.meatballMenu' }); + + jest.spyOn(button, 'getBoundingClientRect').mockReturnValue({ + left: 12.4, + bottom: 40.6, + top: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); + + await user.click(button); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: APP_MENU_TRIGGERED, + payload: { x: 12, y: 41 }, + }); + }); + + it('exposes menu affordance attributes for accessibility', () => { + renderWithStore(); + + const button = screen.getByRole('button', { name: 'tabBar.meatballMenu' }); + expect(button).toHaveAttribute('aria-haspopup', 'menu'); + }); + + it('opens the menu on a solo Alt key press', () => { + renderWithStore(); + + const button = screen.getByRole('button', { name: 'tabBar.meatballMenu' }); + jest.spyOn(button, 'getBoundingClientRect').mockReturnValue({ + left: 0, + bottom: 32, + top: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt' })); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: APP_MENU_TRIGGERED, + payload: { x: 0, y: 32 }, + }); + }); + + it('does not open the menu when Alt is used as a modifier for another key', () => { + renderWithStore(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt' })); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/TabBar/MeatballMenuButton.tsx b/src/ui/components/TabBar/MeatballMenuButton.tsx new file mode 100644 index 0000000000..f142415b1f --- /dev/null +++ b/src/ui/components/TabBar/MeatballMenuButton.tsx @@ -0,0 +1,67 @@ +import { Icon } from '@rocket.chat/fuselage'; +import type { MouseEvent } from 'react'; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { dispatch } from '../../../store'; +import { APP_MENU_TRIGGERED } from '../../actions'; +import { MeatballButton } from './styles'; + +export const MeatballMenuButton = () => { + const { t } = useTranslation(); + const buttonRef = useRef(null); + + const openMenu = (element: HTMLElement): void => { + const rect = element.getBoundingClientRect(); + dispatch({ + type: APP_MENU_TRIGGERED, + payload: { x: Math.round(rect.left), y: Math.round(rect.bottom) }, + }); + }; + + const handleClick = (event: MouseEvent): void => { + openMenu(event.currentTarget); + }; + + useEffect(() => { + let isSoloAltPress = false; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Alt') { + isSoloAltPress = !event.repeat; + return; + } + isSoloAltPress = false; + }; + + const handleKeyUp = (event: KeyboardEvent): void => { + if (event.key === 'Alt' && isSoloAltPress && buttonRef.current) { + openMenu(buttonRef.current); + } + isSoloAltPress = false; + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, []); + + return ( + + + + ); +}; + +export default MeatballMenuButton; diff --git a/src/ui/components/TabBar/MinimizeGlyph.tsx b/src/ui/components/TabBar/MinimizeGlyph.tsx new file mode 100644 index 0000000000..2a028e213f --- /dev/null +++ b/src/ui/components/TabBar/MinimizeGlyph.tsx @@ -0,0 +1,7 @@ +export const MinimizeGlyph = () => ( + +); + +export default MinimizeGlyph; diff --git a/src/ui/components/TabBar/RestoreGlyph.tsx b/src/ui/components/TabBar/RestoreGlyph.tsx new file mode 100644 index 0000000000..3e271b7e9d --- /dev/null +++ b/src/ui/components/TabBar/RestoreGlyph.tsx @@ -0,0 +1,19 @@ +export const RestoreGlyph = () => ( + +); + +export default RestoreGlyph; diff --git a/src/ui/components/TabBar/WindowControls.spec.tsx b/src/ui/components/TabBar/WindowControls.spec.tsx new file mode 100644 index 0000000000..300b6de710 --- /dev/null +++ b/src/ui/components/TabBar/WindowControls.spec.tsx @@ -0,0 +1,136 @@ +import { + WINDOW_CONTROLS_CLOSE_CLICKED, + WINDOW_CONTROLS_MAXIMIZE_CLICKED, + WINDOW_CONTROLS_MINIMIZE_CLICKED, +} from '../../actions'; +import { renderWithStore, screen, userEvent } from '../../test-utils'; +import { WindowControls } from './WindowControls'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (action: unknown) => mockDispatch(action), +})); + +const buildState = (overrides: Record = {}) => + ({ + rootWindowState: { maximized: false, fullscreen: false }, + ...overrides, + }) as any; + +describe('WindowControls', () => { + beforeEach(() => { + mockDispatch.mockClear(); + }); + + it('dispatches WINDOW_CONTROLS_MINIMIZE_CLICKED when the minimize button is clicked', async () => { + const user = userEvent.setup(); + renderWithStore(, { preloadedState: buildState() }); + + await user.click( + screen.getByRole('button', { + name: 'tabBar.windowControls.minimize', + }) + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: WINDOW_CONTROLS_MINIMIZE_CLICKED, + }); + }); + + it('dispatches WINDOW_CONTROLS_MAXIMIZE_CLICKED when the maximize button is clicked', async () => { + const user = userEvent.setup(); + renderWithStore(, { preloadedState: buildState() }); + + await user.click( + screen.getByRole('button', { + name: 'tabBar.windowControls.maximize', + }) + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: WINDOW_CONTROLS_MAXIMIZE_CLICKED, + }); + }); + + it('dispatches WINDOW_CONTROLS_CLOSE_CLICKED when the close button is clicked', async () => { + const user = userEvent.setup(); + renderWithStore(, { preloadedState: buildState() }); + + await user.click( + screen.getByRole('button', { name: 'tabBar.windowControls.close' }) + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: WINDOW_CONTROLS_CLOSE_CLICKED, + }); + }); + + it('shows the maximize label and glyph when the window is not maximized', () => { + renderWithStore(, { + preloadedState: buildState({ + rootWindowState: { maximized: false, fullscreen: false }, + }), + }); + + expect( + screen.getByRole('button', { name: 'tabBar.windowControls.maximize' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'tabBar.windowControls.restore' }) + ).not.toBeInTheDocument(); + }); + + it('shows the restore label when rootWindowState.maximized is true', () => { + renderWithStore(, { + preloadedState: buildState({ + rootWindowState: { maximized: true, fullscreen: false }, + }), + }); + + expect( + screen.getByRole('button', { name: 'tabBar.windowControls.restore' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'tabBar.windowControls.maximize' }) + ).not.toBeInTheDocument(); + }); + + it('shows the restore label when rootWindowState.fullscreen is true', () => { + renderWithStore(, { + preloadedState: buildState({ + rootWindowState: { maximized: false, fullscreen: true }, + }), + }); + + expect( + screen.getByRole('button', { name: 'tabBar.windowControls.restore' }) + ).toBeInTheDocument(); + }); + + it('dispatches WINDOW_CONTROLS_MAXIMIZE_CLICKED from the restore button when maximized', async () => { + const user = userEvent.setup(); + renderWithStore(, { + preloadedState: buildState({ + rootWindowState: { maximized: true, fullscreen: false }, + }), + }); + + await user.click( + screen.getByRole('button', { name: 'tabBar.windowControls.restore' }) + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: WINDOW_CONTROLS_MAXIMIZE_CLICKED, + }); + }); +}); diff --git a/src/ui/components/TabBar/WindowControls.tsx b/src/ui/components/TabBar/WindowControls.tsx new file mode 100644 index 0000000000..24b7446bd1 --- /dev/null +++ b/src/ui/components/TabBar/WindowControls.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { dispatch } from '../../../store'; +import type { RootState } from '../../../store/rootReducer'; +import { + WINDOW_CONTROLS_CLOSE_CLICKED, + WINDOW_CONTROLS_MAXIMIZE_CLICKED, + WINDOW_CONTROLS_MINIMIZE_CLICKED, +} from '../../actions'; +import { CloseGlyph } from './CloseGlyph'; +import { MaximizeGlyph } from './MaximizeGlyph'; +import { MinimizeGlyph } from './MinimizeGlyph'; +import { RestoreGlyph } from './RestoreGlyph'; +import { WindowControlButton, WindowControlsGroup } from './styles'; + +export const WindowControls = () => { + const { t } = useTranslation(); + + const isMaximized = useSelector( + ({ rootWindowState }: RootState) => + rootWindowState.maximized || rootWindowState.fullscreen + ); + + const handleMinimize = (): void => { + dispatch({ type: WINDOW_CONTROLS_MINIMIZE_CLICKED }); + }; + + const handleMaximize = (): void => { + dispatch({ type: WINDOW_CONTROLS_MAXIMIZE_CLICKED }); + }; + + const handleClose = (): void => { + dispatch({ type: WINDOW_CONTROLS_CLOSE_CLICKED }); + }; + + const maximizeLabel = isMaximized + ? t('tabBar.windowControls.restore') + : t('tabBar.windowControls.maximize'); + + return ( + + + + + + {isMaximized ? : } + + + + + + ); +}; + +export default WindowControls; diff --git a/src/ui/components/TabBar/WindowsTitleBar.tsx b/src/ui/components/TabBar/WindowsTitleBar.tsx new file mode 100644 index 0000000000..f738d8de18 --- /dev/null +++ b/src/ui/components/TabBar/WindowsTitleBar.tsx @@ -0,0 +1,24 @@ +import { useSelector } from 'react-redux'; + +import type { RootState } from '../../../store/rootReducer'; +import { MeatballMenuButton } from './MeatballMenuButton'; +import { WindowControls } from './WindowControls'; +import { TitleBarDragRegion, TitleBarStrip, TitleBarText } from './styles'; + +export const WindowsTitleBar = () => { + const mainWindowTitle = useSelector( + ({ mainWindowTitle }: RootState) => mainWindowTitle + ); + + return ( + + + + {mainWindowTitle} + + + + ); +}; + +export default WindowsTitleBar; diff --git a/src/ui/components/TabBar/WorkspaceTab.tsx b/src/ui/components/TabBar/WorkspaceTab.tsx new file mode 100644 index 0000000000..947ae52f7a --- /dev/null +++ b/src/ui/components/TabBar/WorkspaceTab.tsx @@ -0,0 +1,145 @@ +import { Badge } from '@rocket.chat/fuselage'; +import type { DragEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; +import { useContext, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { dispatch } from '../../../store'; +import { + SIDE_BAR_CONTEXT_MENU_TRIGGERED, + SIDE_BAR_SERVER_SELECTED, +} from '../../actions'; +import { isDarwin } from '../../utils/platform'; +import { TooltipContext } from '../utils/TooltipContext'; +import { getServerPanelId, getServerTabId } from '../utils/getServerDomId'; +import { getServerInitials } from '../utils/getServerInitials'; +import { Favicon, Initials, Label, ShortcutChip, Tab } from './styles'; + +const formatMentionCount = (count: number | undefined): string | undefined => { + if (count === undefined) { + return undefined; + } + + return count > 99 ? '99+' : String(count); +}; + +type WorkspaceTabProps = { + url: string; + title: string; + favicon: string | null; + isSelected: boolean; + badge?: '•' | number; + userLoggedIn?: boolean; + shortcutNumber: string | null; + isShortcutVisible: boolean; + tabIndex: 0 | -1; + onDragStart: (event: DragEvent) => void; + onDragEnd: (event: DragEvent) => void; + onDragEnter: (event: DragEvent) => void; + onDrop: (event: DragEvent) => void; +}; + +const WorkspaceTab = ({ + url, + title, + favicon, + isSelected, + badge, + userLoggedIn, + shortcutNumber, + isShortcutVisible, + tabIndex, + onDragStart, + onDragEnd, + onDragEnter, + onDrop, +}: WorkspaceTabProps) => { + const { t } = useTranslation(); + const tooltip = useContext(TooltipContext); + const ref = useRef(null); + + const initials = useMemo(() => getServerInitials(title, url), [title, url]); + + const mentionCount = + typeof badge === 'number' && badge > 0 ? badge : undefined; + const displayCount = formatMentionCount(mentionCount); + + const shortcutSuffix = + shortcutNumber && Number(shortcutNumber) >= 1 && Number(shortcutNumber) <= 9 + ? ` (${isDarwin ? '⌘' : 'Ctrl+'}${shortcutNumber})` + : ''; + + const getUnreadSuffix = (): string => { + if (mentionCount !== undefined) { + return ` — ${t('tabBar.unreadMessage', { count: mentionCount })}`; + } + + if (badge === '•') { + return ` — ${t('tabBar.unreadMessages')}`; + } + + return ''; + }; + + const unreadSuffix = getUnreadSuffix(); + + const tooltipText = `${title}${unreadSuffix}${shortcutSuffix}`; + + const handleClick = (): void => { + dispatch({ type: SIDE_BAR_SERVER_SELECTED, payload: url }); + }; + + const handleContextMenu = (event: MouseEvent): void => { + event.preventDefault(); + dispatch({ type: SIDE_BAR_CONTEXT_MENU_TRIGGERED, payload: url }); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleClick(); + } + }; + + const handleFocus = (event: FocusEvent): void => { + tooltip.open(<>{tooltipText}, event.currentTarget); + }; + + const handleBlur = (): void => { + tooltip.close(); + }; + + return ( + event.preventDefault()} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + onDragEnter={onDragEnter} + onDrop={onDrop} + > + {initials} + + + {isShortcutVisible && shortcutNumber && ( + {shortcutNumber} + )} + {displayCount && {displayCount}} + {!userLoggedIn && !} + + ); +}; + +export default WorkspaceTab; diff --git a/src/ui/components/TabBar/index.spec.tsx b/src/ui/components/TabBar/index.spec.tsx new file mode 100644 index 0000000000..01bc862e80 --- /dev/null +++ b/src/ui/components/TabBar/index.spec.tsx @@ -0,0 +1,259 @@ +import { act } from '@testing-library/react'; + +import { TabBar } from '.'; +import { + SIDE_BAR_ADD_NEW_SERVER_CLICKED, + SIDE_BAR_CONTEXT_MENU_TRIGGERED, + SIDE_BAR_SERVER_SELECTED, +} from '../../actions'; +import { renderWithStore, screen, userEvent } from '../../test-utils'; + +// The layout hook debounces ResizeObserver updates through a +// requestAnimationFrame. With fake timers installed, flush it right after +// render so `availableWidth` reflects the mocked tablist width before assertions run. +const renderTabBar = ( + ...args: Parameters +): ReturnType => { + const result = renderWithStore(...args); + act(() => { + jest.runOnlyPendingTimers(); + }); + return result; +}; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => + options?.count !== undefined ? `${key}:${options.count}` : key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +const mockDispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (action: unknown) => mockDispatch(action), +})); + +let mockTabListWidth = 1000; + +class MockResizeObserver { + callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { width: mockTabListWidth } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver + ); + } + + unobserve() {} + + disconnect() {} +} + +const buildState = (overrides: Record = {}) => + ({ + servers: [ + { url: 'https://a.rocket.chat/', title: 'Server A' }, + { url: 'https://b.rocket.chat/', title: 'Server B' }, + ], + currentView: { url: 'https://a.rocket.chat/' }, + isAddNewServersEnabled: true, + isTransparentWindowEnabled: false, + rootWindowState: { fullscreen: false }, + ...overrides, + }) as any; + +describe('TabBar', () => { + const originalResizeObserver = (global as any).ResizeObserver; + + beforeAll(() => { + (global as any).ResizeObserver = MockResizeObserver; + }); + + afterAll(() => { + (global as any).ResizeObserver = originalResizeObserver; + }); + + beforeEach(() => { + jest.useFakeTimers(); + mockDispatch.mockClear(); + mockTabListWidth = 1000; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders one tab per server', () => { + renderTabBar(, { preloadedState: buildState() }); + + expect(screen.getByText('Server A')).toBeInTheDocument(); + expect(screen.getByText('Server B')).toBeInTheDocument(); + }); + + it('marks the active server tab as selected', () => { + renderTabBar(, { preloadedState: buildState() }); + + const tabs = screen.getAllByRole('tab'); + const selected = tabs.filter( + (tab) => tab.getAttribute('aria-selected') === 'true' + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent('Server A'); + }); + + it('dispatches SIDE_BAR_SERVER_SELECTED with the url when a tab is clicked', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderTabBar(, { preloadedState: buildState() }); + + await user.click(screen.getByText('Server B')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: SIDE_BAR_SERVER_SELECTED, + payload: 'https://b.rocket.chat/', + }); + }); + + it('dispatches SIDE_BAR_ADD_NEW_SERVER_CLICKED when the add button is clicked', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderTabBar(, { preloadedState: buildState() }); + + await user.click(screen.getByTitle('tabBar.addWorkspace')); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: SIDE_BAR_ADD_NEW_SERVER_CLICKED, + }); + }); + + it('hides the add button when adding servers is disabled', () => { + renderTabBar(, { + preloadedState: buildState({ isAddNewServersEnabled: false }), + }); + + expect(screen.queryByTitle('tabBar.addWorkspace')).not.toBeInTheDocument(); + }); + + it('caps the unread mention badge display at 99+', () => { + renderTabBar(, { + preloadedState: buildState({ + servers: [ + { url: 'https://a.rocket.chat/', title: 'Server A', badge: 150 }, + ], + }), + }); + + expect(screen.getByText('99+')).toBeInTheDocument(); + }); + + it('renders initials as a fallback when there is no favicon', () => { + renderTabBar(, { + preloadedState: buildState({ + servers: [{ url: 'https://a.rocket.chat/', title: 'Server A' }], + }), + }); + + expect(screen.getByText('SA')).toBeInTheDocument(); + }); + + it('dispatches SIDE_BAR_CONTEXT_MENU_TRIGGERED on context menu', () => { + renderTabBar(, { preloadedState: buildState() }); + + const tab = screen.getByText('Server A').closest('[role="tab"]'); + expect(tab).not.toBeNull(); + + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + tab?.dispatchEvent(event); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: SIDE_BAR_CONTEXT_MENU_TRIGGERED, + payload: 'https://a.rocket.chat/', + }); + }); + + it('renders with zero servers without crashing', () => { + renderTabBar(, { + preloadedState: buildState({ + servers: [], + currentView: 'add-new-server', + }), + }); + + expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.queryAllByRole('tab')).toHaveLength(0); + }); + + it('moves focus to the next tab on ArrowRight', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderTabBar(, { preloadedState: buildState() }); + + const tabs = screen.getAllByRole('tab'); + tabs[0].focus(); + expect(tabs[0]).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + + expect(tabs[1]).toHaveFocus(); + }); + + it('falls back tabindex to the first tab when no server is selected', () => { + renderTabBar(, { + preloadedState: buildState({ currentView: 'settings' }), + }); + + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('tabindex', '0'); + expect(tabs[1]).toHaveAttribute('tabindex', '-1'); + }); + + it('shows a warning badge for logged-out servers', () => { + renderTabBar(, { + preloadedState: buildState({ + servers: [ + { + url: 'https://a.rocket.chat/', + title: 'Server A', + userLoggedIn: false, + }, + ], + }), + }); + + expect(screen.getByText('!')).toBeInTheDocument(); + }); + + it('renders more tabs when the add button is disabled', () => { + mockTabListWidth = 130; + + const servers = [ + { url: 'https://a.rocket.chat/', title: 'Server A' }, + { url: 'https://b.rocket.chat/', title: 'Server B' }, + { url: 'https://c.rocket.chat/', title: 'Server C' }, + ]; + + renderTabBar(, { + preloadedState: buildState({ + servers, + isAddNewServersEnabled: false, + }), + }); + + expect(screen.getAllByRole('tab')).toHaveLength(2); + }); +}); diff --git a/src/ui/components/TabBar/index.tsx b/src/ui/components/TabBar/index.tsx new file mode 100644 index 0000000000..ef03294326 --- /dev/null +++ b/src/ui/components/TabBar/index.tsx @@ -0,0 +1,156 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { dispatch } from '../../../store'; +import type { RootState } from '../../../store/rootReducer'; +import { SIDE_BAR_ADD_NEW_SERVER_CLICKED } from '../../actions'; +import { isDarwin } from '../../utils/platform'; +import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; +import { useServers } from '../hooks/useServers'; +import { useSorting } from '../hooks/useSorting'; +import WorkspaceTab from './WorkspaceTab'; +import { + AddButtonWrapper, + DragSpacer, + Strip, + TabList, + TrafficLightSpacer, +} from './styles'; +import { useTabBarLayout } from './useTabBarLayout'; + +type TabBarProps = { + leadingSlot?: ReactNode; + trailingSlot?: ReactNode; +}; + +export const TabBar = ({ leadingSlot, trailingSlot }: TabBarProps) => { + const { t } = useTranslation(); + + const servers = useServers(); + + const isAddNewServersEnabled = useSelector( + ({ isAddNewServersEnabled }: RootState) => isAddNewServersEnabled + ); + + const isTransparentWindowEnabled = useSelector( + ({ isTransparentWindowEnabled }: RootState) => isTransparentWindowEnabled + ); + + const isFullscreen = useSelector( + ({ rootWindowState }: RootState) => rootWindowState.fullscreen + ); + + const isEachShortcutVisible = useKeyboardShortcuts(); + const { + sortedServers, + handleDragStart, + handleDragEnd, + handleDragEnter, + handleDrop, + } = useSorting(servers); + + const activeServer = sortedServers.find((server) => server.selected); + + const { visibleServers, tabListRef } = useTabBarLayout( + sortedServers, + activeServer?.url, + isAddNewServersEnabled + ); + + const hasSelectedServer = visibleServers.some((server) => server.selected); + + const handleAddServerButtonClicked = (): void => { + dispatch({ type: SIDE_BAR_ADD_NEW_SERVER_CLICKED }); + }; + + const handleTabListKeyDown = (event: KeyboardEvent): void => { + const tabs = Array.from( + event.currentTarget.querySelectorAll('[role="tab"]') + ); + + if (tabs.length === 0) { + return; + } + + const currentIndex = tabs.indexOf(document.activeElement as HTMLElement); + + let nextIndex: number | null = null; + + switch (event.key) { + case 'ArrowLeft': + nextIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1; + break; + case 'ArrowRight': + nextIndex = + currentIndex === -1 || currentIndex === tabs.length - 1 + ? 0 + : currentIndex + 1; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = tabs.length - 1; + break; + default: + return; + } + + event.preventDefault(); + tabs[nextIndex]?.focus(); + }; + + return ( + + {leadingSlot} + {isDarwin && } + + {visibleServers.map((server, index) => { + const order = sortedServers.indexOf(server); + const shortcutNumber = + order >= 0 && order <= 8 ? String(order + 1) : null; + + return ( + + ); + })} + {isAddNewServersEnabled && ( + + + + )} + + + {trailingSlot} + + ); +}; diff --git a/src/ui/components/TabBar/styles.tsx b/src/ui/components/TabBar/styles.tsx new file mode 100644 index 0000000000..6d7dc6c79c --- /dev/null +++ b/src/ui/components/TabBar/styles.tsx @@ -0,0 +1,274 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +type StripProps = { + isTransparentWindowEnabled: boolean; +}; + +export const Strip = styled.div` + display: flex; + flex-direction: row; + align-items: stretch; + flex: 0 0 auto; + width: 100%; + height: 44px; + -webkit-app-region: drag; + user-select: none; + background-color: ${({ isTransparentWindowEnabled }) => + isTransparentWindowEnabled + ? 'transparent' + : 'var(--rcx-color-surface-tint, #ffffff)'}; +`; + +type TrafficLightSpacerProps = { + collapsed: boolean; +}; + +export const TrafficLightSpacer = styled.div` + flex: 0 0 auto; + width: ${({ collapsed }) => (collapsed ? '0px' : '78px')}; + -webkit-app-region: drag; + transition: width var(--transitions-duration, 100ms); +`; + +export const TabList = styled.div` + display: flex; + flex-direction: row; + align-items: flex-end; + flex: 1 1 auto; + min-width: 0; + gap: 8px; + padding-left: 10px; + overflow: hidden; + -webkit-app-region: drag; +`; + +export const DragSpacer = styled.div` + flex: 0 0 44px; + -webkit-app-region: drag; +`; + +export const AddButtonWrapper = styled.div` + display: flex; + align-items: center; + align-self: stretch; + flex: 0 0 auto; + -webkit-app-region: no-drag; +`; + +type TabProps = { + isSelected: boolean; +}; + +export const Tab = styled.button` + appearance: none; + border: none; + outline: none; + background: transparent; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + flex: 1 1 180px; + min-width: 52px; + max-width: 180px; + height: 34px; + align-self: flex-end; + position: relative; + container-type: inline-size; + padding: 0 10px; + cursor: pointer; + -webkit-app-region: no-drag; + color: var(--rcx-color-font-default, #1f2329); + border-radius: 10px 10px 0 0; + + ${({ isSelected }) => + isSelected + ? css` + background-color: var(--rcx-color-surface-sidebar, #2f343d); + z-index: 1; + + /* Chrome-style concave fillets where the tab meets the strip */ + &::before, + &::after { + content: ''; + position: absolute; + bottom: 0; + width: 10px; + height: 10px; + pointer-events: none; + } + + &::before { + left: -10px; + background: radial-gradient( + circle at 0 0, + transparent 10px, + var(--rcx-color-surface-sidebar, #2f343d) 10.5px + ); + } + + &::after { + right: -10px; + background: radial-gradient( + circle at 100% 0, + transparent 10px, + var(--rcx-color-surface-sidebar, #2f343d) 10.5px + ); + } + ` + : css` + &:hover { + background-color: var(--rcx-color-surface-neutral, #2d3039); + } + `} + + &:focus-visible { + box-shadow: inset 0 0 0 2px var(--rcx-color-stroke-highlight, #1d74f5); + } +`; + +type FaviconProps = { + visible: boolean; +}; + +export const Favicon = styled.img` + flex: 0 0 auto; + width: 22px; + height: 22px; + border-radius: 4px; + object-fit: contain; + display: ${({ visible }) => (visible ? 'initial' : 'none')}; +`; + +type InitialsProps = { + visible: boolean; +}; + +export const Initials = styled.span` + flex: 0 0 auto; + width: 22px; + height: 22px; + line-height: 22px; + text-align: center; + font-size: 11px; + border-radius: 4px; + background-color: var(--rcx-color-surface-neutral, #e4e7ea); + display: ${({ visible }) => (visible ? 'initial' : 'none')}; +`; + +export const Label = styled.span` + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 500; + + @container (max-width: 75px) { + display: none; + } +`; + +export const ShortcutChip = styled.span` + flex: 0 0 auto; + font-size: 11px; + opacity: 0.7; + + @container (max-width: 75px) { + display: none; + } +`; + +export const MeatballButton = styled.button` + appearance: none; + border: none; + outline: none; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 40px; + height: 100%; + cursor: pointer; + -webkit-app-region: no-drag; + color: var(--rcx-color-font-default, #1f2329); + + &:hover { + background-color: var(--rcx-color-surface-neutral, #e4e7ea); + } + + &:focus-visible { + box-shadow: inset 0 0 0 2px var(--rcx-color-stroke-highlight, #1d74f5); + } +`; + +export const WindowControlsGroup = styled.div` + display: flex; + flex-direction: row; + align-items: stretch; + flex: 0 0 auto; + height: 100%; + -webkit-app-region: no-drag; +`; + +type WindowControlButtonProps = { + isCloseButton?: boolean; +}; + +export const WindowControlButton = styled.button` + appearance: none; + border: none; + outline: none; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 46px; + height: 100%; + cursor: pointer; + -webkit-app-region: no-drag; + color: var(--rcx-color-font-default, #1f2329); + + &:hover { + background-color: ${({ isCloseButton }) => + isCloseButton ? '#c42b1c' : 'var(--rcx-color-surface-neutral, #e4e7ea)'}; + color: ${({ isCloseButton }) => (isCloseButton ? '#ffffff' : 'inherit')}; + } + + &:focus-visible { + box-shadow: inset 0 0 0 2px var(--rcx-color-stroke-highlight, #1d74f5); + } +`; + +export const TitleBarStrip = styled.div` + display: flex; + flex-direction: row; + align-items: stretch; + flex: 0 0 auto; + width: 100%; + height: 32px; + -webkit-app-region: drag; + user-select: none; + background-color: var(--rcx-color-surface-tint, #ffffff); +`; + +export const TitleBarDragRegion = styled.div` + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + -webkit-app-region: drag; + overflow: hidden; +`; + +export const TitleBarText = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +`; diff --git a/src/ui/components/TabBar/useTabBarLayout.spec.ts b/src/ui/components/TabBar/useTabBarLayout.spec.ts new file mode 100644 index 0000000000..3f49549e4a --- /dev/null +++ b/src/ui/components/TabBar/useTabBarLayout.spec.ts @@ -0,0 +1,80 @@ +import type { Server } from '../../../servers/common'; +import { computeVisibleServers } from './useTabBarLayout'; + +const buildServer = (url: string): Server => ({ url, title: url }); + +describe('computeVisibleServers', () => { + it('returns all servers when they all fit in the available width', () => { + const servers = [buildServer('a'), buildServer('b'), buildServer('c')]; + + expect(computeVisibleServers(300, servers, undefined)).toEqual(servers); + }); + + it('slices to k servers based on available width', () => { + const servers = [ + buildServer('a'), + buildServer('b'), + buildServer('c'), + buildServer('d'), + ]; + + // 130 / 52 = 2.5 -> floor 2 + const result = computeVisibleServers(130, servers, undefined); + + expect(result).toEqual([buildServer('a'), buildServer('b')]); + }); + + it('swaps the active server into the last visible slot when it would be cut off', () => { + const servers = [ + buildServer('a'), + buildServer('b'), + buildServer('c'), + buildServer('d'), + ]; + + const result = computeVisibleServers(130, servers, 'd'); + + expect(result).toEqual([buildServer('a'), buildServer('d')]); + }); + + it('does not modify the slice when the active server is already visible', () => { + const servers = [buildServer('a'), buildServer('b'), buildServer('c')]; + + const result = computeVisibleServers(130, servers, 'a'); + + expect(result).toEqual([buildServer('a'), buildServer('b')]); + }); + + it('floors k at 1 even when available width is smaller than a single tab', () => { + const servers = [buildServer('a'), buildServer('b')]; + + const result = computeVisibleServers(10, servers, undefined); + + expect(result).toEqual([buildServer('a')]); + }); + + it('renders all servers when width is not yet measured (zero or negative)', () => { + const servers = [buildServer('a'), buildServer('b')]; + + expect(computeVisibleServers(0, servers, undefined)).toEqual(servers); + expect(computeVisibleServers(-50, servers, undefined)).toEqual(servers); + }); + + it('fits k tabs exactly when the available width covers k tabs and k-1 gaps', () => { + const servers = [ + buildServer('a'), + buildServer('b'), + buildServer('c'), + buildServer('d'), + ]; + + // 3 tabs (52) + 2 gaps (8) = 172, no room for a trailing gap. + const result = computeVisibleServers(172, servers, undefined); + + expect(result).toEqual([ + buildServer('a'), + buildServer('b'), + buildServer('c'), + ]); + }); +}); diff --git a/src/ui/components/TabBar/useTabBarLayout.ts b/src/ui/components/TabBar/useTabBarLayout.ts new file mode 100644 index 0000000000..6713eb3ff8 --- /dev/null +++ b/src/ui/components/TabBar/useTabBarLayout.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { Server } from '../../../servers/common'; + +const TAB_MIN_WIDTH = 52; +const TAB_GAP = 8; +const ADD_BUTTON_WIDTH = 36; + +export const computeVisibleServers = ( + availableWidth: number, + servers: S[], + activeUrl: string | undefined +): S[] => { + // Width 0 means "not measured yet" (ResizeObserver hasn't fired) — + // render everything rather than collapsing the strip to one tab. + if (availableWidth <= 0) { + return servers; + } + + const k = Math.max( + 1, + Math.floor((availableWidth + TAB_GAP) / (TAB_MIN_WIDTH + TAB_GAP)) + ); + + if (servers.length <= k) { + return servers; + } + + const visible = servers.slice(0, k); + + if ( + activeUrl !== undefined && + !visible.some((server) => server.url === activeUrl) + ) { + const activeServer = servers.find((server) => server.url === activeUrl); + if (activeServer) { + visible[visible.length - 1] = activeServer; + } + } + + return visible; +}; + +export const useTabBarLayout = ( + servers: S[], + activeUrl: string | undefined, + hasAddButton: boolean +): { + visibleServers: S[]; + tabListRef: (node: HTMLElement | null) => void; +} => { + const [availableWidth, setAvailableWidth] = useState(0); + const elementRef = useRef(null); + const rafRef = useRef(null); + const observerRef = useRef(null); + + const tabListRef = (node: HTMLElement | null): void => { + if (observerRef.current && elementRef.current) { + observerRef.current.unobserve(elementRef.current); + } + + elementRef.current = node; + + if (node && observerRef.current) { + observerRef.current.observe(node); + } + }; + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + + rafRef.current = requestAnimationFrame(() => { + const width = + entry.contentRect.width - (hasAddButton ? ADD_BUTTON_WIDTH : 0); + setAvailableWidth(Math.max(0, width)); + }); + }); + + observerRef.current = observer; + + if (elementRef.current) { + observer.observe(elementRef.current); + } + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + observer.disconnect(); + observerRef.current = null; + }; + }, [hasAddButton]); + + const visibleServers = computeVisibleServers( + availableWidth, + servers, + activeUrl + ); + + return { visibleServers, tabListRef }; +}; diff --git a/src/ui/components/SideBar/useKeyboardShortcuts.tsx b/src/ui/components/hooks/useKeyboardShortcuts.tsx similarity index 100% rename from src/ui/components/SideBar/useKeyboardShortcuts.tsx rename to src/ui/components/hooks/useKeyboardShortcuts.tsx diff --git a/src/ui/components/SideBar/useSorting.tsx b/src/ui/components/hooks/useSorting.tsx similarity index 100% rename from src/ui/components/SideBar/useSorting.tsx rename to src/ui/components/hooks/useSorting.tsx diff --git a/src/ui/components/utils/getServerDomId.ts b/src/ui/components/utils/getServerDomId.ts new file mode 100644 index 0000000000..faa6eae903 --- /dev/null +++ b/src/ui/components/utils/getServerDomId.ts @@ -0,0 +1,16 @@ +const hash = (url: string): string => { + let h = 0; + for (let i = 0; i < url.length; i += 1) { + h = (Math.imul(31, h) + url.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); +}; + +const sanitize = (url: string): string => + `${url.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}-${hash(url)}`; + +export const getServerTabId = (url: string): string => + `workspace-tab-${sanitize(url)}`; + +export const getServerPanelId = (url: string): string => + `workspace-panel-${sanitize(url)}`; diff --git a/src/ui/components/utils/getServerInitials.ts b/src/ui/components/utils/getServerInitials.ts new file mode 100644 index 0000000000..1b78f7d5e5 --- /dev/null +++ b/src/ui/components/utils/getServerInitials.ts @@ -0,0 +1,10 @@ +export const getServerInitials = ( + title: string | undefined, + url: string +): string | undefined => + title + ?.replace(url, new URL(url).hostname ?? '') + ?.split(/[^A-Za-z0-9]+/g) + ?.slice(0, 2) + ?.map((text) => text.slice(0, 1).toUpperCase()) + ?.join(''); diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index 82f1f491a2..40f10908d7 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -9,17 +9,19 @@ import { relaunchApp } from '../../app/main/app'; import { CERTIFICATES_CLEARED } from '../../navigation/actions'; import { dispatch, select, Service } from '../../store'; import type { RootState } from '../../store/rootReducer'; +import { UPDATES_CHECK_FOR_UPDATES_REQUESTED } from '../../updates/actions'; import * as urls from '../../urls'; import { openExternal } from '../../utils/browserLauncher'; import { openVideoCallWebviewDevTools } from '../../videoCallWindow/ipc'; import { + APP_MENU_TRIGGERED, CLEAR_CACHE_TRIGGERED, MENU_BAR_ABOUT_CLICKED, MENU_BAR_ADD_NEW_SERVER_CLICKED, MENU_BAR_SELECT_SERVER_CLICKED, + MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, MENU_BAR_TOGGLE_IS_MENU_BAR_ENABLED_CLICKED, MENU_BAR_TOGGLE_IS_SHOW_WINDOW_ON_UNREAD_CHANGED_ENABLED_CLICKED, - MENU_BAR_TOGGLE_IS_SIDE_BAR_ENABLED_CLICKED, MENU_BAR_TOGGLE_IS_TRAY_ICON_ENABLED_CLICKED, MENU_BAR_TOGGLE_IS_DEVELOPER_MODE_ENABLED_CLICKED, MENU_BAR_TOGGLE_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CLICKED, @@ -43,26 +45,91 @@ const selectAddServersDeps = createStructuredSelector({ isAddNewServersEnabled, }); -const createAppMenu = createSelector( +const createAboutMenuItem = (): MenuItemConstructorOptions => ({ + id: 'about', + label: t('menus.about', { appName: app.name }), + click: async () => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + browserWindow.focus(); + dispatch({ type: MENU_BAR_ABOUT_CLICKED }); + }, +}); + +const createAddNewServerMenuItem = (): MenuItemConstructorOptions => ({ + id: 'addNewServer', + label: t('menus.addNewServer'), + accelerator: 'CommandOrControl+N', + click: async () => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + browserWindow.focus(); + dispatch({ type: MENU_BAR_ADD_NEW_SERVER_CLICKED }); + }, +}); + +const createDisableGpuMenuItem = (): MenuItemConstructorOptions => ({ + id: 'disableGpu', + label: t('menus.disableGpu'), + enabled: !app.commandLine.hasSwitch('disable-gpu'), + click: () => { + relaunchApp('--disable-gpu'); + }, +}); + +const createQuitMenuItem = (): MenuItemConstructorOptions => ({ + id: 'quit', + label: t('menus.quit', { appName: app.name }), + accelerator: 'CommandOrControl+Q', + click: () => { + app.quit(); + }, +}); + +const selectAdjacentServer = async ( + servers: RootState['servers'], + currentView: RootState['currentView'], + direction: 1 | -1 +): Promise => { + if (servers.length === 0) { + return; + } + + const currentIndex = + typeof currentView === 'object' + ? servers.findIndex((server) => server.url === currentView.url) + : -1; + const nextIndex = + currentIndex === -1 + ? 0 + : (currentIndex + direction + servers.length) % servers.length; + + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + browserWindow.focus(); + dispatch({ + type: MENU_BAR_SELECT_SERVER_CLICKED, + payload: servers[nextIndex].url, + }); +}; + +export const createAppMenu = createSelector( selectAddServersDeps, ({ isAddNewServersEnabled }): MenuItemConstructorOptions => ({ id: 'appMenu', label: process.platform === 'darwin' ? app.name : t('menus.fileMenu'), submenu: [ ...on(process.platform === 'darwin', () => [ - { - id: 'about', - label: t('menus.about', { appName: app.name }), - click: async () => { - const browserWindow = await getRootWindow(); - - if (!browserWindow.isVisible()) { - browserWindow.showInactive(); - } - browserWindow.focus(); - dispatch({ type: MENU_BAR_ABOUT_CLICKED }); - }, - }, + createAboutMenuItem(), { type: 'separator' }, { id: 'services', @@ -88,44 +155,17 @@ const createAppMenu = createSelector( { type: 'separator' }, ]), ...on(process.platform !== 'darwin' && isAddNewServersEnabled, () => [ - { - id: 'addNewServer', - label: t('menus.addNewServer'), - accelerator: 'CommandOrControl+N', - click: async () => { - const browserWindow = await getRootWindow(); - - if (!browserWindow.isVisible()) { - browserWindow.showInactive(); - } - browserWindow.focus(); - dispatch({ type: MENU_BAR_ADD_NEW_SERVER_CLICKED }); - }, - }, + createAddNewServerMenuItem(), { type: 'separator' }, ]), - { - id: 'disableGpu', - label: t('menus.disableGpu'), - enabled: !app.commandLine.hasSwitch('disable-gpu'), - click: () => { - relaunchApp('--disable-gpu'); - }, - }, + createDisableGpuMenuItem(), { type: 'separator' }, - { - id: 'quit', - label: t('menus.quit', { appName: app.name }), - accelerator: 'CommandOrControl+Q', - click: () => { - app.quit(); - }, - }, + createQuitMenuItem(), ], }) ); -const createEditMenu = createSelector( +export const createEditMenu = createSelector( (_: RootState) => undefined, (): MenuItemConstructorOptions => ({ id: 'editMenu', @@ -168,10 +208,10 @@ const createEditMenu = createSelector( const selectViewDeps = createStructuredSelector({ currentView: ({ currentView }: RootState) => currentView, - isSideBarEnabled: ({ isSideBarEnabled }: RootState) => isSideBarEnabled, isTrayIconEnabled: ({ isTrayIconEnabled }: RootState) => isTrayIconEnabled, isMenuBarEnabled: ({ isMenuBarEnabled }: RootState) => isMenuBarEnabled, rootWindowState: ({ rootWindowState }: RootState) => rootWindowState, + navigationLayout: ({ navigationLayout }: RootState) => navigationLayout, }); const getCurrentView = async () => { @@ -193,14 +233,14 @@ const getCurrentViewWebcontents = async () => { return getWebContentsByServerUrl(url); }; -const createViewMenu = createSelector( +export const createViewMenu = createSelector( selectViewDeps, ({ currentView, - isSideBarEnabled, isTrayIconEnabled, isMenuBarEnabled, rootWindowState, + navigationLayout, }): MenuItemConstructorOptions => ({ id: 'viewMenu', label: t('menus.viewMenu'), @@ -322,13 +362,13 @@ const createViewMenu = createSelector( }, }, ]), - ...on(process.platform !== 'darwin', () => [ + ...on(process.platform === 'linux', () => [ { id: 'showMenuBar', label: t('menus.showMenuBar'), type: 'checkbox', checked: isMenuBarEnabled, - enabled: !isMenuBarEnabled || isSideBarEnabled, + enabled: !isMenuBarEnabled || navigationLayout === 'sidebar', accelerator: process.platform === 'darwin' ? 'Shift+Command+M' : 'Ctrl+Shift+M', click: async ({ checked }) => { @@ -346,14 +386,35 @@ const createViewMenu = createSelector( }, ]), { - id: 'showServerList', - label: t('menus.showServerList'), - type: 'checkbox', - checked: isSideBarEnabled, - enabled: !isSideBarEnabled || isMenuBarEnabled, + id: 'workspaceTabs', + label: t('menus.workspaceTabs'), + type: 'radio', + checked: navigationLayout === 'tabs', + enabled: + process.platform !== 'linux' || + isMenuBarEnabled || + navigationLayout === 'tabs', + click: async () => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + browserWindow.focus(); + dispatch({ + type: MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + payload: 'tabs', + }); + }, + }, + { + id: 'workspaceBar', + label: t('menus.workspaceBar'), + type: 'radio', + checked: navigationLayout === 'sidebar', accelerator: process.platform === 'darwin' ? 'Shift+Command+S' : 'Ctrl+Shift+S', - click: async ({ checked }) => { + click: async () => { const browserWindow = await getRootWindow(); if (!browserWindow.isVisible()) { @@ -361,8 +422,8 @@ const createViewMenu = createSelector( } browserWindow.focus(); dispatch({ - type: MENU_BAR_TOGGLE_IS_SIDE_BAR_ENABLED_CLICKED, - payload: checked, + type: MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + payload: 'sidebar', }); }, }, @@ -424,7 +485,7 @@ const selectWindowDeps = createStructuredSelector({ isAddNewServersEnabled, }); -const createWindowMenu = createSelector( +export const createWindowMenu = createSelector( selectWindowDeps, ({ servers, @@ -481,6 +542,24 @@ const createWindowMenu = createSelector( }, }) ), + { + id: 'nextWorkspace', + label: t('menus.nextWorkspace'), + visible: false, + accelerator: 'CommandOrControl+Tab', + click: async () => { + await selectAdjacentServer(servers, currentView, 1); + }, + }, + { + id: 'previousWorkspace', + label: t('menus.previousWorkspace'), + visible: false, + accelerator: 'CommandOrControl+Shift+Tab', + click: async () => { + await selectAdjacentServer(servers, currentView, -1); + }, + }, { type: 'separator' }, ]), { @@ -502,6 +581,7 @@ const createWindowMenu = createSelector( id: 'settings', label: t('menus.settings'), checked: currentView === 'settings', + accelerator: 'CommandOrControl+,', click: async () => { const browserWindow = await getRootWindow(); @@ -555,7 +635,7 @@ const selectHelpDeps = createStructuredSelector({ }: RootState) => isVideoCallDevtoolsAutoOpenEnabled, }); -const createHelpMenu = createSelector( +export const createHelpMenu = createSelector( selectHelpDeps, ({ isDeveloperModeEnabled, @@ -787,6 +867,87 @@ const selectMenuBarTemplateAsJson = createSelector( (template: unknown) => JSON.stringify(template) ); +const createRocketChatMenu = createSelector( + (_: RootState) => undefined, + (): MenuItemConstructorOptions => ({ + id: 'rocketChatMenu', + label: app.name, + submenu: [ + createAboutMenuItem(), + { type: 'separator' }, + createDisableGpuMenuItem(), + ], + }) +); + +const createFileMenu = createSelector( + selectAddServersDeps, + ({ isAddNewServersEnabled }): MenuItemConstructorOptions => ({ + id: 'fileMenu', + label: t('menus.fileMenu'), + submenu: [ + ...on(isAddNewServersEnabled, () => [createAddNewServerMenuItem()]), + ], + }) +); + +export const selectAppMenuPopupTemplate = createSelector( + [ + createRocketChatMenu, + createFileMenu, + createEditMenu, + createViewMenu, + createWindowMenu, + createHelpMenu, + ], + ( + rocketChatMenu, + fileMenu, + editMenu, + viewMenu, + windowMenu, + helpMenu + ): MenuItemConstructorOptions[] => [ + rocketChatMenu, + fileMenu, + editMenu, + viewMenu, + { + ...windowMenu, + submenu: (windowMenu.submenu as MenuItemConstructorOptions[]).filter( + (item) => item.id !== 'settings' && item.id !== 'downloads' + ), + }, + helpMenu, + { type: 'separator' }, + { + id: 'settings', + label: t('menus.settings'), + accelerator: 'CommandOrControl+,', + click: () => { + dispatch({ type: SIDE_BAR_SETTINGS_BUTTON_CLICKED }); + }, + }, + { + id: 'downloads', + label: t('menus.downloads'), + accelerator: 'CommandOrControl+D', + click: () => { + dispatch({ type: SIDE_BAR_DOWNLOADS_BUTTON_CLICKED }); + }, + }, + { + id: 'checkForUpdates', + label: t('menus.checkForUpdates'), + click: () => { + dispatch({ type: UPDATES_CHECK_FOR_UPDATES_REQUESTED }); + }, + }, + { type: 'separator' }, + createQuitMenuItem(), + ] +); + class MenuBarService extends Service { protected initialize(): void { this.watch(selectMenuBarTemplateAsJson, async () => { @@ -798,8 +959,27 @@ class MenuBarService extends Service { return; } + const browserWindow = await getRootWindow(); + + if (process.platform === 'win32') { + Menu.setApplicationMenu(null); + browserWindow.setMenu(menu); + browserWindow.setMenuBarVisibility(false); + browserWindow.autoHideMenuBar = false; + return; + } + Menu.setApplicationMenu(null); - (await getRootWindow()).setMenu(menu); + browserWindow.setMenu(menu); + }); + + this.listen(APP_MENU_TRIGGERED, async (action) => { + const menu = Menu.buildFromTemplate(select(selectAppMenuPopupTemplate)); + menu.popup({ + window: await getRootWindow(), + x: Math.round(action.payload.x), + y: Math.round(action.payload.y), + }); }); } } diff --git a/src/ui/main/rootWindow.ts b/src/ui/main/rootWindow.ts index c718ce78be..7b3d779184 100644 --- a/src/ui/main/rootWindow.ts +++ b/src/ui/main/rootWindow.ts @@ -25,7 +25,13 @@ import { setupRootWindowReload } from '../../app/main/dev'; import { getPersistedValues } from '../../app/main/persistence'; import { select, watch, listen, dispatchLocal, dispatch } from '../../store'; import type { RootState } from '../../store/rootReducer'; -import { ROOT_WINDOW_STATE_CHANGED, WEBVIEW_FOCUS_REQUESTED } from '../actions'; +import { + ROOT_WINDOW_STATE_CHANGED, + WEBVIEW_FOCUS_REQUESTED, + WINDOW_CONTROLS_CLOSE_CLICKED, + WINDOW_CONTROLS_MAXIMIZE_CLICKED, + WINDOW_CONTROLS_MINIMIZE_CLICKED, +} from '../actions'; import type { WindowState } from '../common'; import { selectGlobalBadge, selectGlobalBadgeCount } from '../selectors'; import { debounce } from './debounce'; @@ -73,7 +79,9 @@ export const getRootWindow = (): Promise => }); const platformTitleBarStyle = - process.platform === 'darwin' ? 'hidden' : 'default'; + process.platform === 'darwin' || process.platform === 'win32' + ? 'hidden' + : 'default'; const isMac = process.platform === 'darwin'; const getEnableVibrancy = (): boolean => { @@ -316,6 +324,43 @@ export const setupRootWindow = (): void => { rootWindow.show(); }, 'Webview focus request'); }), + listen(WINDOW_CONTROLS_MINIMIZE_CLICKED, async () => { + await safeWindowOperation((browserWindow) => { + browserWindow.minimize(); + }, 'Window controls minimize'); + }), + listen(WINDOW_CONTROLS_MAXIMIZE_CLICKED, async () => { + await safeWindowOperation((browserWindow) => { + if (browserWindow.isFullScreen()) { + browserWindow.setFullScreen(false); + return; + } + if (browserWindow.isMaximized()) { + browserWindow.unmaximize(); + return; + } + browserWindow.maximize(); + }, 'Window controls maximize'); + }), + listen(WINDOW_CONTROLS_CLOSE_CLICKED, async () => { + await safeWindowOperation((browserWindow) => { + browserWindow.close(); + }, 'Window controls close'); + }), + ...(process.platform === 'darwin' + ? [ + watch( + ({ navigationLayout }) => navigationLayout, + async (navigationLayout) => { + await safeWindowOperation((browserWindow) => { + browserWindow.setWindowButtonPosition( + navigationLayout === 'tabs' ? { x: 16, y: 16 } : null + ); + }, 'Window button position update'); + } + ), + ] + : []), ]; const fetchAndDispatchWindowState = debounce(async (): Promise => { @@ -346,6 +391,25 @@ export const setupRootWindow = (): void => { fetchAndDispatchWindowState(); + const dispatchWindowStateImmediately = async (): Promise => { + try { + const state = await fetchRootWindowState(); + dispatch({ + type: ROOT_WINDOW_STATE_CHANGED, + payload: state, + }); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to fetch window state:', error); + } + } + }; + + rootWindow.addListener('maximize', dispatchWindowStateImmediately); + rootWindow.addListener('unmaximize', dispatchWindowStateImmediately); + rootWindow.addListener('enter-full-screen', dispatchWindowStateImmediately); + rootWindow.addListener('leave-full-screen', dispatchWindowStateImmediately); + rootWindow.addListener('focus', async () => { rootWindow.flashFrame(false); }); @@ -510,15 +574,19 @@ export const setupRootWindow = (): void => { } }, 'Window icon update'); }), - watch( - ({ isMenuBarEnabled }) => isMenuBarEnabled, - async (isMenuBarEnabled) => { - await safeWindowOperation((browserWindow) => { - browserWindow.autoHideMenuBar = !isMenuBarEnabled; - browserWindow.setMenuBarVisibility(isMenuBarEnabled); - }, 'Menu bar visibility update'); - } - ) + ...(process.platform === 'linux' + ? [ + watch( + ({ isMenuBarEnabled }) => isMenuBarEnabled, + async (isMenuBarEnabled) => { + await safeWindowOperation((browserWindow) => { + browserWindow.autoHideMenuBar = !isMenuBarEnabled; + browserWindow.setMenuBarVisibility(isMenuBarEnabled); + }, 'Menu bar visibility update'); + } + ), + ] + : []) ); } diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index adabe831e1..ba0ecac3f7 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -28,6 +28,7 @@ import { dispatch, listen, select } from '../../../store'; import { openExternal } from '../../../utils/browserLauncher'; import { LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED, + SIDE_BAR_ADD_NEW_SERVER_CLICKED, SIDE_BAR_CONTEXT_MENU_TRIGGERED, SIDE_BAR_REMOVE_SERVER_CLICKED, WEBVIEW_READY, @@ -555,6 +556,9 @@ export const attachGuestWebContentsEvents = async (): Promise => { listen(SIDE_BAR_CONTEXT_MENU_TRIGGERED, (action) => { const { payload: serverUrl } = action; + const isAddNewServersEnabled = select( + ({ isAddNewServersEnabled }) => isAddNewServersEnabled + ); const menuTemplate: MenuItemConstructorOptions[] = [ { @@ -613,6 +617,17 @@ export const attachGuestWebContentsEvents = async (): Promise => { }); }, }, + ...(isAddNewServersEnabled + ? [ + { type: 'separator' as const }, + { + label: t('sidebar.item.addWorkspace'), + click: () => { + dispatch({ type: SIDE_BAR_ADD_NEW_SERVER_CLICKED }); + }, + }, + ] + : []), ]; const menu = Menu.buildFromTemplate(menuTemplate); menu.popup({ diff --git a/src/ui/preload/sidebar.ts b/src/ui/preload/sidebar.ts index 666d89a95f..d93f4fa5f2 100644 --- a/src/ui/preload/sidebar.ts +++ b/src/ui/preload/sidebar.ts @@ -1,10 +1,10 @@ import { watch } from '../../store'; import type { RootState } from '../../store/rootReducer'; -const selectIsSideBarVisible = ({ +const selectIsTrafficLightsCovered = ({ servers, - isSideBarEnabled, -}: RootState): boolean => servers.length > 0 && isSideBarEnabled; + navigationLayout, +}: RootState): boolean => navigationLayout === 'tabs' || servers.length > 0; export const handleTrafficLightsSpacing = (): void => { if (process.platform !== 'darwin') { @@ -17,10 +17,10 @@ export const handleTrafficLightsSpacing = (): void => { style.id = 'sidebar-padding'; document.head.append(style); - watch(selectIsSideBarVisible, (isSideBarVisible) => { + watch(selectIsTrafficLightsCovered, (isTrafficLightsCovered) => { style.innerHTML = ` .sidebar { - padding-top: ${isSideBarVisible ? 0 : '10px'} !important; + padding-top: ${isTrafficLightsCovered ? 0 : '10px'} !important; transition: padding-top 230ms ease-in-out !important; } `; diff --git a/src/ui/reducers/currentView.ts b/src/ui/reducers/currentView.ts index dd9f0f1ab2..3b20f95773 100644 --- a/src/ui/reducers/currentView.ts +++ b/src/ui/reducers/currentView.ts @@ -7,7 +7,6 @@ import { SERVERS_LOADED } from '../../servers/actions'; import type { ActionOf } from '../../store/actions'; import type { SIDE_BAR_SERVER_REMOVE } from '../actions'; import { - DOWNLOADS_BACK_BUTTON_CLICKED, ADD_SERVER_VIEW_SERVER_ADDED, MENU_BAR_ADD_NEW_SERVER_CLICKED, MENU_BAR_SELECT_SERVER_CLICKED, @@ -35,7 +34,6 @@ type CurrentViewAction = | ActionOf | ActionOf | ActionOf - | ActionOf | ActionOf; type CurrentViewState = @@ -93,9 +91,6 @@ export const currentView = ( case SIDE_BAR_SETTINGS_BUTTON_CLICKED: return 'settings'; - case DOWNLOADS_BACK_BUTTON_CLICKED: - return { url: action.payload }; - default: return state; } diff --git a/src/ui/reducers/navigationLayout.spec.ts b/src/ui/reducers/navigationLayout.spec.ts new file mode 100644 index 0000000000..43a6ebc265 --- /dev/null +++ b/src/ui/reducers/navigationLayout.spec.ts @@ -0,0 +1,103 @@ +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { + MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, +} from '../actions'; +import { navigationLayout } from './navigationLayout'; + +describe('navigationLayout reducer', () => { + it('should return initial state as tabs', () => { + expect(navigationLayout(undefined, { type: 'UNKNOWN_ACTION' } as any)).toBe( + 'tabs' + ); + }); + + describe('SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED', () => { + it('should set navigation layout to sidebar', () => { + const action: ActionOf = { + type: SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, + payload: 'sidebar', + }; + + expect(navigationLayout('tabs', action)).toBe('sidebar'); + }); + + it('should set navigation layout to tabs', () => { + const action: ActionOf = { + type: SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, + payload: 'tabs', + }; + + expect(navigationLayout('sidebar', action)).toBe('tabs'); + }); + }); + + describe('MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED', () => { + it('should set navigation layout to sidebar', () => { + const action: ActionOf = { + type: MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + payload: 'sidebar', + }; + + expect(navigationLayout('tabs', action)).toBe('sidebar'); + }); + + it('should set navigation layout to tabs', () => { + const action: ActionOf = { + type: MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + payload: 'tabs', + }; + + expect(navigationLayout('sidebar', action)).toBe('tabs'); + }); + }); + + describe('APP_SETTINGS_LOADED', () => { + it('should load navigation layout from settings', () => { + const action: ActionOf = { + type: APP_SETTINGS_LOADED, + payload: { + navigationLayout: 'sidebar', + }, + }; + + expect(navigationLayout('tabs', action)).toBe('sidebar'); + }); + + it('should use default state when navigationLayout not in payload', () => { + const action: ActionOf = { + type: APP_SETTINGS_LOADED, + payload: {}, + }; + + expect(navigationLayout('tabs', action)).toBe('tabs'); + }); + }); + + describe('state persistence', () => { + it('should maintain immutability', () => { + const initialState = 'tabs'; + const action: ActionOf = { + type: SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, + payload: 'sidebar', + }; + + const newState = navigationLayout(initialState, action); + + expect(newState).toBe('sidebar'); + expect(initialState).toBe('tabs'); + }); + + it('should return same reference when state does not change', () => { + const initialState = 'tabs'; + const action = { + type: 'UNKNOWN_ACTION', + }; + + const newState = navigationLayout(initialState, action as any); + + expect(newState).toBe(initialState); + }); + }); +}); diff --git a/src/ui/reducers/navigationLayout.ts b/src/ui/reducers/navigationLayout.ts new file mode 100644 index 0000000000..982a4cbc0e --- /dev/null +++ b/src/ui/reducers/navigationLayout.ts @@ -0,0 +1,33 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { + MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED, +} from '../actions'; +import type { NavigationLayout } from '../common'; + +type NavigationLayoutAction = + | ActionOf + | ActionOf + | ActionOf; + +export const navigationLayout: Reducer< + NavigationLayout, + NavigationLayoutAction +> = (state = 'tabs', action) => { + switch (action.type) { + case SETTINGS_SET_NAVIGATION_LAYOUT_CHANGED: + case MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED: + return action.payload; + + case APP_SETTINGS_LOADED: { + const { navigationLayout = state } = action.payload; + return navigationLayout; + } + + default: + return state; + } +};