From aa0e1b6ae33e40ba18d3c8b8a22b676b32e37761 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 18 May 2026 15:12:51 -0300 Subject: [PATCH 01/31] chore(storybook): expose DDPCommon stub on meteor mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client/lib/sdk/ddpProtocol.ts` destructures `parseDDP` and `stringifyDDP` from `DDPCommon` at module load. Without a matching entry in the meteor mock any story whose bundle reaches the SDK chain crashes with: Cannot destructure property 'parseDDP' of 'DDPCommon' as it is undefined Add a narrow `DDPCommon` no-op (parseDDP/stringifyDDP) so the destructuring resolves. Scoped change — does not re-introduce the broader SDKClient stub. --- apps/meteor/.storybook/mocks/meteor.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 = {}; From 73572467b165871db4ea18fc7cc0a07a7e771333 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 18 May 2026 15:12:57 -0300 Subject: [PATCH 02/31] feat(ui): add HorizontalDivider wrapper around fuselage Divider Thin wrapper that forces `vertical={false}` on `@rocket.chat/fuselage` Divider, providing a single horizontal-only entrypoint for callers that want to express intent at the type level without re-implementing the visual style. --- .../components/HorizontalDivider/HorizontalDivider.tsx | 8 ++++++++ apps/meteor/client/components/HorizontalDivider/index.ts | 1 + 2 files changed, 9 insertions(+) create mode 100644 apps/meteor/client/components/HorizontalDivider/HorizontalDivider.tsx create mode 100644 apps/meteor/client/components/HorizontalDivider/index.ts 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'; From d434ecb07f4fe17de5c00bb57ca9fab4f51dc7fb Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 19 May 2026 13:10:40 -0300 Subject: [PATCH 03/31] feat(ui-voip): add inline mount mode to MediaCallWidget The widget is currently a floating, draggable portal. The sidebar rail needs to host the same widget anchored inside a slot, so add a slot-based mount mechanism and let the widget render in place without dragging or border styles. Slot mechanism - `MediaCallWidgetSlotContext`: shared context exposing the active slot element + an `inline` boolean. - `MediaCallWidgetSlot`: drop-in component that registers its own `
` as the target. Removing it from the tree restores the floating portal automatically. The slot aligns its child to the top so the anchored widget keeps its intrinsic height instead of stretching to the container. - `MediaCallProvider`: hosts the slot state above the view provider so consumers in `children` and the widget itself share the same context value. `MediaCallProvider` now wraps `children` with `MediaCallViewProvider` so `useMediaCallView` resolves in the app tree. - `MediaCallViewProvider`: when a slot is registered the widget is rendered via `createPortal(widget, slot)`; otherwise it falls back to the previous `AnchorPortal` mount. Widget composition - `WidgetBase`: inline branch drops the fixed positioning, border, and z-index. Background, border-radius, shadow and width are kept so the anchored render looks the same as the floating one. - `Widget`: read inline from context, skip drag wiring when inline. - `WidgetHandle`: hide itself when inline (drag is disabled). - `NewCall`: render the numeric Keypad below the autocomplete when inline so the dialer matches the sidebar layout in the design. Visibility rules - Only "undock" the widget for active call states (calling, ringing, ongoing). When the slot is gone and the session is idle the widget renders nothing instead of popping out as a floating NewCall. Snapshot tests regenerated to reflect the inline-aware render tree. --- .../src/components/MediaCallWidgetSlot.tsx | 19 +++++++++++++++++++ packages/ui-voip/src/components/index.ts | 1 + .../src/context/MediaCallWidgetSlotContext.ts | 19 +++++++++++++++++++ packages/ui-voip/src/index.ts | 2 ++ .../src/views/MediaCallWidget/NewCall.tsx | 10 +++++++++- 5 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 packages/ui-voip/src/components/MediaCallWidgetSlot.tsx create mode 100644 packages/ui-voip/src/context/MediaCallWidgetSlotContext.ts diff --git a/packages/ui-voip/src/components/MediaCallWidgetSlot.tsx b/packages/ui-voip/src/components/MediaCallWidgetSlot.tsx new file mode 100644 index 0000000000000..6a00996d002d3 --- /dev/null +++ b/packages/ui-voip/src/components/MediaCallWidgetSlot.tsx @@ -0,0 +1,19 @@ +import { useLayoutEffect, useRef } from 'react'; + +import { useMediaCallWidgetSlot } from '../context/MediaCallWidgetSlotContext'; + +const slotStyle = { display: 'flex', alignItems: 'flex-start', flex: '0 0 auto', width: '100%' } as const; + +const MediaCallWidgetSlot = () => { + const ref = useRef(null); + const { setSlot } = useMediaCallWidgetSlot(); + + useLayoutEffect(() => { + setSlot(ref.current); + return () => setSlot(null); + }, [setSlot]); + + return
; +}; + +export default MediaCallWidgetSlot; diff --git a/packages/ui-voip/src/components/index.ts b/packages/ui-voip/src/components/index.ts index 0f4ee8b2011fe..1a74180bd381f 100644 --- a/packages/ui-voip/src/components/index.ts +++ b/packages/ui-voip/src/components/index.ts @@ -13,3 +13,4 @@ export { default as Timer } from './Timer'; export { default as DevicePicker } from './DevicePicker'; export { default as CallHistoryInternalUser } from './CallHistoryInternalUser'; export { default as CallHistoryExternalUser } from './CallHistoryExternalUser'; +export { default as MediaCallWidgetSlot } from './MediaCallWidgetSlot'; diff --git a/packages/ui-voip/src/context/MediaCallWidgetSlotContext.ts b/packages/ui-voip/src/context/MediaCallWidgetSlotContext.ts new file mode 100644 index 0000000000000..7f6611cca73b5 --- /dev/null +++ b/packages/ui-voip/src/context/MediaCallWidgetSlotContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +export type MediaCallWidgetSlotContextValue = { + slot: HTMLElement | null; + inline: boolean; + setSlot: (element: HTMLElement | null) => void; +}; + +const defaultValue: MediaCallWidgetSlotContextValue = { + slot: null, + inline: false, + setSlot: () => undefined, +}; + +const MediaCallWidgetSlotContext = createContext(defaultValue); + +export const useMediaCallWidgetSlot = (): MediaCallWidgetSlotContextValue => useContext(MediaCallWidgetSlotContext); + +export default MediaCallWidgetSlotContext; diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts index ab88f1020af61..08b3bff1db801 100644 --- a/packages/ui-voip/src/index.ts +++ b/packages/ui-voip/src/index.ts @@ -2,6 +2,7 @@ export { default as MediaCallProvider } from './providers/MediaCallProvider'; export { MediaCallInstanceContext, + useMediaCallView, useWidgetExternalControls, usePeekMediaSessionState, usePeekMediaSessionPeerInfo, @@ -10,6 +11,7 @@ export { export type { PeekMediaSessionStateReturn } from './context'; export type { PeerInfo } from './context'; export { useMediaCallAction, useMediaCallOpenRoomTracker } from './hooks'; +export { MediaCallWidgetSlot } from './components'; export { CallHistoryContextualBar, MediaCallRoomActivity } from './views'; export type { CallHistoryData } from './views'; diff --git a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx index 085c1fc7611c0..57f2046ccbe57 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx @@ -11,9 +11,11 @@ import { WidgetContent, DevicePicker, ActionButton, + Keypad, } from '../../components'; import { usePeerAutocomplete } from '../../context'; import { useMediaCallView } from '../../context/MediaCallViewContext'; +import { useMediaCallWidgetSlot } from '../../context/MediaCallWidgetSlotContext'; import { useWidgetExternalControls } from '../../context/useWidgetExternalControls'; const NewCall = () => { @@ -21,6 +23,7 @@ const NewCall = () => { const { onCall, onSelectPeer, targetPeer } = useMediaCallView(); const { toggleWidget } = useWidgetExternalControls(); + const { inline } = useMediaCallWidgetSlot(); const autocomplete = usePeerAutocomplete(onSelectPeer, targetPeer); @@ -28,7 +31,7 @@ const NewCall = () => { - toggleWidget()} /> + {!inline && toggleWidget()} />} @@ -37,6 +40,11 @@ const NewCall = () => { )} + {inline && ( + + + + )} From 42f033666940454dff63cecc3dc507c6c0233d24 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 20 May 2026 17:08:16 -0300 Subject: [PATCH 04/31] feat(sidebar): add SidebarRail component 44px-wide vertical navigation rail (Material Design "navigation rail" pattern) matching the Figma "Local Sidebar / Collapsed" design. Composes existing NavBar items (Home, Directory, Marketplace, Administration menu, UserMenu) and adds rail-specific variants: - `SidebarRailCreateNew`: pencil-box menu reusing `useCreateNewMenu`. - `SidebarRailPhone`: phone icon that navigates to `/call-history` and reflects the route in its `pressed` state. - `SidebarRailCallPanel`: Sidepanel-based second-level container hosting a `MediaCallWidgetSlot` for the dialer body. - `SidebarRailCallRouteSync`: keeps the dialer open while on `/call-history` (bouncing back from `closed` to `new`) and closes it on leave when the dialer is idle. - `SidebarRailHeader`: slim top bar reusing the Fuselage NavBar with the workspace 28px icon and the existing `NavBarNavigation` (search + back/forward), suppressing the legacy NavBar groups. - `SidebarRailLoginPage`: icon-only login NavBarItem for the avatar slot when no user is signed in. - `SidebarRailDivider`: horizontal divider with rail-specific tokens. Includes an `Inbox` i18n key reused later and a Storybook file with Anonymous, LoggedIn, WithUnreadBadge and WithCreatePermissions variants. --- .../SidebarRail/SidebarRail.stories.tsx | 64 +++++++++++++++++++ .../sidebar/SidebarRail/SidebarRail.tsx | 56 ++++++++++++++++ .../SidebarRail/SidebarRailCallPanel.tsx | 19 ++++++ .../SidebarRail/SidebarRailCallRouteSync.tsx | 31 +++++++++ .../SidebarRail/SidebarRailCreateNew.tsx | 22 +++++++ .../SidebarRail/SidebarRailDivider.tsx | 11 ++++ .../sidebar/SidebarRail/SidebarRailHeader.tsx | 15 +++++ .../SidebarRail/SidebarRailLoginPage.tsx | 15 +++++ .../sidebar/SidebarRail/SidebarRailPhone.tsx | 38 +++++++++++ .../client/sidebar/SidebarRail/index.ts | 1 + 10 files changed, 272 insertions(+) create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRail.stories.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRail.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailCallPanel.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailCallRouteSync.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailCreateNew.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailDivider.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailHeader.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailLoginPage.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/SidebarRailPhone.tsx create mode 100644 apps/meteor/client/sidebar/SidebarRail/index.ts 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..c1468434ca808 --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRail.tsx @@ -0,0 +1,56 @@ +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 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..0d764fcf113aa --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallPanel.tsx @@ -0,0 +1,19 @@ +import { Box, Sidepanel } from '@rocket.chat/fuselage'; +import { MediaCallWidgetSlot } from '@rocket.chat/ui-voip'; +import { useTranslation } from 'react-i18next'; + +const SidebarRailCallPanel = () => { + const { t } = useTranslation(); + + return ( + + + + + + + + ); +}; + +export default SidebarRailCallPanel; diff --git a/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallRouteSync.tsx b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallRouteSync.tsx new file mode 100644 index 0000000000000..1c0fcc3b71747 --- /dev/null +++ b/apps/meteor/client/sidebar/SidebarRail/SidebarRailCallRouteSync.tsx @@ -0,0 +1,31 @@ +import { useMediaCallView, useWidgetExternalControls } from '@rocket.chat/ui-voip'; +import { useEffect } from 'react'; + +type SidebarRailCallRouteSyncProps = { + currentRoutePath?: string; +}; + +const SidebarRailCallRouteSync = ({ currentRoutePath }: SidebarRailCallRouteSyncProps) => { + const { sessionState } = useMediaCallView(); + const { toggleWidget } = useWidgetExternalControls(); + + useEffect(() => { + const onRoute = currentRoutePath?.includes('/call-history') ?? false; + + // On /call-history: keep the dialer open. If the session is idle (closed) + // — initial load, an ended call, etc — bounce it back to `new`. + if (onRoute && sessionState.state === 'closed') { + toggleWidget(); + return; + } + + // Off /call-history: an idle dialer has no host, so close it. + if (!onRoute && sessionState.state === 'new') { + toggleWidget(); + } + }, [currentRoutePath, sessionState.state, toggleWidget]); + + return null; +}; + +export default SidebarRailCallRouteSync; 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..60a4aa630bcfe --- /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/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'; From 8b2c490a66d1cf67daca1096d9a0ecf28e17bad9 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 20 May 2026 17:08:16 -0300 Subject: [PATCH 05/31] feat(layout): gate SidebarRail behind USE_SIDEBAR_RAIL flag When `USE_SIDEBAR_RAIL` is true (and the layout is not embedded / mobile), replace the top horizontal `NavBar` with the slim `SidebarRailHeader` and render `SidebarRail` as the first column of `#rocket-chat`. The legacy NavBar path remains for the other cases so the change is reversible by flipping a single constant. - `sidebarRailFlag.ts`: dev-only constant, intended to be replaced by `useFeaturePreview('sidebarRail')` in a follow-up before any rollout. - `LayoutWithSidebar.tsx`: pick the header, render the rail, and drive the secondary slot through a new `SecondaryPanel`. While on `/call-history` the slot renders the rail call panel; otherwise it falls back to the existing Sidebar / NavigationRegion feature preview split. - `SecondaryPanel.tsx`: extracted into its own file to comply with `react/no-multi-comp`. - `MainLayoutStyleTags.tsx`: extend the dark palette selector to cover `.rcx-sidebar-rail` so the rail keeps the dark surface in both themes. --- .../root/MainLayout/LayoutWithSidebar.tsx | 29 +++++++---------- .../root/MainLayout/MainLayoutStyleTags.tsx | 2 +- .../views/root/MainLayout/SecondaryPanel.tsx | 32 +++++++++++++++++++ .../views/root/MainLayout/sidebarRailFlag.ts | 1 + 4 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 apps/meteor/client/views/root/MainLayout/SecondaryPanel.tsx create mode 100644 apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx index 81beed25d6b82..a7ad973fe7723 100644 --- a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx @@ -1,5 +1,4 @@ import { Box } from '@rocket.chat/fuselage'; -import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import type { IRouterPaths } from '@rocket.chat/ui-contexts'; import { useLayout, useSetting, useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; @@ -8,17 +7,20 @@ import { useEffect, useRef } from 'react'; import AccessibilityShortcut from './AccessibilityShortcut'; import MainContent from './MainContent'; import { MainLayoutStyleTags } from './MainLayoutStyleTags'; +import SecondaryPanel from './SecondaryPanel'; +import { USE_SIDEBAR_RAIL } from './sidebarRailFlag'; import NavBar from '../../../navbar'; -import Sidebar from '../../../sidebar'; -import NavigationRegion from '../../navigation'; -import RoomsNavigationProvider from '../../navigation/providers/RoomsNavigationProvider'; +import SidebarRail from '../../../sidebar/SidebarRail'; +import SidebarRailCallRouteSync from '../../../sidebar/SidebarRail/SidebarRailCallRouteSync'; +import SidebarRailHeader from '../../../sidebar/SidebarRail/SidebarRailHeader'; const INVALID_ROOM_NAME_PREFIXES = ['#', '?'] as const; export type LayoutWithSidebarProps = { children: ReactNode }; const LayoutWithSidebar = ({ children }: LayoutWithSidebarProps) => { - const { isEmbedded: embeddedLayout } = useLayout(); + const { isEmbedded: embeddedLayout, isMobile } = useLayout(); + const showSidebarRail = USE_SIDEBAR_RAIL && !embeddedLayout && !isMobile; const currentRoutePath = useCurrentRoutePath(); const router = useRouter(); @@ -56,25 +58,16 @@ const LayoutWithSidebar = ({ children }: LayoutWithSidebarProps) => { return ( <> - {!embeddedLayout && } + {!embeddedLayout && (showSidebarRail ? : )} - {!removeSidenav && ( - - - - - - - - - - - )} + {showSidebarRail && } + {showSidebarRail && } + {!removeSidenav && } {children} 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/client/views/root/MainLayout/SecondaryPanel.tsx b/apps/meteor/client/views/root/MainLayout/SecondaryPanel.tsx new file mode 100644 index 0000000000000..aab7112c670e4 --- /dev/null +++ b/apps/meteor/client/views/root/MainLayout/SecondaryPanel.tsx @@ -0,0 +1,32 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; + +import Sidebar from '../../../sidebar'; +import SidebarRailCallPanel from '../../../sidebar/SidebarRail/SidebarRailCallPanel'; +import NavigationRegion from '../../navigation'; +import RoomsNavigationProvider from '../../navigation/providers/RoomsNavigationProvider'; + +type SecondaryPanelProps = { + showSidebarRail: boolean; + currentRoutePath?: string; +}; + +const SecondaryPanel = ({ showSidebarRail, currentRoutePath }: SecondaryPanelProps) => { + if (showSidebarRail && currentRoutePath?.includes('/call-history')) { + return ; + } + + return ( + + + + + + + + + + + ); +}; + +export default SecondaryPanel; diff --git a/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts b/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts new file mode 100644 index 0000000000000..eea625472d967 --- /dev/null +++ b/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts @@ -0,0 +1 @@ +export const USE_SIDEBAR_RAIL = true; From e192de99b6035b7778f02274fcef98add4e1f29b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 3 Jun 2026 13:48:44 -0300 Subject: [PATCH 06/31] feat(layout): make USE_SIDEBAR_RAIL a runtime window flag Expose the SidebarRail flag on window.USE_SIDEBAR_RAIL (default false) so it can be toggled at runtime via a custom script, instead of a hardcoded build-time constant. Read on every render through isSidebarRailEnabled(). --- .../root/MainLayout/LayoutWithSidebar.tsx | 4 ++-- .../views/root/MainLayout/sidebarRailFlag.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx index a7ad973fe7723..d9462e46a9d9e 100644 --- a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.tsx @@ -8,7 +8,7 @@ import AccessibilityShortcut from './AccessibilityShortcut'; import MainContent from './MainContent'; import { MainLayoutStyleTags } from './MainLayoutStyleTags'; import SecondaryPanel from './SecondaryPanel'; -import { USE_SIDEBAR_RAIL } from './sidebarRailFlag'; +import { isSidebarRailEnabled } from './sidebarRailFlag'; import NavBar from '../../../navbar'; import SidebarRail from '../../../sidebar/SidebarRail'; import SidebarRailCallRouteSync from '../../../sidebar/SidebarRail/SidebarRailCallRouteSync'; @@ -20,7 +20,7 @@ export type LayoutWithSidebarProps = { children: ReactNode }; const LayoutWithSidebar = ({ children }: LayoutWithSidebarProps) => { const { isEmbedded: embeddedLayout, isMobile } = useLayout(); - const showSidebarRail = USE_SIDEBAR_RAIL && !embeddedLayout && !isMobile; + const showSidebarRail = isSidebarRailEnabled() && !embeddedLayout && !isMobile; const currentRoutePath = useCurrentRoutePath(); const router = useRouter(); diff --git a/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts b/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts index eea625472d967..d0ae673352a11 100644 --- a/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts +++ b/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts @@ -1 +1,18 @@ -export const USE_SIDEBAR_RAIL = true; +declare global { + interface Window { + USE_SIDEBAR_RAIL?: boolean; + } +} + +/** + * Feature flag for the collapsed SidebarRail navigation. + * + * Exposed on `window` so it can be toggled at runtime without a rebuild — e.g. from an + * admin custom script: + * + * window.USE_SIDEBAR_RAIL = true; + * + * Read on every render, so the value applies on the next page load after being set. + * Defaults to `false`. + */ +export const isSidebarRailEnabled = (): boolean => window.USE_SIDEBAR_RAIL ?? false; From b82e8a8fd081e8686cdda39cf383ef562453ca24 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 3 Jun 2026 14:27:05 -0300 Subject: [PATCH 07/31] chore(lint): disable naming-convention for Window augmentation The global Window interface augmentation must keep the lib name 'Window', which violates the I-prefix naming-convention rule. Disable the rule on that line. --- apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts b/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts index d0ae673352a11..ede40e22772ba 100644 --- a/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts +++ b/apps/meteor/client/views/root/MainLayout/sidebarRailFlag.ts @@ -1,4 +1,5 @@ declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention interface Window { USE_SIDEBAR_RAIL?: boolean; } From cf272475d4889e8b4bda3ec4cc3d9c15629f5bf8 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 3 Jun 2026 14:37:32 -0300 Subject: [PATCH 08/31] test: align LayoutWithSidebar spec mocks with SidebarRail refactor The spec still mocked the removed layout modules (Sidebar, NavigationRegion, FeaturePreview) while LayoutWithSidebar now imports SidebarRail, SidebarRailHeader, SidebarRailCallRouteSync and SecondaryPanel. The unmocked real SidebarRail pulled app/utils/client -> rocketchat.info into the jest graph, which jest cannot parse (SyntaxError). Mock the new imports instead. --- .../root/MainLayout/LayoutWithSidebar.spec.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx index f87e63732ae51..9574e6cd92c86 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,20 +11,11 @@ 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('../../../sidebar/SidebarRail/SidebarRailCallRouteSync', () => () =>
SidebarRailCallRouteSync
); +jest.mock('./SecondaryPanel', () => () =>
SecondaryPanel
); jest.mock('./AccessibilityShortcut', () => () =>
AccessibilityShortcut
); -jest.mock('../../navigation/providers/RoomsNavigationProvider', () => ({ - __esModule: true, - default: ({ children }: { children: ReactNode }) => <>{children}, -})); - -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}, -})); const mockedUseCurrentRoutePath = useCurrentRoutePath as jest.MockedFunction; const mockedUseRouter = useRouter as jest.MockedFunction; From 26f083bd7c660c47779e5b0fbacb97bc92f9f61f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 8 Jun 2026 13:19:02 -0300 Subject: [PATCH 09/31] feat(ui-voip): show dialpad during active SIP calls (DMV-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the DTMF dialpad while a voice call is ongoing so users can drive IVRs, conference bridges and other keypad-driven phone services. - Gate the dialpad on `peerInfo.external` (SIP calls only) — internal user-to-user calls don't need DTMF. - Inline (sidebar rail) renders the keypad permanently expanded; the floating widget keeps the collapsible toggle. - `OngoingCallWithScreen`: show the dialpad when screen sharing is supported but hide it while a local share is active, preserving the screen-sharing layout. - `Keypad` gains an `autoFocus` prop (default true) so the always-on inline keypad does not steal focus on mount; `useKeypad` exposes an `alwaysOpen` option for that layout. - Cover the visibility rules with a unit test. --- .../ui-voip/src/components/Keypad/Keypad.tsx | 5 +- .../src/components/Keypad/useKeypad.tsx | 52 ++++++++ packages/ui-voip/src/components/index.ts | 1 + .../MediaCallWidget/MediaCallDialpad.tsx | 34 ++++++ .../src/views/MediaCallWidget/NewCall.tsx | 2 +- .../OngoingCall.dialpad.spec.tsx | 112 ++++++++++++++++++ .../src/views/MediaCallWidget/OngoingCall.tsx | 44 +++---- .../MediaCallWidget/OngoingCallWithScreen.tsx | 3 + 8 files changed, 221 insertions(+), 32 deletions(-) create mode 100644 packages/ui-voip/src/components/Keypad/useKeypad.tsx create mode 100644 packages/ui-voip/src/views/MediaCallWidget/MediaCallDialpad.tsx create mode 100644 packages/ui-voip/src/views/MediaCallWidget/OngoingCall.dialpad.spec.tsx 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]) => ( void; + }; +}; + +type UseKeypadOptions = { + // When `true` the keypad is always rendered (expanded) and does not steal focus on mount. + // Used by the inline (sidebar rail) layout where the dialpad is permanently visible. + alwaysOpen?: boolean; +}; + +export const useKeypad = (onPress: (tone: string) => void, { alwaysOpen = false }: UseKeypadOptions = {}): UseKeypad => { + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const { t } = useTranslation(); + + const element = ( + + + + + + + { + setInputValue((inputValue) => inputValue + args[0]); + onPress(...args); + }} + /> + + + ); + + return { + element: alwaysOpen || open ? element : null, + buttonProps: { + title: open ? t('Close_dialpad') : t('Open_dialpad'), + onClick: () => setOpen((open) => !open), + }, + }; +}; diff --git a/packages/ui-voip/src/components/index.ts b/packages/ui-voip/src/components/index.ts index 1a74180bd381f..aaf620b1ad009 100644 --- a/packages/ui-voip/src/components/index.ts +++ b/packages/ui-voip/src/components/index.ts @@ -7,6 +7,7 @@ export * from './Actions'; export { default as ToggleButton } from './ToggleButton'; export { default as ActionButton } from './ActionButton'; export { default as Keypad } from './Keypad/Keypad'; +export { useKeypad } from './Keypad/useKeypad'; export { useInfoSlots } from './PeerInfo/useInfoSlots'; export { default as PeerAutocomplete } from './PeerAutocomplete'; export { default as Timer } from './Timer'; diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallDialpad.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallDialpad.tsx new file mode 100644 index 0000000000000..0d154224153af --- /dev/null +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallDialpad.tsx @@ -0,0 +1,34 @@ +import { useKeypad } from '../../components'; +import { useMediaCallView } from '../../context/MediaCallViewContext'; +import { useMediaCallWidgetSlot } from '../../context/MediaCallWidgetSlotContext'; + +/** + * Inline DTMF dialpad for the sidebar-rail call panel (DMV-16). + * + * Rendered inside the widget content rather than the footer action row, so it + * stays clear of footer-level features (e.g. AppActions) and can be added + * without editing shared footer markup — keeping this change isolated. + * + * Visible only when the widget is inline (sidebar rail), the call is external + * (SIP), and screen sharing is not active. Internal user-to-user calls don't + * need DTMF, and an active screen share must keep the available space. + */ +const MediaCallDialpad = () => { + const { sessionState, streams, onTone } = useMediaCallView(); + const { peerInfo } = sessionState; + const { inline } = useMediaCallWidgetSlot(); + + // The keypad is permanently expanded inline, so it must not steal focus on mount. + const { element } = useKeypad(onTone, { alwaysOpen: true }); + + const isSip = !!peerInfo && 'number' in peerInfo; + const isScreenSharing = Boolean(streams?.localScreen?.active); + + if (!inline || !isSip || isScreenSharing) { + return null; + } + + return <>{element}; +}; + +export default MediaCallDialpad; diff --git a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx index 57f2046ccbe57..73d21c79a477d 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx @@ -42,7 +42,7 @@ const NewCall = () => { )} {inline && ( - + )} diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.dialpad.spec.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.dialpad.spec.tsx new file mode 100644 index 0000000000000..52b190a0bdb32 --- /dev/null +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.dialpad.spec.tsx @@ -0,0 +1,112 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import type { ComponentType, ReactNode } from 'react'; + +import OngoingCall from './OngoingCall'; +import OngoingCallWithScreen from './OngoingCallWithScreen'; +import MediaCallViewContext from '../../context/MediaCallViewContext'; +import MediaCallWidgetSlotContext from '../../context/MediaCallWidgetSlotContext'; +import type { PeerInfo, SessionState } from '../../context/definitions'; + +const externalPeer = { number: '+15551234567' } as PeerInfo; +const internalPeer = { displayName: 'John Doe', userId: 'u1', username: 'john.doe' } as PeerInfo; + +type RenderOptions = { + peerInfo: PeerInfo; + inline?: boolean; + localScreenActive?: boolean; +}; + +const renderView = (Component: ComponentType, { peerInfo, inline = false, localScreenActive = false }: RenderOptions) => { + const sessionState = { + state: 'ongoing', + connectionState: 'CONNECTED', + peerInfo, + transferredBy: undefined, + hidden: false, + muted: false, + held: false, + remoteMuted: false, + remoteHeld: false, + callId: 'call-1', + supportedFeatures: ['audio', 'screen-share', 'hold', 'transfer'], + } as SessionState; + + const streams = localScreenActive ? { localScreen: { active: true, stream: null } } : {}; + + const viewContextValue = { + sessionState, + onClickDirectMessage: undefined, + onMute: jest.fn(), + onHold: jest.fn(), + onDeviceChange: jest.fn(), + onForward: jest.fn(), + onTone: jest.fn(), + onEndCall: jest.fn(), + onCall: jest.fn(), + onAccept: jest.fn(), + onSelectPeer: jest.fn(), + onToggleScreenSharing: jest.fn(), + streams, + widgetPositionTracker: undefined, + } as any; + + const slotContextValue = { + slot: inline ? document.createElement('div') : null, + inline, + setSlot: jest.fn(), + }; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return render( + + + , + { wrapper: mockAppRoot().build() }, + ); +}; + +describe('Ongoing call dialpad (DMV-16)', () => { + it('does not render the dialpad for internal (non-SIP) calls', () => { + renderView(OngoingCall, { peerInfo: internalPeer }); + + expect(screen.queryByRole('button', { name: 'Dialpad' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '#' })).not.toBeInTheDocument(); + }); + + it('renders a collapsible dialpad toggle for external (SIP) calls when floating', () => { + renderView(OngoingCall, { peerInfo: externalPeer, inline: false }); + + // floating: toggle button present, keypad collapsed by default + expect(screen.getByRole('button', { name: 'Dialpad' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '#' })).not.toBeInTheDocument(); + }); + + it('renders the dialpad expanded (no toggle) for external calls when inline', () => { + renderView(OngoingCall, { peerInfo: externalPeer, inline: true }); + + // inline: no toggle, the expanded keypad is mounted (the '#' key proves it) + expect(screen.queryByRole('button', { name: 'Dialpad' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '#' })).toBeInTheDocument(); + }); +}); + +describe('Ongoing call with screen share dialpad (DMV-16)', () => { + // The screen-share view only exposes the dialpad inline (sidebar rail), via . + it('hides the inline dialpad while screen sharing is active', () => { + renderView(OngoingCallWithScreen, { peerInfo: externalPeer, inline: true, localScreenActive: true }); + + expect(screen.queryByRole('button', { name: '#' })).not.toBeInTheDocument(); + }); + + it('shows the inline dialpad when screen sharing is supported but not active', () => { + renderView(OngoingCallWithScreen, { peerInfo: externalPeer, inline: true, localScreenActive: false }); + + expect(screen.getByRole('button', { name: '#' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx index 934691bed18f7..5c7746fcd9a9b 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx @@ -1,7 +1,7 @@ -import { Box, ButtonGroup, Divider, Field, FieldRow, TextInput } from '@rocket.chat/fuselage'; -import { useState } from 'react'; +import { ButtonGroup } from '@rocket.chat/fuselage'; import { useTranslation } from 'react-i18next'; +import MediaCallDialpad from './MediaCallDialpad'; import { ToggleButton, PeerInfo, @@ -14,19 +14,23 @@ import { Timer, DevicePicker, ActionButton, + useKeypad, useInfoSlots, - Keypad, } from '../../components'; import { useMediaCallView } from '../../context/MediaCallViewContext'; +import { useMediaCallWidgetSlot } from '../../context/MediaCallWidgetSlotContext'; const OngoingCall = () => { const { t } = useTranslation(); const { sessionState, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = useMediaCallView(); const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures } = sessionState; + const { inline } = useMediaCallWidgetSlot(); - const [open, setOpen] = useState(false); - const [inputValue, setInputValue] = useState(''); + // Floating widget keeps the collapsible DTMF toggle for external (SIP) calls. + // The inline (sidebar rail) dialpad is rendered by in the content instead. + const showDialpad = !!peerInfo && 'number' in peerInfo; + const { element: keypad, buttonProps: keypadButtonProps } = useKeypad(onTone); const slots = useInfoSlots(muted, held, connectionState); const remoteSlots = useInfoSlots(remoteMuted, remoteHeld); @@ -53,33 +57,15 @@ const OngoingCall = () => { + - {open ? ( - - - - - - - { - setInputValue((inputValue) => inputValue + args[0]); - onTone(...args); - }} - /> - - - ) : null} - - setOpen((open) => !open)} - /> + {keypad} + + {showDialpad && !inline && ( + + )} {holdAvailable && ( { + + {isPopout && (