diff --git a/apps/meteor/.storybook/mocks/meteor.ts b/apps/meteor/.storybook/mocks/meteor.ts index e3f0e0a979e70..7fdd0161454fe 100644 --- a/apps/meteor/.storybook/mocks/meteor.ts +++ b/apps/meteor/.storybook/mocks/meteor.ts @@ -29,6 +29,11 @@ export const Meteor = { users: {}, }; +export const DDPCommon = { + parseDDP: () => undefined, + stringifyDDP: () => '', +}; + export const Tracker = { autorun: () => ({ stop: () => {}, @@ -94,5 +99,3 @@ export const Session = { get: () => {}, set: () => {}, }; - -export const DDPCommon = {}; diff --git a/apps/meteor/client/components/HorizontalDivider/HorizontalDivider.tsx b/apps/meteor/client/components/HorizontalDivider/HorizontalDivider.tsx new file mode 100644 index 0000000000000..8e44ec39a1287 --- /dev/null +++ b/apps/meteor/client/components/HorizontalDivider/HorizontalDivider.tsx @@ -0,0 +1,8 @@ +import { Divider } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; + +type HorizontalDividerProps = Omit, 'vertical'>; + +const HorizontalDivider = (props: HorizontalDividerProps) => ; + +export default HorizontalDivider; diff --git a/apps/meteor/client/components/HorizontalDivider/index.ts b/apps/meteor/client/components/HorizontalDivider/index.ts new file mode 100644 index 0000000000000..dc7f5fe8b5e2d --- /dev/null +++ b/apps/meteor/client/components/HorizontalDivider/index.ts @@ -0,0 +1 @@ +export { default } from './HorizontalDivider'; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRail.stories.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRail.stories.tsx new file mode 100644 index 0000000000000..0db3df42492ae --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRail.stories.tsx @@ -0,0 +1,64 @@ +import { Box } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { SessionContext } from '@rocket.chat/ui-contexts'; +import type { SessionContextValue } from '@rocket.chat/ui-contexts'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; + +import SidebarRail from './SidebarRail'; + +const sessionMock = (state: Record): SessionContextValue => ({ + query: (name) => [() => () => undefined, () => state[name]], + dispatch: () => undefined, +}); + +const baseRoot = () => + mockAppRoot().withSetting('Layout_Show_Home_Button', true).withTranslations('en', 'core', { + Sidebar: 'Sidebar', + Home: 'Home', + Create_new: 'Create new', + Voice_Call: 'Voice Call', + Pages_and_actions: 'Pages and actions', + Workspace_and_user_preferences: 'Workspace and user preferences', + }); + +export default { + title: 'Sidebar/SidebarRail', + + component: SidebarRail, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +type Story = StoryObj; + +export const Anonymous: Story = { + decorators: [baseRoot().buildStoryDecorator()], +}; + +export const LoggedIn: Story = { + decorators: [baseRoot().withJohnDoe().buildStoryDecorator()], +}; + +export const WithUnreadBadge: Story = { + decorators: [ + baseRoot() + .withJohnDoe() + .wrap((children: ReactNode) => {children}) + .buildStoryDecorator(), + ], +}; + +export const WithCreatePermissions: Story = { + decorators: [ + baseRoot().withJohnDoe().withPermission('create-c').withPermission('create-p').withPermission('create-d').buildStoryDecorator(), + ], +}; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRail.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRail.tsx new file mode 100644 index 0000000000000..85e823af71146 --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRail.tsx @@ -0,0 +1,61 @@ +import { Box, NavBarGroup } from '@rocket.chat/fuselage'; +import { useUser } from '@rocket.chat/ui-contexts'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import SidebarRailCreateNew from './SidebarRailCreateNew'; +import SidebarRailDivider from './SidebarRailDivider'; +import SidebarRailLoginPage from './SidebarRailLoginPage'; +import SidebarRailPhone from './SidebarRailPhone'; +import SidebarRailSort from './SidebarRailSort'; +import NavBarItemDirectoryPage from '../../navbar/NavBarPagesGroup/NavBarItemDirectoryPage'; +import NavBarItemHomePage from '../../navbar/NavBarPagesGroup/NavBarItemHomePage'; +import NavBarItemMarketPlaceMenu from '../../navbar/NavBarPagesGroup/NavBarItemMarketPlaceMenu'; +import { NavBarItemAdministrationMenu, UserMenu } from '../../navbar/NavBarSettingsToolbar'; + +const SidebarRail = () => { + const { t } = useTranslation(); + const user = useUser(); + + return ( + + + + + + + + + + + + + + + + + + {user ? : } + + + + ); +}; + +export default memo(SidebarRail); diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallPanel.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallPanel.tsx new file mode 100644 index 0000000000000..559e2573ff19a --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallPanel.tsx @@ -0,0 +1,29 @@ +import { Box, Sidepanel } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOn, FeaturePreviewOff } from '@rocket.chat/ui-client'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import { InlineMediaCallWidget } from '@rocket.chat/ui-voip'; +import { useTranslation } from 'react-i18next'; + +import SidebarPortal from '../../portals/SidebarPortal'; + +const SidebarRailCallPanel = () => { + const { t } = useTranslation(); + const { isEmbedded: embeddedLayout, isMobile } = useLayout(); + + return ( + + + + + + + + + + + {null} + + ); +}; + +export default SidebarRailCallPanel; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailCreateNew.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCreateNew.tsx new file mode 100644 index 0000000000000..5299cfc77adc3 --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCreateNew.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import type { HTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCreateNewMenu } from '../../navbar/NavBarPagesGroup/hooks/useCreateNewMenu'; + +type SidebarRailCreateNewProps = Omit, 'is'>; + +const SidebarRailCreateNew = (props: SidebarRailCreateNewProps) => { + const { t } = useTranslation(); + + const sections = useCreateNewMenu(); + + if (sections.length === 0) { + return null; + } + + return ; +}; + +export default SidebarRailCreateNew; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailDivider.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailDivider.tsx new file mode 100644 index 0000000000000..e6b8bd916ac0c --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailDivider.tsx @@ -0,0 +1,11 @@ +import type { ComponentProps } from 'react'; + +import HorizontalDivider from '../../components/HorizontalDivider'; + +type SidebarRailDividerProps = ComponentProps; + +const SidebarRailDivider = (props: SidebarRailDividerProps) => ( + +); + +export default SidebarRailDivider; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailHeader.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailHeader.tsx new file mode 100644 index 0000000000000..27c15578474f5 --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailHeader.tsx @@ -0,0 +1,15 @@ +import { Box, NavBar as NavBarComponent, NavBarSection } from '@rocket.chat/fuselage'; + +import NavBarNavigation from '../../navbar/NavBarNavigation'; + +const SidebarRailHeader = () => ( + + + + + + + +); + +export default SidebarRailHeader; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailLoginPage.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailLoginPage.tsx new file mode 100644 index 0000000000000..6eae2924116e4 --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailLoginPage.tsx @@ -0,0 +1,15 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useSessionDispatch } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; + +type SidebarRailLoginPageProps = Omit, 'is'>; + +const SidebarRailLoginPage = (props: SidebarRailLoginPageProps) => { + const setForceLogin = useSessionDispatch('forceLogin'); + const { t } = useTranslation(); + + return setForceLogin(true)} />; +}; + +export default SidebarRailLoginPage; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailPhone.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailPhone.tsx new file mode 100644 index 0000000000000..4860c09674cba --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailPhone.tsx @@ -0,0 +1,38 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useStableCallback } from '@rocket.chat/fuselage-hooks'; +import { useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts'; +import { useMediaCallAction } from '@rocket.chat/ui-voip'; +import type { HTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; + +type SidebarRailPhoneProps = Omit, 'is'>; + +const SidebarRailPhone = (props: SidebarRailPhoneProps) => { + const { t } = useTranslation(); + const callAction = useMediaCallAction(); + const router = useRouter(); + const currentRoute = useCurrentRoutePath(); + + const isActive = currentRoute?.includes('/call-history') ?? false; + + const handleClick = useStableCallback(() => { + router.navigate('/call-history'); + }); + + if (!callAction) { + return null; + } + + return ( + + ); +}; + +export default SidebarRailPhone; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailSort.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailSort.tsx new file mode 100644 index 0000000000000..9f5ac72bf00da --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailSort.tsx @@ -0,0 +1,28 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import type { HTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useSortMenu } from '../../navbar/NavBarPagesGroup/hooks/useSortMenu'; + +type SidebarRailSortProps = Omit, 'is'>; + +const SidebarRailSort = (props: SidebarRailSortProps) => { + const { t } = useTranslation(); + + const sections = useSortMenu(); + + return ( + + ); +}; + +export default SidebarRailSort; diff --git a/apps/meteor/client/sidebar/SidebarRail/index.ts b/apps/meteor/client/sidebar/SidebarRail/index.ts new file mode 100644 index 0000000000000..d7869b1253c9b --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/index.ts @@ -0,0 +1 @@ +export { default } from './SidebarRail'; diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index d09d2a6bc5cbd..19c4ebb65fb83 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -25,6 +25,7 @@ const OAuthAuthorizationPage = lazy(() => import('../views/oauth/OAuthAuthorizat const OAuthErrorPage = lazy(() => import('../views/oauth/OAuthErrorPage')); const NotFoundPage = lazy(() => import('../views/notFound/NotFoundPage')); const CallHistoryPage = lazy(() => import('../views/mediaCallHistory/CallHistoryPage')); +const SidebarRailCallPanel = lazy(() => import('../sidebar/SidebarRail/SidebarRailCallPanel')); const SearchPage = lazy(() => import('../views/search/SearchPage')); declare module '@rocket.chat/ui-contexts' { @@ -253,6 +254,7 @@ router.defineRoutes([ element: appLayout.wrap( + , ), }, diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx index f87e63732ae51..070f2e90f478d 100644 --- a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx @@ -1,7 +1,6 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts'; import { render } from '@testing-library/react'; -import type { ReactNode } from 'react'; import LayoutWithSidebar from './LayoutWithSidebar'; @@ -12,19 +11,17 @@ jest.mock('@rocket.chat/ui-contexts', () => ({ })); jest.mock('../../../navbar', () => () =>
NavBar
); -jest.mock('../../../sidebar', () => () =>
Sidebar
); -jest.mock('../../navigation', () => () =>
NavigationRegion
); +jest.mock('../../../sidebar/SidebarRail', () => () =>
SidebarRail
); +jest.mock('../../../sidebar/SidebarRail/SidebarRailHeader', () => () =>
SidebarRailHeader
); jest.mock('./AccessibilityShortcut', () => () =>
AccessibilityShortcut
); -jest.mock('../../navigation/providers/RoomsNavigationProvider', () => ({ - __esModule: true, - default: ({ children }: { children: ReactNode }) => <>{children}, -})); - +jest.mock('../../navigation/providers/RoomsNavigationProvider', () => () =>
Navigationprovider
); +jest.mock('../../navigation', () => () =>
NavigationRegion
); +jest.mock('../../../sidebar', () => () =>
Sidebar
); jest.mock('@rocket.chat/ui-client', () => ({ ...jest.requireActual('@rocket.chat/ui-client'), - FeaturePreview: ({ children }: { children: ReactNode }) => <>{children}, - FeaturePreviewOn: ({ children }: { children: ReactNode }) => <>{children}, - FeaturePreviewOff: ({ children }: { children: ReactNode }) => <>{children}, + FeaturePreview: ({ children }: any) => children, + FeaturePreviewOn: ({ children }: any) => children, + FeaturePreviewOff: () => null, })); const mockedUseCurrentRoutePath = useCurrentRoutePath as jest.MockedFunction; diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx index 81beed25d6b82..eb6c94467162f 100644 --- a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx @@ -10,6 +10,8 @@ import MainContent from './MainContent'; import { MainLayoutStyleTags } from './MainLayoutStyleTags'; import NavBar from '../../../navbar'; import Sidebar from '../../../sidebar'; +import SidebarRail from '../../../sidebar/SidebarRail'; +import SidebarRailHeader from '../../../sidebar/SidebarRail/SidebarRailHeader'; import NavigationRegion from '../../navigation'; import RoomsNavigationProvider from '../../navigation/providers/RoomsNavigationProvider'; @@ -18,7 +20,10 @@ const INVALID_ROOM_NAME_PREFIXES = ['#', '?'] as const; export type LayoutWithSidebarProps = { children: ReactNode }; const LayoutWithSidebar = ({ children }: LayoutWithSidebarProps) => { - const { isEmbedded: embeddedLayout } = useLayout(); + const { + isEmbedded: embeddedLayout, + sidebar: { shouldToggle }, + } = useLayout(); const currentRoutePath = useCurrentRoutePath(); const router = useRouter(); @@ -56,13 +61,28 @@ const LayoutWithSidebar = ({ children }: LayoutWithSidebarProps) => { return ( <> - {!embeddedLayout && } + {!embeddedLayout && ( + + + + + + + + + )} + + + + + {null} + {!removeSidenav && ( diff --git a/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx b/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx index 735347ea19799..69eb1fdc0b72f 100644 --- a/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx +++ b/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx @@ -9,7 +9,7 @@ export const MainLayoutStyleTags = () => { return ( <> - + {theme === 'dark' && } ); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/siderail.ts b/apps/meteor/tests/e2e/page-objects/fragments/siderail.ts new file mode 100644 index 0000000000000..8152840d72573 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/siderail.ts @@ -0,0 +1,18 @@ +import type { Locator, Page } from '@playwright/test'; + +// Sidebar rail is currently in feature preview +export class SidebarRail { + private root: Locator; + + constructor(protected page: Page) { + this.root = page.getByRole('navigation', { name: 'Sidebar rail' }); + } + + private get voiceCallGroup() { + return this.root.getByRole('group', { name: 'Voice call' }); + } + + get callBtn() { + return this.voiceCallGroup.getByRole('button', { name: 'Calls' }); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts index 0fc68a03bf716..7abf3f21cf4d2 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts @@ -111,9 +111,12 @@ export class Widget { private readonly transferModal: TransferModal; - constructor(private readonly page: Page) { + private readonly page: Page; + + constructor(page: Page, root?: Locator) { + this.page = page; this.transferModal = new TransferModal(page, page.getByRole('dialog', { name: 'Transfer call' })); - this.root = page.getByRole('dialog', { name: 'Voice call', exact: false }); + this.root = root || page.getByRole('dialog', { name: 'Voice call', exact: false }); this.callControls = new VoiceCallControls(this.root.getByRole('group')); this.headerControls = new VoiceCallControls(this.root.getByRole('banner')); } @@ -134,6 +137,10 @@ export class Widget { return this.root.getByRole('button', { name: 'Show call here' }); } + get modalTransfer() { + return this.transferModal; + } + async showCallHere(): Promise { await this.btnShowCallHere.click(); await expect(this.btnShowCallHere).not.toBeVisible(); @@ -234,6 +241,30 @@ export class Widget { } } +export class DockedWidget extends Widget { + constructor(page: Page) { + super(page, page.getByRole('complementary', { name: 'Calls' }).getByRole('dialog', { name: 'Voice Call', exact: false })); + } + + public override async hangup(): Promise { + await this.controls.hangup.click(); + await expect(this.content).toBeVisible(); + } + + public override async reject(): Promise { + await this.controls.hangup.click(); + await expect(this.content).toBeVisible(); + } + + public override async transferCall(username: string): Promise { + await this.controls.transfer.click(); + await expect(this.modalTransfer.content).toBeVisible(); + await this.modalTransfer.transferCall(username); + await expect(this.modalTransfer.content).not.toBeVisible(); + await expect(this.content).toBeVisible(); + } +} + export class RoomSection { private readonly root: Locator; @@ -352,6 +383,8 @@ export class PopoutPage extends RoomSection { export class VoiceCalls { public readonly widget: Widget; + public readonly dockedWidget: Widget; + public readonly roomSection: RoomSection; public popoutPage: PopoutPage | undefined; @@ -361,6 +394,7 @@ export class VoiceCalls { constructor(page: Page) { this.page = page; this.widget = new Widget(page); + this.dockedWidget = new DockedWidget(page); this.roomSection = new RoomSection(page.getByRole('region', { name: 'Voice call' })); } diff --git a/apps/meteor/tests/e2e/page-objects/home-channel.ts b/apps/meteor/tests/e2e/page-objects/home-channel.ts index e7b1fbbdcd7d0..f703e4049ed3f 100644 --- a/apps/meteor/tests/e2e/page-objects/home-channel.ts +++ b/apps/meteor/tests/e2e/page-objects/home-channel.ts @@ -20,6 +20,7 @@ import { UserInfoFlexTab, FilesFlexTab, } from './fragments'; +import { SidebarRail } from './fragments/siderail'; import { RoomToolbar } from './fragments/toolbar'; import { UserCard } from './fragments/user-card'; import { VoiceCalls } from './fragments/voice-calls'; @@ -61,11 +62,14 @@ export class HomeChannel { readonly threadComposer: ThreadComposer; + readonly sidebarRail: SidebarRail; + constructor(page: Page) { this.page = page; this.content = new HomeContent(page); this.sidebar = new RoomSidebar(page); this.sidepanel = new Sidepanel(page); + this.sidebarRail = new SidebarRail(page); this.navbar = new Navbar(page); this.userCard = new UserCard(page); this._tabs = { diff --git a/apps/meteor/tests/e2e/utils/setUserPreferences.ts b/apps/meteor/tests/e2e/utils/setUserPreferences.ts index 2e55f22dd9e66..0a2f02a208dae 100644 --- a/apps/meteor/tests/e2e/utils/setUserPreferences.ts +++ b/apps/meteor/tests/e2e/utils/setUserPreferences.ts @@ -3,5 +3,8 @@ import type { UsersSetPreferencesParamsPOST } from '@rocket.chat/rest-typings'; import type { BaseTest } from './test'; -export const setUserPreferences = (api: BaseTest['api'], preferences: UsersSetPreferencesParamsPOST['data']): Promise => - api.post(`/users.setPreferences`, { data: preferences }); +export const setUserPreferences = ( + api: BaseTest['api'], + preferences: UsersSetPreferencesParamsPOST['data'], + userId?: string, +): Promise => api.post(`/users.setPreferences`, { ...(userId ? { userId } : undefined), data: preferences }); diff --git a/apps/meteor/tests/e2e/voice-calls-ee.spec.ts b/apps/meteor/tests/e2e/voice-calls-ee.spec.ts index cbbfab3d260d7..bb65cc68ef96a 100644 --- a/apps/meteor/tests/e2e/voice-calls-ee.spec.ts +++ b/apps/meteor/tests/e2e/voice-calls-ee.spec.ts @@ -4,7 +4,7 @@ import { IS_EE } from './config/constants'; import { createAuxContext } from './fixtures/createAuxContext'; import { Users } from './fixtures/userStates'; import { HomeChannel } from './page-objects'; -import { setSettingValueById } from './utils'; +import { setSettingValueById, setUserPreferences } from './utils'; import { expect, test } from './utils/test'; test.describe('Internal Voice Calls - Enterprise Edition', () => { @@ -437,3 +437,183 @@ test.describe('Internal Voice Calls - Popout view - Enterprise Edition', () => { }); }); }); + +test.describe('Internal Voice Calls - Docked Widget (call panel) - Enterprise Edition', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + + test.beforeAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_AllowFeaturePreview', true), + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + setUserPreferences( + api, + { + featuresPreview: [ + { + name: 'sidebarRail', + value: true, + }, + ], + }, + Users.user1.data._id, + ), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + setSettingValueById(api, 'Accounts_AllowFeaturePreview', false), + ...sessions.map(({ page }) => page.close()), + setUserPreferences( + api, + { + featuresPreview: [ + { + name: 'sidebarRail', + value: false, + }, + ], + }, + Users.user1.data._id, + ), + ]); + }); + + test('should initiate voice call from call panel', async () => { + const [user1, user2] = sessions; + + await test.step('should open call panel', async () => { + await user1.poHomeChannel.sidebarRail.callBtn.click(); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toBeVisible(); + }); + + await test.step('initiate a voice call docked widget', async () => { + await user1.poHomeChannel.voiceCalls.dockedWidget.initiateCall(Users.user2.data.username); + }); + + await test.step('user2 accepts the call', async () => { + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('user2 ends the call', async () => { + await user2.poHomeChannel.voiceCalls.widget.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + // Docked widget doesn't close + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toBeVisible(); + // Test if widget is back to "new call" state + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toHaveAccessibleName('Voice call New call'); + }); + }); + + test('should handle call controls during active call', async () => { + const [user1, user2] = sessions; + await test.step('establish call connection', async () => { + await user1.poHomeChannel.sidebarRail.callBtn.click(); + await user1.poHomeChannel.voiceCalls.dockedWidget.initiateCall(Users.user2.data.username); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('should mute/unmute microphone from user1', async () => { + // User1 mutes microphone + await user1.poHomeChannel.voiceCalls.dockedWidget.muteSelf(); + + // User1 unmutes microphone + await user1.poHomeChannel.voiceCalls.dockedWidget.unmuteSelf(); + }); + + await test.step('should put call on hold from user1', async () => { + // User1 puts call on hold + await user1.poHomeChannel.voiceCalls.dockedWidget.holdSelf(); + + // User1 resumes call + await user1.poHomeChannel.voiceCalls.dockedWidget.resumeSelf(); + }); + + await test.step('should show regular widget when not on call panel', async () => { + await user1.poHomeChannel.navbar.btnHome.click(); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + }); + + await test.step('should show docked widget and hide regular when on call panel', async () => { + await user1.poHomeChannel.sidebarRail.callBtn.click(); + // widget fragment matches both docked and regular widget due to lack of specificity and shared components + // To ensure we only have the docked widget visible, we assert there's only one and that the visible one is the docked. + await expect(user1.poHomeChannel.voiceCalls.widget.content).toHaveCount(1); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toBeVisible(); + }); + + await test.step('should end the call from user1', async () => { + await user1.poHomeChannel.voiceCalls.dockedWidget.hangup(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toHaveAccessibleName('Voice call New call'); + }); + }); + + test('should transfer call to another user', async ({ browser, api }) => { + const [user1, user2] = sessions; + + // Create user3 session only for this test + await api.post('/users.setStatus', { status: 'online', username: 'user3' }); + + const user3Context = await createAuxContext(browser, Users.user3); + const user3 = { page: user3Context.page, poHomeChannel: new HomeChannel(user3Context.page) }; + + await test.step('establish call between user1 and user2', async () => { + await user1.poHomeChannel.sidebarRail.callBtn.click(); + await user1.poHomeChannel.voiceCalls.dockedWidget.initiateCall(Users.user2.data.username); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('user1 transfers call to user3', async () => { + await user1.poHomeChannel.voiceCalls.dockedWidget.transferCall('user3'); + await user1.poHomeChannel.toastMessage.waitForDisplay({ type: 'success' }); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toHaveAccessibleName('Voice call New call'); + await expect(user2.poHomeChannel.voiceCalls.widget.content).toHaveAccessibleName(/Transferring call.../gi); + }); + + await test.step('user3 receives transferred call', async () => { + await expect(user3.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).toHaveAccessibleName(/Incoming call transfer.../gi); + await user3.poHomeChannel.voiceCalls.widget.acceptCall(); + }); + + await test.step('user3 ends the call', async () => { + await user3.poHomeChannel.voiceCalls.widget.hangup(); + await expect(user3.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + + await user3.page.close(); + }); + + test('should decline incoming voice call', async () => { + const [user1, user2] = sessions; + + await test.step('user1 initiates call to user2', async () => { + await user1.poHomeChannel.sidebarRail.callBtn.click(); + await user1.poHomeChannel.voiceCalls.dockedWidget.initiateCall(Users.user2.data.username); + }); + + await test.step('user2 declines the call', async () => { + await user2.poHomeChannel.voiceCalls.widget.reject(); + }); + + await test.step('Verify call widget disappears', async () => { + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.dockedWidget.content).toHaveAccessibleName('Voice call New call'); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9d367a70979dc..f21236642d693 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6100,6 +6100,8 @@ "Sidebar": "Sidebar", "Sidebar_actions": "Sidebar actions", "Sidebar_list_mode": "Sidebar items view mode", + "Sidebar_rail": "Sidebar rail", + "Sidebar_rail_description": "Navigate with a compact, icon-only sidebar rail, alongside a new quick access Voice Call panel unified with Call History.", "Sidebar_Sections_Order": "Sidebar sections order", "Sidebar_Sections_Order_Description": "Select the categories in your preferred order", "Sign_in_to_start_talking": "Sign in to start talking", diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index adb9434a3c4fd..fd39896621d04 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -1,6 +1,6 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; -export type FeaturesAvailable = 'secondarySidebar' | 'aiSearch'; +export type FeaturesAvailable = 'secondarySidebar' | 'aiSearch' | 'sidebarRail'; export type FeaturePreviewProps = { name: FeaturesAvailable; @@ -36,6 +36,14 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [ value: false, enabled: true, }, + { + name: 'sidebarRail', + i18n: 'Sidebar_rail', + description: 'Sidebar_rail_description', + group: 'Navigation', + value: false, + enabled: true, + }, ]; export const enabledDefaultFeatures = defaultFeaturesPreview.filter((feature) => feature.enabled); diff --git a/packages/ui-voip/src/components/Keypad/Keypad.tsx b/packages/ui-voip/src/components/Keypad/Keypad.tsx index cdceeed6229ed..98f6610fac3e3 100644 --- a/packages/ui-voip/src/components/Keypad/Keypad.tsx +++ b/packages/ui-voip/src/components/Keypad/Keypad.tsx @@ -5,6 +5,7 @@ import Key from './Key'; export type KeypadProps = { onKeyPress(key: string): void; + autoFocus?: boolean; }; const DIGITS = [ @@ -22,8 +23,8 @@ const DIGITS = [ ['#', ''], ]; -const Keypad = ({ onKeyPress }: KeypadProps) => ( - +const Keypad = ({ onKeyPress, autoFocus = true }: KeypadProps) => ( + {DIGITS.map(([primaryDigit, alternativeDigit, longPressDigit]) => ( { const draggableContext = useDraggableWidget(); + const isInline = !draggableContext; return ( diff --git a/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap b/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap index 6d6a9a8f7585d..87ad46c7c3f8d 100644 --- a/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap +++ b/packages/ui-voip/src/components/Widget/__snapshots__/Widget.spec.tsx.snap @@ -9,7 +9,7 @@ exports[`renders FullWidget without crashing 1`] = ` />