diff --git a/.changeset/lucky-beds-glow.md b/.changeset/lucky-beds-glow.md new file mode 100644 index 000000000000..3e23797025e1 --- /dev/null +++ b/.changeset/lucky-beds-glow.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/ui-client': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Feature Preview: New Navigation - `Header` and `Contextualbar` size improvements consistent with the new global `NavBar` diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index cead4a2cb584..20b023cc61aa 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -777,7 +777,7 @@ } & .start { - margin-top: 12px; + margin-top: 44px; text-align: center; @@ -794,12 +794,6 @@ & .editing .body { border-radius: var(--border-radius); } - - &.has-leader { - & .wrapper { - padding-top: 57px; - } - } } .rcx-message { diff --git a/apps/meteor/client/NavBarV2/NavBar.tsx b/apps/meteor/client/NavBarV2/NavBar.tsx new file mode 100644 index 000000000000..908e729c956e --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBar.tsx @@ -0,0 +1,73 @@ +import { useToolbar } from '@react-aria/toolbar'; +import { NavBar as NavBarComponent, NavBarSection, NavBarGroup, NavBarDivider } from '@rocket.chat/fuselage'; +import { usePermission, useTranslation, useUser } from '@rocket.chat/ui-contexts'; +import React, { useRef } from 'react'; + +import { useIsCallEnabled, useIsCallReady } from '../contexts/CallContext'; +import { useOmnichannelEnabled } from '../hooks/omnichannel/useOmnichannelEnabled'; +import { useOmnichannelShowQueueLink } from '../hooks/omnichannel/useOmnichannelShowQueueLink'; +import { useHasLicenseModule } from '../hooks/useHasLicenseModule'; +import { + NavBarItemOmniChannelCallDialPad, + NavBarItemOmnichannelContact, + NavBarItemOmnichannelLivechatToggle, + NavBarItemOmnichannelQueue, + NavBarItemOmnichannelCallToggle, +} from './NavBarOmnichannelToolbar'; +import { NavBarItemMarketPlaceMenu, NavBarItemAuditMenu, NavBarItemDirectoryPage, NavBarItemHomePage } from './NavBarPagesToolbar'; +import { NavBarItemLoginPage, NavBarItemAdministrationMenu, UserMenu } from './NavBarSettingsToolbar'; + +const NavBar = () => { + const t = useTranslation(); + const user = useUser(); + + const hasAuditLicense = useHasLicenseModule('auditing') === true; + + const showOmnichannel = useOmnichannelEnabled(); + const hasManageAppsPermission = usePermission('manage-apps'); + const hasAccessMarketplacePermission = usePermission('access-marketplace'); + const showMarketplace = hasAccessMarketplacePermission || hasManageAppsPermission; + + const showOmnichannelQueueLink = useOmnichannelShowQueueLink(); + const isCallEnabled = useIsCallEnabled(); + const isCallReady = useIsCallReady(); + + const pagesToolbarRef = useRef(null); + const { toolbarProps: pagesToolbarProps } = useToolbar({ 'aria-label': t('Pages') }, pagesToolbarRef); + + const omnichannelToolbarRef = useRef(null); + const { toolbarProps: omnichannelToolbarProps } = useToolbar({ 'aria-label': t('Omnichannel') }, omnichannelToolbarRef); + + return ( + + + + + + {showMarketplace && } + {hasAuditLicense && } + + {showOmnichannel && ( + <> + + + {showOmnichannelQueueLink && } + {isCallReady && } + + {isCallEnabled && } + + + + )} + + + + + {user ? : } + + + + ); +}; + +export default NavBar; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx new file mode 100644 index 000000000000..af9b907df12e --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmniChannelCallDialPad.tsx @@ -0,0 +1,30 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +import { useVoipOutboundStates } from '../../contexts/CallContext'; +import { useDialModal } from '../../hooks/useDialModal'; + +type NavBarItemOmniChannelCallDialPadProps = ComponentPropsWithoutRef; + +const NavBarItemOmniChannelCallDialPad = (props: NavBarItemOmniChannelCallDialPadProps) => { + const t = useTranslation(); + + const { openDialModal } = useDialModal(); + + const { outBoundCallsAllowed, outBoundCallsEnabledForUser } = useVoipOutboundStates(); + + return ( + openDialModal()} + disabled={!outBoundCallsEnabledForUser} + aria-label={t('Open_Dialpad')} + data-tooltip={outBoundCallsAllowed ? t('New_Call') : t('New_Call_Premium_Only')} + {...props} + /> + ); +}; + +export default NavBarItemOmniChannelCallDialPad; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggle.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggle.tsx new file mode 100644 index 000000000000..ce62cb51864b --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggle.tsx @@ -0,0 +1,27 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +import { useIsCallReady, useIsCallError } from '../../contexts/CallContext'; +import NavBarItemOmnichannelCallToggleError from './NavBarItemOmnichannelCallToggleError'; +import NavBarItemOmnichannelCallToggleLoading from './NavBarItemOmnichannelCallToggleLoading'; +import NavBarItemOmnichannelCallToggleReady from './NavBarItemOmnichannelCallToggleReady'; + +type NavBarItemOmnichannelCallToggleProps = ComponentPropsWithoutRef< + typeof NavBarItemOmnichannelCallToggleError | typeof NavBarItemOmnichannelCallToggleLoading | typeof NavBarItemOmnichannelCallToggleReady +>; + +const NavBarItemOmnichannelCallToggle = (props: NavBarItemOmnichannelCallToggleProps) => { + const isCallReady = useIsCallReady(); + const isCallError = useIsCallError(); + if (isCallError) { + return ; + } + + if (!isCallReady) { + return ; + } + + return ; +}; + +export default NavBarItemOmnichannelCallToggle; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx new file mode 100644 index 000000000000..cf4e7ec240b4 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleError.tsx @@ -0,0 +1,13 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelCallToggleErrorProps = ComponentPropsWithoutRef; + +const NavBarItemOmnichannelCallToggleError = (props: NavBarItemOmnichannelCallToggleErrorProps) => { + const t = useTranslation(); + return ; +}; + +export default NavBarItemOmnichannelCallToggleError; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx new file mode 100644 index 000000000000..c4b53acefabb --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleLoading.tsx @@ -0,0 +1,13 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelCallToggleLoadingProps = ComponentPropsWithoutRef; + +const NavBarItemOmnichannelCallToggleLoading = (props: NavBarItemOmnichannelCallToggleLoadingProps) => { + const t = useTranslation(); + return ; +}; + +export default NavBarItemOmnichannelCallToggleLoading; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx new file mode 100644 index 000000000000..8b51fc6c5b57 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelCallToggleReady.tsx @@ -0,0 +1,67 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; +import React, { useCallback } from 'react'; + +import { useCallerInfo, useCallRegisterClient, useCallUnregisterClient, useVoipNetworkStatus } from '../../contexts/CallContext'; + +type NavBarItemOmnichannelCallToggleReadyProps = ComponentPropsWithoutRef; + +const NavBarItemOmnichannelCallToggleReady = (props: NavBarItemOmnichannelCallToggleReadyProps) => { + const t = useTranslation(); + + const caller = useCallerInfo(); + const unregister = useCallUnregisterClient(); + const register = useCallRegisterClient(); + + const networkStatus = useVoipNetworkStatus(); + const registered = !['ERROR', 'INITIAL', 'UNREGISTERED'].includes(caller.state); + const inCall = ['IN_CALL'].includes(caller.state); + + const onClickVoipButton = useCallback((): void => { + if (registered) { + unregister(); + return; + } + register(); + }, [registered, register, unregister]); + + const getTitle = (): string => { + if (networkStatus === 'offline') { + return t('Waiting_for_server_connection'); + } + + if (inCall) { + return t('Cannot_disable_while_on_call'); + } + + if (registered) { + return t('Turn_off_answer_calls'); + } + + return t('Turn_on_answer_calls'); + }; + + const getIcon = (): 'phone-issue' | 'phone' | 'phone-disabled' => { + if (networkStatus === 'offline') { + return 'phone-issue'; + } + return registered ? 'phone' : 'phone-disabled'; + }; + + return ( + + ); +}; + +export default NavBarItemOmnichannelCallToggleReady; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelContact.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelContact.tsx new file mode 100644 index 000000000000..99cdbd9b4a16 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelContact.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useRouter, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelContactProps = Omit, 'is'>; + +const NavBarItemOmnichannelContact = (props: NavBarItemOmnichannelContactProps) => { + const router = useRouter(); + const currentRoute = useCurrentRoutePath(); + + return ( + router.navigate('/omnichannel-directory')} + pressed={currentRoute?.includes('/omnichannel-directory')} + /> + ); +}; + +export default NavBarItemOmnichannelContact; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelLivechatToggle.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelLivechatToggle.tsx new file mode 100644 index 000000000000..5bf174362e19 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelLivechatToggle.tsx @@ -0,0 +1,37 @@ +import { Sidebar } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ComponentProps } from 'react'; +import React from 'react'; + +import { useOmnichannelAgentAvailable } from '../../hooks/omnichannel/useOmnichannelAgentAvailable'; + +type NavBarItemOmnichannelLivechatToggleProps = Omit, 'icon'>; + +const NavBarItemOmnichannelLivechatToggle = (props: NavBarItemOmnichannelLivechatToggleProps): ReactElement => { + const t = useTranslation(); + const agentAvailable = useOmnichannelAgentAvailable(); + const changeAgentStatus = useEndpoint('POST', '/v1/livechat/agent.status'); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleAvailableStatusChange = useEffectEvent(async () => { + try { + await changeAgentStatus({}); + } catch (error: unknown) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + return ( + + ); +}; + +export default NavBarItemOmnichannelLivechatToggle; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelQueue.tsx b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelQueue.tsx new file mode 100644 index 000000000000..8b1c00a2a17c --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/NavBarItemOmnichannelQueue.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useRouter, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemOmnichannelQueueProps = Omit, 'is'>; + +const NavBarItemOmnichannelQueue = (props: NavBarItemOmnichannelQueueProps) => { + const router = useRouter(); + const currentRoute = useCurrentRoutePath(); + + return ( + router.navigate('/livechat-queue')} + pressed={currentRoute?.includes('/livechat-queue')} + /> + ); +}; + +export default NavBarItemOmnichannelQueue; diff --git a/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/index.ts new file mode 100644 index 000000000000..8dacb885deb3 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarOmnichannelToolbar/index.ts @@ -0,0 +1,5 @@ +export { default as NavBarItemOmniChannelCallDialPad } from './NavBarItemOmniChannelCallDialPad'; +export { default as NavBarItemOmnichannelCallToggle } from './NavBarItemOmnichannelCallToggle'; +export { default as NavBarItemOmnichannelContact } from './NavBarItemOmnichannelContact'; +export { default as NavBarItemOmnichannelLivechatToggle } from './NavBarItemOmnichannelLivechatToggle'; +export { default as NavBarItemOmnichannelQueue } from './NavBarItemOmnichannelQueue'; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx new file mode 100644 index 000000000000..07936f6f4276 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemAuditMenu.tsx @@ -0,0 +1,29 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../components/GenericMenu/GenericMenu'; +import { useAuditMenu } from './hooks/useAuditMenu'; + +type NavBarItemAuditMenuProps = Omit, 'is'>; + +const NavBarItemAuditMenu = (props: NavBarItemAuditMenuProps) => { + const t = useTranslation(); + const sections = useAuditMenu(); + const currentRoute = useCurrentRoutePath(); + + return ( + + ); +}; + +export default NavBarItemAuditMenu; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemDirectoryPage.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemDirectoryPage.tsx new file mode 100644 index 000000000000..0cc26c6c1356 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemDirectoryPage.tsx @@ -0,0 +1,19 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRouter, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemDirectoryPageProps = Omit, 'is'>; + +const NavBarItemDirectoryPage = (props: NavBarItemDirectoryPageProps) => { + const router = useRouter(); + const handleDirectory = useEffectEvent(() => { + router.navigate('/directory'); + }); + const currentRoute = useCurrentRoutePath(); + + return ; +}; + +export default NavBarItemDirectoryPage; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemHomePage.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemHomePage.tsx new file mode 100644 index 000000000000..128a41ea97ae --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemHomePage.tsx @@ -0,0 +1,22 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRouter, useLayout, useSetting, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemHomePageProps = Omit, 'is'>; + +const NavBarItemHomePage = (props: NavBarItemHomePageProps) => { + const router = useRouter(); + const { sidebar } = useLayout(); + const showHome = useSetting('Layout_Show_Home_Button'); + const handleHome = useEffectEvent(() => { + sidebar.toggle(); + router.navigate('/home'); + }); + const currentRoute = useCurrentRoutePath(); + + return showHome ? : null; +}; + +export default NavBarItemHomePage; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx new file mode 100644 index 000000000000..4a2bbc916b57 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/NavBarItemMarketPlaceMenu.tsx @@ -0,0 +1,30 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../components/GenericMenu/GenericMenu'; +import { useMarketPlaceMenu } from './hooks/useMarketPlaceMenu'; + +type NavBarItemMarketPlaceMenuProps = Omit, 'is'>; + +const NavBarItemMarketPlaceMenu = (props: NavBarItemMarketPlaceMenuProps) => { + const t = useTranslation(); + const sections = useMarketPlaceMenu(); + + const currentRoute = useCurrentRoutePath(); + + return ( + + ); +}; + +export default NavBarItemMarketPlaceMenu; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx new file mode 100644 index 000000000000..11eddf934055 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.spec.tsx @@ -0,0 +1,135 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useAuditMenu } from './useAuditMenu'; + +it('should return an empty array of items if doesn`t have license', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error: just for testing + license: { + activeModules: [], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .withPermission('can-audit-log') + .build(), + }); + + await waitFor(() => result.all.length > 1); + + expect(result.current).toEqual([]); +}); + +it('should return an empty array of items if have license and not have permissions', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withMethod('license:getModules', () => ['auditing']) + .withJohnDoe() + .build(), + }); + + await waitFor(() => result.all.length > 1); + + expect(result.current).toEqual([]); +}); + +it('should return auditItems if have license and permissions', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .withPermission('can-audit-log') + .build(), + }); + + await waitFor(() => result.current.length > 0); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'messages', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'auditLog', + }), + ); +}); + +it('should return auditMessages item if have license and can-audit permission', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit') + .build(), + }); + + await waitFor(() => result.current.length > 0); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'messages', + }), + ); +}); + +it('should return audiLogs item if have license and can-audit-log permission', async () => { + const { result, waitFor } = renderHook(() => useAuditMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + license: { + // @ts-expect-error: just for testing + grantedModules: [{ module: 'auditing' }], + }, + // @ts-expect-error: just for testing + activeModules: ['auditing'], + }, + })) + .withJohnDoe() + .withPermission('can-audit-log') + .build(), + }); + + await waitFor(() => result.current.length > 0); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'auditLog', + }), + ); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx new file mode 100644 index 000000000000..88a2a5de31aa --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useAuditMenu.tsx @@ -0,0 +1,38 @@ +import { usePermission, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; + +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; + +export const useAuditMenu = () => { + const router = useRouter(); + const t = useTranslation(); + + const hasAuditLicense = useHasLicenseModule('auditing') === true; + + const hasAuditPermission = usePermission('can-audit') && hasAuditLicense; + const hasAuditLogPermission = usePermission('can-audit-log') && hasAuditLicense; + + if (!hasAuditPermission && !hasAuditLogPermission) { + return []; + } + + const auditMessageItem: GenericMenuItemProps = { + id: 'messages', + icon: 'document-eye', + content: t('Messages'), + onClick: () => router.navigate('/audit'), + }; + const auditLogItem: GenericMenuItemProps = { + id: 'auditLog', + icon: 'document-eye', + content: t('Logs'), + onClick: () => router.navigate('/audit-log'), + }; + + return [ + { + title: t('Audit'), + items: [hasAuditPermission && auditMessageItem, hasAuditLogPermission && auditLogItem].filter(Boolean) as GenericMenuItemProps[], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx new file mode 100644 index 000000000000..2a3d277e69fe --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.spec.tsx @@ -0,0 +1,279 @@ +import { UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useMarketPlaceMenu } from './useMarketPlaceMenu'; + +it('should return and empty array if the user does not have `manage-apps` and `access-marketplace` permission', () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => []) + .build(), + }); + + expect(result.current[0].items).toEqual([]); +}); + +it('should return `explore` and `installed` items if the user has `access-marketplace` permission', () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => []) + .withPermission('access-marketplace') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); +}); + +it('should return `explore`, `installed` and `requested` items if the user has `manage-apps` permission', () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => []) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + expect(result.current[0].items[2]).toEqual( + expect.objectContaining({ + id: 'requested-apps', + }), + ); +}); + +it('should return one action from the server with no conditions', async () => { + const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + await waitForValueToChange(() => result.current[0].items[3]); + + expect(result.current[0].items[3]).toEqual( + expect.objectContaining({ + id: 'APP_ID_ACTION_ID', + }), + ); +}); + +describe('Marketplace menu with role conditions', () => { + it('should return the action if the user has admin role', async () => { + const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: ['admin'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .withJohnDoe() + .withRole('admin') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + await waitForValueToChange(() => result.current[0].items[3]); + + expect(result.current[0].items[3]).toEqual( + expect.objectContaining({ + id: 'APP_ID_ACTION_ID', + }), + ); + }); + + it('should return filter the action if the user doesn`t have admin role', async () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOneRole: ['admin'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + expect(result.current[0].items[2]).toEqual( + expect.objectContaining({ + id: 'requested-apps', + }), + ); + + expect(result.current[0].items[3]).toEqual(undefined); + }); +}); + +describe('Marketplace menu with permission conditions', () => { + it('should return the action if the user has manage-apps permission', async () => { + const { result, waitForValueToChange } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOnePermission: ['manage-apps'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'explore', + }), + ); + + expect(result.current[0].items[1]).toEqual( + expect.objectContaining({ + id: 'installed', + }), + ); + + await waitForValueToChange(() => result.current[0].items[3]); + + expect(result.current[0].items[3]).toEqual( + expect.objectContaining({ + id: 'APP_ID_ACTION_ID', + }), + ); + }); + + it('should return filter the action if the user doesn`t have `any` permission', async () => { + const { result } = renderHook(() => useMarketPlaceMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/apps/actionButtons', () => [ + { + appId: 'APP_ID', + actionId: 'ACTION_ID', + labelI18n: 'LABEL_I18N', + context: UIActionButtonContext.USER_DROPDOWN_ACTION, + when: { + hasOnePermission: ['any'], + }, + }, + ]) + .withEndpoint('GET', '/apps/app-request/stats', () => ({ + data: { + totalSeen: 0, + totalUnseen: 1, + }, + })) + .withPermission('manage-apps') + .build(), + }); + + expect(result.current[0].items[3]).toEqual(undefined); + }); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx new file mode 100644 index 000000000000..fd704ffafe1f --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/hooks/useMarketPlaceMenu.tsx @@ -0,0 +1,65 @@ +import { Badge, Skeleton } from '@rocket.chat/fuselage'; +import { useTranslation, usePermission, useRouter } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; +import { useUserDropdownAppsActionButtons } from '../../../hooks/useAppActionButtons'; +import { useAppRequestStats } from '../../../views/marketplace/hooks/useAppRequestStats'; + +export const useMarketPlaceMenu = () => { + const t = useTranslation(); + + const appBoxItems = useUserDropdownAppsActionButtons(); + + const hasManageAppsPermission = usePermission('manage-apps'); + const hasAccessMarketplacePermission = usePermission('access-marketplace'); + + const showMarketplace = hasAccessMarketplacePermission || hasManageAppsPermission; + + const router = useRouter(); + + const appRequestStats = useAppRequestStats(); + + const marketPlaceItems: GenericMenuItemProps[] = [ + { + id: 'explore', + icon: 'compass', + content: t('Explore'), + onClick: () => router.navigate('/marketplace/explore/list'), + }, + { + id: 'installed', + icon: 'circle-arrow-down', + content: t('Installed'), + onClick: () => router.navigate('/marketplace/installed/list'), + }, + ]; + + const appsManagementItem: GenericMenuItemProps = { + id: 'requested-apps', + icon: 'cube', + content: t('Requested'), + onClick: () => { + router.navigate('/marketplace/requested/list'); + }, + addon: ( + <> + {appRequestStats.isLoading && } + {appRequestStats.isSuccess && appRequestStats.data.totalUnseen > 0 && ( + {appRequestStats.data.totalUnseen} + )} + + ), + }; + + return [ + { + title: t('Marketplace'), + items: [ + ...(showMarketplace ? marketPlaceItems : []), + ...(hasManageAppsPermission ? [appsManagementItem] : []), + ...(appBoxItems.isSuccess ? appBoxItems.data : []), + ], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarPagesToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/index.ts new file mode 100644 index 000000000000..2b334cab4b2d --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarPagesToolbar/index.ts @@ -0,0 +1,4 @@ +export { default as NavBarItemAuditMenu } from './NavBarItemAuditMenu'; +export { default as NavBarItemHomePage } from './NavBarItemHomePage'; +export { default as NavBarItemMarketPlaceMenu } from './NavBarItemMarketPlaceMenu'; +export { default as NavBarItemDirectoryPage } from './NavBarItemDirectoryPage'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx new file mode 100644 index 000000000000..045b36425512 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemAdministrationMenu.tsx @@ -0,0 +1,33 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useCurrentRoutePath, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../components/GenericMenu/GenericMenu'; +import { useAdministrationMenu } from './hooks/useAdministrationMenu'; + +type NavBarItemAdministrationMenuProps = Omit, 'is'>; + +const NavBarItemAdministrationMenu = (props: NavBarItemAdministrationMenuProps) => { + const t = useTranslation(); + const currentRoute = useCurrentRoutePath(); + + const sections = useAdministrationMenu(); + + if (!sections[0].items.length) { + return null; + } + return ( + + ); +}; + +export default NavBarItemAdministrationMenu; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemLoginPage.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemLoginPage.tsx new file mode 100644 index 000000000000..a02c17db0b9b --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/NavBarItemLoginPage.tsx @@ -0,0 +1,19 @@ +import { Button } from '@rocket.chat/fuselage'; +import { useSessionDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +type NavBarItemLoginPageProps = Omit, 'is'>; + +const NavBarItemLoginPage = (props: NavBarItemLoginPageProps) => { + const setForceLogin = useSessionDispatch('forceLogin'); + const t = useTranslation(); + + return ( + + ); +}; + +export default NavBarItemLoginPage; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx new file mode 100644 index 000000000000..f4dce69af876 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -0,0 +1,106 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Field, TextInput, FieldGroup, Modal, Button, Box, FieldLabel, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; +import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ChangeEvent, ComponentProps, FormEvent } from 'react'; +import React, { useState, useCallback } from 'react'; + +import UserStatusMenu from '../../../components/UserStatusMenu'; +import { USER_STATUS_TEXT_MAX_LENGTH } from '../../../lib/constants'; + +type EditStatusModalProps = { + onClose: () => void; + userStatus: IUser['status']; + userStatusText: IUser['statusText']; +}; + +const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModalProps): ReactElement => { + const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange'); + const dispatchToastMessage = useToastMessageDispatch(); + const [customStatus, setCustomStatus] = useLocalStorage('Local_Custom_Status', ''); + const initialStatusText = customStatus || userStatusText; + + const t = useTranslation(); + const [statusText, setStatusText] = useState(initialStatusText); + const [statusType, setStatusType] = useState(userStatus); + const [statusTextError, setStatusTextError] = useState(); + + const setUserStatus = useEndpoint('POST', '/v1/users.setStatus'); + + const handleStatusText = useEffectEvent((e: ChangeEvent): void => { + setStatusText(e.currentTarget.value); + + if (statusText && statusText.length > USER_STATUS_TEXT_MAX_LENGTH) { + return setStatusTextError(t('Max_length_is', USER_STATUS_TEXT_MAX_LENGTH)); + } + + return setStatusTextError(undefined); + }); + + const handleStatusType = (type: IUser['status']): void => setStatusType(type); + + const handleSaveStatus = useCallback(async () => { + try { + await setUserStatus({ message: statusText, status: statusType }); + setCustomStatus(statusText); + dispatchToastMessage({ type: 'success', message: t('StatusMessage_Changed_Successfully') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + + onClose(); + }, [dispatchToastMessage, setUserStatus, statusText, statusType, onClose, t]); + + return ( + ) => ( + { + e.preventDefault(); + handleSaveStatus(); + }} + {...props} + /> + )} + > + + + {t('Edit_Status')} + + + + + + {t('StatusMessage')} + + } + /> + + {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} + {statusTextError} + + + + + + + + + + + ); +}; + +export default EditStatusModal; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx new file mode 100644 index 000000000000..fd4498f5fb8e --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx @@ -0,0 +1,37 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, useState } from 'react'; + +import GenericMenu from '../../../components/GenericMenu/GenericMenu'; +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; +import { useHandleMenuAction } from '../../../components/GenericMenu/hooks/useHandleMenuAction'; +import UserMenuButton from './UserMenuButton'; +import { useUserMenu } from './hooks/useUserMenu'; + +type UserMenuProps = { user: IUser; className?: string }; + +const UserMenu = function UserMenu({ user }: UserMenuProps) { + const t = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + + const sections = useUserMenu(user); + const items = sections.reduce((acc, { items }) => [...acc, ...items], [] as GenericMenuItemProps[]); + + const handleAction = useHandleMenuAction(items, () => setIsOpen(false)); + + return ( + + ); +}; + +export default memo(UserMenu); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuButton.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuButton.tsx new file mode 100644 index 000000000000..9120678c7581 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuButton.tsx @@ -0,0 +1,59 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useSetting, useUser } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef, ForwardedRef } from 'react'; +import React, { forwardRef } from 'react'; + +import { UserStatus } from '../../../components/UserStatus'; + +const anon = { + _id: '', + username: 'Anonymous', + status: 'online', + avatarETag: undefined, +} as const; + +type UserMenuButtonProps = ComponentPropsWithoutRef; + +const UserMenuButton = forwardRef(function UserMenuButton(props: UserMenuButtonProps, ref: ForwardedRef) { + const user = useUser(); + + const { status = !user ? 'online' : 'offline', username, avatarETag } = user || anon; + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + + return ( + : 'user'} + > + + + + + ); +}); + +export default UserMenuButton; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuHeader.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuHeader.tsx new file mode 100644 index 000000000000..974af6be8ed8 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenuHeader.tsx @@ -0,0 +1,45 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Box, Margins } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import MarkdownText from '../../../components/MarkdownText'; +import { UserStatus } from '../../../components/UserStatus'; +import { useUserDisplayName } from '../../../hooks/useUserDisplayName'; + +type UserMenuHeaderProps = { user: IUser }; + +const UserMenuHeader = ({ user }: UserMenuHeaderProps) => { + const t = useTranslation(); + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + const displayName = useUserDisplayName(user); + + return ( + + + + + + + + + + {displayName} + + + + + + + + + ); +}; + +export default UserMenuHeader; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx new file mode 100644 index 000000000000..bf1b7e55f244 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useAccountItems.tsx @@ -0,0 +1,63 @@ +import { Badge } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { defaultFeaturesPreview, useFeaturePreviewList } from '@rocket.chat/ui-client'; +import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; + +export const useAccountItems = (): GenericMenuItemProps[] => { + const t = useTranslation(); + const router = useRouter(); + + const { unseenFeatures, featurePreviewEnabled } = useFeaturePreviewList(); + + const handleMyAccount = useEffectEvent(() => { + router.navigate('/account'); + }); + const handlePreferences = useEffectEvent(() => { + router.navigate('/account/preferences'); + }); + const handleFeaturePreview = useEffectEvent(() => { + router.navigate('/account/feature-preview'); + }); + const handleAccessibility = useEffectEvent(() => { + router.navigate('/account/accessibility-and-appearance'); + }); + + const featurePreviewItem = { + id: 'feature-preview', + icon: 'flask' as const, + content: t('Feature_preview'), + onClick: handleFeaturePreview, + ...(unseenFeatures > 0 && { + addon: ( + + {unseenFeatures} + + ), + }), + }; + + return [ + { + id: 'profile', + icon: 'user', + content: t('Profile'), + onClick: handleMyAccount, + }, + { + id: 'preferences', + icon: 'customize', + content: t('Preferences'), + onClick: handlePreferences, + }, + { + id: 'accessibility', + icon: 'person-arms-spread', + content: t('Accessibility_and_Appearance'), + onClick: handleAccessibility, + }, + ...(featurePreviewEnabled && defaultFeaturesPreview.length > 0 ? [featurePreviewItem] : []), + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useCustomStatusModalHandler.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useCustomStatusModalHandler.tsx new file mode 100644 index 000000000000..f0f863f8efab --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useCustomStatusModalHandler.tsx @@ -0,0 +1,14 @@ +import { useSetModal, useUser } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import EditStatusModal from '../EditStatusModal'; + +export const useCustomStatusModalHandler = () => { + const user = useUser(); + const setModal = useSetModal(); + + return () => { + const handleModalClose = () => setModal(null); + setModal(); + }; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx new file mode 100644 index 000000000000..2957d22c5e32 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -0,0 +1,87 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { callbacks } from '../../../../../lib/callbacks'; +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import MarkdownText from '../../../../components/MarkdownText'; +import { UserStatus } from '../../../../components/UserStatus'; +import { userStatuses } from '../../../../lib/userStatuses'; +import type { UserStatusDescriptor } from '../../../../lib/userStatuses'; +import { useStatusDisabledModal } from '../../../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; +import { useCustomStatusModalHandler } from './useCustomStatusModalHandler'; + +export const useStatusItems = (): GenericMenuItemProps[] => { + // We should lift this up to somewhere else if we want to use it in other places + + userStatuses.invisibleAllowed = useSetting('Accounts_AllowInvisibleStatusOption', true); + + const queryClient = useQueryClient(); + + useEffect( + () => + userStatuses.watch(() => { + queryClient.setQueryData(['user-statuses'], Array.from(userStatuses)); + }), + [queryClient], + ); + + const { t } = useTranslation(); + + const setStatus = useEndpoint('POST', '/v1/users.setStatus'); + const setStatusMutation = useMutation({ + mutationFn: async (status: UserStatusDescriptor) => { + void setStatus({ status: status.statusType, message: userStatuses.isValidType(status.id) ? '' : status.name }); + void callbacks.run('userStatusManuallySet', status); + }, + }); + + const presenceDisabled = useSetting('Presence_broadcast_disabled', false); + + const { data: statuses } = useQuery({ + queryKey: ['user-statuses'], + queryFn: async () => { + await userStatuses.sync(); + return Array.from(userStatuses); + }, + staleTime: Infinity, + select: (statuses) => + statuses.map((status): GenericMenuItemProps => { + const content = status.localizeName ? t(status.name) : status.name; + return { + id: status.id, + status: , + content: , + disabled: presenceDisabled, + onClick: () => setStatusMutation.mutate(status), + }; + }), + }); + + const handleStatusDisabledModal = useStatusDisabledModal(); + const handleCustomStatus = useCustomStatusModalHandler(); + + return [ + ...(presenceDisabled + ? [ + { + id: 'presence-disabled', + content: ( + + + {t('User_status_disabled')} + + + {t('Learn_more')} + + + ), + }, + ] + : []), + ...(statuses ?? []), + { id: 'custom-status', icon: 'emoji', content: t('Custom_Status'), onClick: handleCustomStatus, disabled: presenceDisabled }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx new file mode 100644 index 000000000000..a969c853d797 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx @@ -0,0 +1,46 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useLogout, useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import UserMenuHeader from '../UserMenuHeader'; +import { useAccountItems } from './useAccountItems'; +import { useStatusItems } from './useStatusItems'; + +export const useUserMenu = (user: IUser) => { + const t = useTranslation(); + + const statusItems = useStatusItems(); + const accountItems = useAccountItems(); + + const logout = useLogout(); + const handleLogout = useEffectEvent(() => { + logout(); + }); + + const logoutItem: GenericMenuItemProps = { + id: 'logout', + icon: 'sign-out', + content: t('Logout'), + onClick: handleLogout, + }; + + return [ + { + title: , + items: [], + }, + { + title: t('Status'), + items: statusItems, + }, + { + title: t('Account'), + items: accountItems, + }, + { + items: [logoutItem], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/index.ts b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/index.ts new file mode 100644 index 000000000000..63aab39921d7 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/index.ts @@ -0,0 +1 @@ +export { default } from './UserMenu'; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx new file mode 100644 index 000000000000..1315d1053392 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.spec.tsx @@ -0,0 +1,54 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useAdministrationMenu } from './useAdministrationMenu'; + +it('should return omnichannel item if has `view-livechat-manager` permission ', async () => { + const { result, waitFor } = renderHook(() => useAdministrationMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, + })) + .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ + registrationStatus: { + workspaceRegistered: false, + } as any, + })) + .withPermission('view-livechat-manager') + .build(), + }); + + await waitFor(() => !!result.current.length); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'omnichannel', + }), + ); +}); + +it('should show administration item if has at least one admin permission', async () => { + const { result, waitFor } = renderHook(() => useAdministrationMenu(), { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, + })) + .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ + registrationStatus: { + workspaceRegistered: false, + } as any, + })) + .withPermission('access-permissions') + .build(), + }); + + await waitFor(() => !!result.current.length); + + expect(result.current[0].items[0]).toEqual( + expect.objectContaining({ + id: 'workspace', + }), + ); +}); diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx new file mode 100644 index 000000000000..54d4818128ea --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/hooks/useAdministrationMenu.tsx @@ -0,0 +1,57 @@ +import { useAtLeastOnePermission, usePermission, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; + +import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; + +const ADMIN_PERMISSIONS = [ + 'view-statistics', + 'run-import', + 'view-user-administration', + 'view-room-administration', + 'create-invite-links', + 'manage-cloud', + 'view-logs', + 'manage-sounds', + 'view-federation-data', + 'manage-email-inbox', + 'manage-emoji', + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + 'manage-oauth-apps', + 'access-mailer', + 'manage-user-status', + 'access-permissions', + 'access-setting-permissions', + 'view-privileged-setting', + 'edit-privileged-setting', + 'manage-selected-settings', + 'view-engagement-dashboard', + 'view-moderation-console', +]; + +export const useAdministrationMenu = () => { + const router = useRouter(); + const t = useTranslation(); + + const isAdmin = useAtLeastOnePermission(ADMIN_PERMISSIONS); + const isOmnichannel = usePermission('view-livechat-manager'); + + const workspace: GenericMenuItemProps = { + id: 'workspace', + content: t('Workspace'), + onClick: () => router.navigate('/admin'), + }; + const omnichannel: GenericMenuItemProps = { + id: 'omnichannel', + content: t('Omnichannel'), + onClick: () => router.navigate('/omnichannel/current'), + }; + + return [ + { + title: t('Manage'), + items: [isAdmin && workspace, isOmnichannel && omnichannel].filter(Boolean) as GenericMenuItemProps[], + }, + ]; +}; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/index.ts new file mode 100644 index 000000000000..9bc514a8088a --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/index.ts @@ -0,0 +1,3 @@ +export { default as NavBarItemAdministrationMenu } from './NavBarItemAdministrationMenu'; +export { default as NavBarItemLoginPage } from './NavBarItemLoginPage'; +export { default as UserMenu } from './UserMenu'; diff --git a/apps/meteor/client/NavBarV2/index.ts b/apps/meteor/client/NavBarV2/index.ts new file mode 100644 index 000000000000..902ee590de66 --- /dev/null +++ b/apps/meteor/client/NavBarV2/index.ts @@ -0,0 +1 @@ +export { default } from './NavBar'; diff --git a/apps/meteor/client/components/Contextualbar/Contextualbar.tsx b/apps/meteor/client/components/Contextualbar/Contextualbar.tsx new file mode 100644 index 000000000000..481537d23f3e --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/Contextualbar.tsx @@ -0,0 +1,19 @@ +import { ContextualbarV2, Contextualbar as ContextualbarComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const Contextualbar = forwardRef>(function Contextualbar(props, ref) { + return ( + + + + + + + + + ); +}); + +export default memo(Contextualbar); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarAction.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarAction.tsx new file mode 100644 index 000000000000..567bd4e276e1 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarAction.tsx @@ -0,0 +1,17 @@ +import { ContextualbarAction as ContextualbarActionComponent, ContextualbarV2Action } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarAction = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarAction); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarActions.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarActions.tsx new file mode 100644 index 000000000000..869030ddb479 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarActions.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Actions, ContextualbarActions as ContextualbarActionsComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarActions = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarActions); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx index c2ae717eda33..c8e17ab88d80 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarBack.tsx @@ -1,8 +1,9 @@ -import { ContextualbarAction } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ComponentProps } from 'react'; import React, { memo } from 'react'; +import ContextualbarAction from './ContextualbarAction'; + type ContextualbarBackProps = Partial>; const ContextualbarBack = (props: ContextualbarBackProps): ReactElement => { diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarButton.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarButton.tsx new file mode 100644 index 000000000000..ab2ab878503e --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarButton.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Button, ContextualbarButton as ContextualbarButtonComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarButton = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarButton); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx index 6c0fbd5c8ebe..1670c9be5895 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarClose.tsx @@ -1,8 +1,9 @@ -import { ContextualbarAction } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo } from 'react'; +import ContextualbarAction from './ContextualbarAction'; + type ContextualbarCloseProps = Partial>; const ContextualbarClose = (props: ContextualbarCloseProps): ReactElement => { diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarContent.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarContent.tsx new file mode 100644 index 000000000000..10d3d74b673b --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarContent.tsx @@ -0,0 +1,22 @@ +import { ContextualbarV2Content, ContextualbarContent as ContextualbarContentComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarContent = forwardRef>(function ContextualbarContent( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(ContextualbarContent); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx index c3421f3fc9d3..23def16a94a1 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarDialog.tsx @@ -1,4 +1,3 @@ -import { Contextualbar } from '@rocket.chat/fuselage'; import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useLayoutSizes, useLayoutContextualBarPosition } from '@rocket.chat/ui-contexts'; import type { ComponentProps, KeyboardEvent } from 'react'; @@ -7,6 +6,7 @@ import type { AriaDialogProps } from 'react-aria'; import { FocusScope, useDialog } from 'react-aria'; import { useRoomToolbox } from '../../views/room/contexts/RoomToolboxContext'; +import Contextualbar from './Contextualbar'; import ContextualbarResizable from './ContextualbarResizable'; type ContextualbarDialogProps = AriaDialogProps & ComponentProps; diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarEmptyContent.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarEmptyContent.tsx new file mode 100644 index 000000000000..be3b3aca7c53 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarEmptyContent.tsx @@ -0,0 +1,21 @@ +import { ContextualbarV2EmptyContent, ContextualbarEmptyContent as ContextualbarEmptyContentComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarEmptyContent = forwardRef>( + function ContextualbarEmptyContent(props, ref) { + return ( + + + + + + + + + ); + }, +); + +export default memo(ContextualbarEmptyContent); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarFooter.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarFooter.tsx new file mode 100644 index 000000000000..481823a1a13f --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarFooter.tsx @@ -0,0 +1,22 @@ +import { ContextualbarV2Footer, ContextualbarFooter as ContextualbarFooterComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarFooter = forwardRef>(function ContextualbarFooter( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(ContextualbarFooter); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx index 9486ffb1432b..795182df8465 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarHeader.tsx @@ -1,5 +1,6 @@ -import { ContextualbarHeader as ContextualbarHeaderComponent } from '@rocket.chat/fuselage'; -import type { ReactNode, ComponentPropsWithoutRef } from 'react'; +import { ContextualbarV2Header, ContextualbarHeader as ContextualbarHeaderComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; import React, { memo } from 'react'; type ContextualbarHeaderProps = { @@ -7,10 +8,15 @@ type ContextualbarHeaderProps = { children: ReactNode; } & ComponentPropsWithoutRef; -const ContextualbarHeader = ({ children, expanded, ...props }: ContextualbarHeaderProps) => ( - - {children} - +const ContextualbarHeader = (props: ContextualbarHeaderProps) => ( + + + + + + + + ); export default memo(ContextualbarHeader); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarIcon.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarIcon.tsx new file mode 100644 index 000000000000..5f6062fe351a --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarIcon.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Icon, ContextualbarIcon as ContextualbarIconComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarIcon = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarIcon); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarSection.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarSection.tsx new file mode 100644 index 000000000000..53ee192f5416 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarSection.tsx @@ -0,0 +1,22 @@ +import { ContextualbarV2Section, ContextualbarSection as ContextualbarSectionComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const ContextualbarSection = forwardRef>(function ContextualbarSection( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(ContextualbarSection); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarSkeleton.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarSkeleton.tsx new file mode 100644 index 000000000000..92b74451b450 --- /dev/null +++ b/apps/meteor/client/components/Contextualbar/ContextualbarSkeleton.tsx @@ -0,0 +1,17 @@ +import { ContextualbarV2Skeleton, ContextualbarSkeleton as ContextualbarSkeletonComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const ContextualbarSkeleton = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(ContextualbarSkeleton); diff --git a/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx b/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx index 506be155ce18..bffcc5669ce4 100644 --- a/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx +++ b/apps/meteor/client/components/Contextualbar/ContextualbarTitle.tsx @@ -1,9 +1,17 @@ -import { ContextualbarTitle as ContextualbarTitleComponent } from '@rocket.chat/fuselage'; +import { ContextualbarV2Title, ContextualbarTitle as ContextualbarTitleComponent } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import type { ComponentProps } from 'react'; import React from 'react'; const ContextualbarTitle = (props: ComponentProps) => ( - + + + + + + + + ); export default ContextualbarTitle; diff --git a/apps/meteor/client/components/Contextualbar/index.ts b/apps/meteor/client/components/Contextualbar/index.ts index c370b7f790fc..c8602186e09a 100644 --- a/apps/meteor/client/components/Contextualbar/index.ts +++ b/apps/meteor/client/components/Contextualbar/index.ts @@ -1,20 +1,19 @@ -import { - Contextualbar, - ContextualbarAction, - ContextualbarActions, - ContextualbarContent, - ContextualbarSkeleton, - ContextualbarIcon, - ContextualbarFooter, - ContextualbarEmptyContent, -} from '@rocket.chat/fuselage'; - +import Contextualbar from './Contextualbar'; +import ContextualbarAction from './ContextualbarAction'; +import ContextualbarActions from './ContextualbarActions'; import ContextualbarBack from './ContextualbarBack'; +import ContextualbarButton from './ContextualbarButton'; import ContextualbarClose from './ContextualbarClose'; +import ContextualbarContent from './ContextualbarContent'; import ContextualbarDialog from './ContextualbarDialog'; +import ContextualbarEmptyContent from './ContextualbarEmptyContent'; +import ContextualbarFooter from './ContextualbarFooter'; import ContextualbarHeader from './ContextualbarHeader'; +import ContextualbarIcon from './ContextualbarIcon'; import ContextualbarInnerContent from './ContextualbarInnerContent'; import ContextualbarScrollableContent from './ContextualbarScrollableContent'; +import ContextualbarSection from './ContextualbarSection'; +import ContextualbarSkeleton from './ContextualbarSkeleton'; import ContextualbarTitle from './ContextualbarTitle'; export { @@ -24,6 +23,7 @@ export { ContextualbarAction, ContextualbarActions, ContextualbarBack, + ContextualbarButton, ContextualbarClose, ContextualbarContent, ContextualbarSkeleton, @@ -33,4 +33,5 @@ export { ContextualbarEmptyContent, ContextualbarScrollableContent, ContextualbarInnerContent, + ContextualbarSection, }; diff --git a/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx b/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx index b07083be1a03..06080ede2510 100644 --- a/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx +++ b/apps/meteor/client/components/CustomScrollbars/VirtuosoScrollbars.tsx @@ -10,7 +10,7 @@ const VirtuosoScrollbars = forwardRef(function VirtuosoScrollbars( ref: Ref, ) { return ( -
}> +
}> {children} ); diff --git a/apps/meteor/client/components/Header/Header.tsx b/apps/meteor/client/components/Header/Header.tsx new file mode 100644 index 000000000000..53c7f085889c --- /dev/null +++ b/apps/meteor/client/components/Header/Header.tsx @@ -0,0 +1,16 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn, HeaderV2, Header as HeaderComponent } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const Header = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(Header); diff --git a/apps/meteor/client/components/Header/HeaderAvatar.tsx b/apps/meteor/client/components/Header/HeaderAvatar.tsx new file mode 100644 index 000000000000..0c1c3665f823 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderAvatar.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Avatar, + HeaderAvatar as HeaderAvatarComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderAvatar = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderAvatar); diff --git a/apps/meteor/client/components/Header/HeaderContent.tsx b/apps/meteor/client/components/Header/HeaderContent.tsx new file mode 100644 index 000000000000..622c85bf6bae --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderContent.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Content, + HeaderContent as HeaderContentComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderContent = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderContent); diff --git a/apps/meteor/client/components/Header/HeaderContentRow.tsx b/apps/meteor/client/components/Header/HeaderContentRow.tsx new file mode 100644 index 000000000000..4ab684ce23a0 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderContentRow.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ContentRow, + HeaderContentRow as HeaderContentRowComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderContentRow = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderContentRow); diff --git a/apps/meteor/client/components/Header/HeaderDivider.tsx b/apps/meteor/client/components/Header/HeaderDivider.tsx new file mode 100644 index 000000000000..22861846852f --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderDivider.tsx @@ -0,0 +1,21 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Divider, + HeaderDivider as HeaderDividerComponent, +} from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; + +const HeaderDivider = () => ( + + + + + + + + +); + +export default memo(HeaderDivider); diff --git a/apps/meteor/client/components/Header/HeaderIcon.tsx b/apps/meteor/client/components/Header/HeaderIcon.tsx new file mode 100644 index 000000000000..abcdba673fb0 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderIcon.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Icon, + HeaderIcon as HeaderIconComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderIcon = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderIcon); diff --git a/apps/meteor/client/components/Header/HeaderState.tsx b/apps/meteor/client/components/Header/HeaderState.tsx new file mode 100644 index 000000000000..fee88b64b4e7 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderState.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2State, + HeaderState as HeaderStateComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderState = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderState); diff --git a/apps/meteor/client/components/Header/HeaderSubtitle.tsx b/apps/meteor/client/components/Header/HeaderSubtitle.tsx new file mode 100644 index 000000000000..f23db95f3ee1 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderSubtitle.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Subtitle, + HeaderSubtitle as HeaderSubtitleComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderSubtitle = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderSubtitle); diff --git a/apps/meteor/client/components/Header/HeaderTag.tsx b/apps/meteor/client/components/Header/HeaderTag.tsx new file mode 100644 index 000000000000..ae3332f2246a --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTag.tsx @@ -0,0 +1,16 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn, HeaderV2Tag, HeaderTag as HeaderTagComponent } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTag = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTag); diff --git a/apps/meteor/client/components/Header/HeaderTagIcon.tsx b/apps/meteor/client/components/Header/HeaderTagIcon.tsx new file mode 100644 index 000000000000..c0fe4d086eca --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTagIcon.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2TagIcon, + HeaderTagIcon as HeaderTagIconComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTagIcon = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTagIcon); diff --git a/apps/meteor/client/components/Header/HeaderTagSkeleton.tsx b/apps/meteor/client/components/Header/HeaderTagSkeleton.tsx new file mode 100644 index 000000000000..40d4dfbf59e8 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTagSkeleton.tsx @@ -0,0 +1,21 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2TagSkeleton, + HeaderTagSkeleton as HeaderTagSkeletonComponent, +} from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; + +const HeaderTagSkeleton = () => ( + + + + + + + + +); + +export default memo(HeaderTagSkeleton); diff --git a/apps/meteor/client/components/Header/HeaderTitle.tsx b/apps/meteor/client/components/Header/HeaderTitle.tsx new file mode 100644 index 000000000000..f5f2944781b5 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTitle.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Title, + HeaderTitle as HeaderTitleComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTitle = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTitle); diff --git a/apps/meteor/client/components/Header/HeaderTitleButton.tsx b/apps/meteor/client/components/Header/HeaderTitleButton.tsx new file mode 100644 index 000000000000..099bfb13fdd3 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderTitleButton.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2TitleButton, + HeaderTitleButton as HeaderTitleButtonComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderTitleButton = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderTitleButton); diff --git a/apps/meteor/client/components/Header/HeaderToolbar.tsx b/apps/meteor/client/components/Header/HeaderToolbar.tsx new file mode 100644 index 000000000000..f0eccfda0401 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbar.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2Toolbar, + HeaderToolbar as HeaderToolbarComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderToolbar = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderToolbar); diff --git a/apps/meteor/client/components/Header/HeaderToolbarAction.tsx b/apps/meteor/client/components/Header/HeaderToolbarAction.tsx new file mode 100644 index 000000000000..bbf296ff23e1 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbarAction.tsx @@ -0,0 +1,27 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ToolbarAction, + HeaderToolbarAction as HeaderToolbarActionComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { forwardRef, memo } from 'react'; + +const HeaderToolbarAction = forwardRef>(function HeaderToolbarAction( + props, + ref, +) { + return ( + + + + + + + + + ); +}); + +export default memo(HeaderToolbarAction); diff --git a/apps/meteor/client/components/Header/HeaderToolbarActionBadge.tsx b/apps/meteor/client/components/Header/HeaderToolbarActionBadge.tsx new file mode 100644 index 000000000000..67aae03729f9 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbarActionBadge.tsx @@ -0,0 +1,22 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ToolbarActionBadge, + HeaderToolbarActionBadge as HeaderToolbarActionBadgeComponent, +} from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +const HeaderToolbarActionBadge = (props: ComponentProps) => ( + + + + + + + + +); + +export default memo(HeaderToolbarActionBadge); diff --git a/apps/meteor/client/components/Header/HeaderToolbarDivider.tsx b/apps/meteor/client/components/Header/HeaderToolbarDivider.tsx new file mode 100644 index 000000000000..5986671ec836 --- /dev/null +++ b/apps/meteor/client/components/Header/HeaderToolbarDivider.tsx @@ -0,0 +1,21 @@ +import { + FeaturePreview, + FeaturePreviewOff, + FeaturePreviewOn, + HeaderV2ToolbarDivider, + HeaderToolbarDivider as HeaderToolbarDividerComponent, +} from '@rocket.chat/ui-client'; +import React, { memo } from 'react'; + +const HeaderToolbarDivider = () => ( + + + + + + + + +); + +export default memo(HeaderToolbarDivider); diff --git a/apps/meteor/client/components/Header/index.ts b/apps/meteor/client/components/Header/index.ts new file mode 100644 index 000000000000..be01ea638c98 --- /dev/null +++ b/apps/meteor/client/components/Header/index.ts @@ -0,0 +1,37 @@ +import Header from './Header'; +import HeaderAvatar from './HeaderAvatar'; +import HeaderContent from './HeaderContent'; +import HeaderContentRow from './HeaderContentRow'; +import HeaderDivider from './HeaderDivider'; +import HeaderIcon from './HeaderIcon'; +import HeaderState from './HeaderState'; +import HeaderSubtitle from './HeaderSubtitle'; +import HeaderTag from './HeaderTag'; +import HeaderTagIcon from './HeaderTagIcon'; +import HeaderTagSkeleton from './HeaderTagSkeleton'; +import HeaderTitle from './HeaderTitle'; +import HeaderTitleButton from './HeaderTitleButton'; +import HeaderToolbar from './HeaderToolbar'; +import HeaderToolbarAction from './HeaderToolbarAction'; +import HeaderToolbarActionBadge from './HeaderToolbarActionBadge'; +import HeaderToolbarDivider from './HeaderToolbarDivider'; + +export { + Header, + HeaderAvatar, + HeaderContent, + HeaderContentRow, + HeaderDivider, + HeaderIcon, + HeaderState, + HeaderSubtitle, + HeaderTag, + HeaderTagIcon, + HeaderTagSkeleton, + HeaderTitle, + HeaderTitleButton, + HeaderToolbar, + HeaderToolbarAction, + HeaderToolbarActionBadge, + HeaderToolbarDivider, +}; diff --git a/apps/meteor/client/components/Page/PageHeader.tsx b/apps/meteor/client/components/Page/PageHeader.tsx index 84d5778a37ec..c6667e4fc5cc 100644 --- a/apps/meteor/client/components/Page/PageHeader.tsx +++ b/apps/meteor/client/components/Page/PageHeader.tsx @@ -1,9 +1,10 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; -import { HeaderToolbar, useDocumentTitle } from '@rocket.chat/ui-client'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentPropsWithoutRef, ReactNode } from 'react'; import React, { useContext } from 'react'; +import { HeaderToolbar } from '../Header'; import SidebarToggler from '../SidebarToggler'; import PageContext from './PageContext'; diff --git a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx index 92cc93e339fd..9b5cafe99833 100644 --- a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx @@ -1,9 +1,9 @@ import type { BadgeProps } from '@rocket.chat/fuselage'; -import { HeaderToolbarAction, HeaderToolbarActionBadge } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; import React, { lazy, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { HeaderToolbarAction, HeaderToolbarActionBadge } from '../../components/Header'; import { useRoomSubscription } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; diff --git a/apps/meteor/client/sidebar/header/Header.tsx b/apps/meteor/client/sidebar/header/Header.tsx index b7f00af460ae..b11a103006d2 100644 --- a/apps/meteor/client/sidebar/header/Header.tsx +++ b/apps/meteor/client/sidebar/header/Header.tsx @@ -1,4 +1,5 @@ -import { Sidebar } from '@rocket.chat/fuselage'; +import { Sidebar, SidebarDivider, SidebarSection } from '@rocket.chat/fuselage'; +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo } from 'react'; @@ -25,22 +26,45 @@ const Header = (): ReactElement => { const user = useUser(); return ( - - {user ? : } - - - - {user && ( - <> - - - - - - )} - {!user && } - - + + + + {user ? : } + + + + {user && ( + <> + + + + + + )} + {!user && } + + + + + + {user ? : } + + + + {user && ( + <> + + + + + + )} + {!user && } + + + + + ); }; diff --git a/apps/meteor/client/sidebar/search/SearchList.tsx b/apps/meteor/client/sidebar/search/SearchList.tsx index d215a77ce4bd..dd97678f638a 100644 --- a/apps/meteor/client/sidebar/search/SearchList.tsx +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -338,15 +338,17 @@ const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, role='search' > - } - /> + + } + /> + { <> {isWorkspaceOverMacLimit && } - - {t('Omnichannel')} - - {showOmnichannelQueueLink && ( - handleRoute('queue')} /> - )} - {isCallEnabled && } - - {hasPermissionToSeeContactCenter && ( - handleRoute('directory')} - /> - )} - {isCallReady && } - - + + + + {t('Omnichannel')} + + {showOmnichannelQueueLink && ( + handleRoute('queue')} /> + )} + {isCallEnabled && } + + {hasPermissionToSeeContactCenter && ( + handleRoute('directory')} + /> + )} + {isCallReady && } + + + + + + {t('Omnichannel')} + + {showOmnichannelQueueLink && ( + handleRoute('queue')} /> + )} + {isCallEnabled && } + + {hasPermissionToSeeContactCenter && ( + handleRoute('directory')} + /> + )} + {isCallReady && } + + + + + ); }; diff --git a/apps/meteor/client/sidebarv2/Item/Condensed.stories.tsx b/apps/meteor/client/sidebarv2/Item/Condensed.stories.tsx new file mode 100644 index 000000000000..f63893a30a81 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Condensed.stories.tsx @@ -0,0 +1,73 @@ +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import * as Status from '../../components/UserStatus'; +import Condensed from './Condensed'; + +export default { + title: 'Sidebar/Condensed', + component: Condensed, + args: { + clickable: true, + title: 'John Doe', + }, + decorators: [ + (fn) => ( + + {fn()} + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + + } + avatar={} + /> +); + +export const Normal = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const Menu = Template.bind({}); +Menu.args = { + menuOptions: { + hide: { + label: { label: 'Hide', icon: 'eye-off' }, + action: action('action'), + }, + read: { + label: { label: 'Mark_read', icon: 'flag' }, + action: action('action'), + }, + favorite: { + label: { label: 'Favorite', icon: 'star' }, + action: action('action'), + }, + }, +}; + +export const Actions = Template.bind({}); +Actions.args = { + actions: ( + <> + + + + + + ), +}; diff --git a/apps/meteor/client/sidebarv2/Item/Condensed.tsx b/apps/meteor/client/sidebarv2/Item/Condensed.tsx new file mode 100644 index 000000000000..db76935d4c3f --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Condensed.tsx @@ -0,0 +1,60 @@ +import { IconButton, Sidebar } from '@rocket.chat/fuselage'; +import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactElement } from 'react'; +import React, { memo, useState } from 'react'; + +type CondensedProps = { + title: ReactElement | string; + titleIcon?: ReactElement; + avatar: ReactElement | boolean; + icon?: IconName; + actions?: ReactElement; + href?: string; + unread?: boolean; + menu?: () => ReactElement; + menuOptions?: any; + selected?: boolean; + badges?: ReactElement; + clickable?: boolean; +}; + +const Condensed = ({ icon, title = '', avatar, actions, href, unread, menu, badges, ...props }: CondensedProps) => { + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const isReduceMotionEnabled = usePrefersReducedMotion(); + + const handleMenu = useEffectEvent((e) => { + setMenuVisibility(e.target.offsetWidth > 0 && Boolean(menu)); + }); + const handleMenuEvent = { + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + }; + + return ( + + {avatar && {avatar}} + + + {icon} + + {title} + + + {badges && {badges}} + {menu && ( + + {menuVisibility ? menu() : } + + )} + + {actions && ( + + {actions} + + )} + + ); +}; + +export default memo(Condensed); diff --git a/apps/meteor/client/sidebarv2/Item/Extended.stories.tsx b/apps/meteor/client/sidebarv2/Item/Extended.stories.tsx new file mode 100644 index 000000000000..a6392eae5d61 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Extended.stories.tsx @@ -0,0 +1,98 @@ +import { Box, IconButton, Badge } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import * as Status from '../../components/UserStatus'; +import Extended from './Extended'; + +export default { + title: 'Sidebar/Extended', + component: Extended, + args: { + clickable: true, + }, + decorators: [ + (fn) => ( + + {fn()} + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + John Doe + + 15:38 + + } + subtitle={ + + + John Doe: test 123 + + + 99 + + + } + titleIcon={ + + + + } + avatar={} + /> +); + +export const Normal = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const Menu = Template.bind({}); +Menu.args = { + menuOptions: { + hide: { + label: { label: 'Hide', icon: 'eye-off' }, + action: action('action'), + }, + read: { + label: { label: 'Mark_read', icon: 'flag' }, + action: action('action'), + }, + favorite: { + label: { label: 'Favorite', icon: 'star' }, + action: action('action'), + }, + }, +}; + +export const Actions = Template.bind({}); +Actions.args = { + actions: ( + <> + + + + + + ), +}; diff --git a/apps/meteor/client/sidebarv2/Item/Extended.tsx b/apps/meteor/client/sidebarv2/Item/Extended.tsx new file mode 100644 index 000000000000..f288f5fd35c6 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Extended.tsx @@ -0,0 +1,89 @@ +import { Sidebar, IconButton } from '@rocket.chat/fuselage'; +import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import React, { memo, useState } from 'react'; + +import { useShortTimeAgo } from '../../hooks/useTimeAgo'; + +type ExtendedProps = { + icon?: IconName; + title?: React.ReactNode; + avatar?: React.ReactNode | boolean; + actions?: React.ReactNode; + href?: string; + time?: any; + menu?: () => React.ReactNode; + subtitle?: React.ReactNode; + badges?: React.ReactNode; + unread?: boolean; + selected?: boolean; + menuOptions?: any; + titleIcon?: React.ReactNode; + threadUnread?: boolean; +}; + +const Extended = ({ + icon, + title = '', + avatar, + actions, + href, + time, + menu, + menuOptions: _menuOptions, + subtitle = '', + titleIcon: _titleIcon, + badges, + threadUnread: _threadUnread, + unread, + selected, + ...props +}: ExtendedProps) => { + const formatDate = useShortTimeAgo(); + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const isReduceMotionEnabled = usePrefersReducedMotion(); + + const handleMenu = useEffectEvent((e) => { + setMenuVisibility(e.target.offsetWidth > 0 && Boolean(menu)); + }); + + const handleMenuEvent = { + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + }; + + return ( + + {avatar && {avatar}} + + + + {icon} + + {title} + + {time && {formatDate(time)}} + + + + + {subtitle} + {badges} + {menu && ( + + {menuVisibility ? menu() : } + + )} + + + + {actions && ( + + {actions} + + )} + + ); +}; + +export default memo(Extended); diff --git a/apps/meteor/client/sidebarv2/Item/Medium.stories.tsx b/apps/meteor/client/sidebarv2/Item/Medium.stories.tsx new file mode 100644 index 000000000000..0c03cf33c500 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Medium.stories.tsx @@ -0,0 +1,73 @@ +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import * as Status from '../../components/UserStatus'; +import Medium from './Medium'; + +export default { + title: 'Sidebar/Medium', + component: Medium, + args: { + clickable: true, + title: 'John Doe', + }, + decorators: [ + (fn) => ( + + {fn()} + + ), + ], +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + + } + avatar={} + /> +); + +export const Normal = Template.bind({}); + +export const Selected = Template.bind({}); +Selected.args = { + selected: true, +}; + +export const Menu = Template.bind({}); +Menu.args = { + menuOptions: { + hide: { + label: { label: 'Hide', icon: 'eye-off' }, + action: action('action'), + }, + read: { + label: { label: 'Mark_read', icon: 'flag' }, + action: action('action'), + }, + favorite: { + label: { label: 'Favorite', icon: 'star' }, + action: action('action'), + }, + }, +}; + +export const Actions = Template.bind({}); +Actions.args = { + actions: ( + <> + + + + + + ), +}; diff --git a/apps/meteor/client/sidebarv2/Item/Medium.tsx b/apps/meteor/client/sidebarv2/Item/Medium.tsx new file mode 100644 index 000000000000..ffc13047f66d --- /dev/null +++ b/apps/meteor/client/sidebarv2/Item/Medium.tsx @@ -0,0 +1,57 @@ +import { Sidebar, IconButton } from '@rocket.chat/fuselage'; +import { useEffectEvent, usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks'; +import React, { memo, useState } from 'react'; + +type MediumProps = { + title: React.ReactNode; + titleIcon?: React.ReactNode; + avatar: React.ReactNode | boolean; + icon?: string; + actions?: React.ReactNode; + href?: string; + unread?: boolean; + menu?: () => React.ReactNode; + badges?: React.ReactNode; + selected?: boolean; + menuOptions?: any; +}; + +const Medium = ({ icon, title = '', avatar, actions, href, badges, unread, menu, ...props }: MediumProps) => { + const [menuVisibility, setMenuVisibility] = useState(!!window.DISABLE_ANIMATION); + + const isReduceMotionEnabled = usePrefersReducedMotion(); + + const handleMenu = useEffectEvent((e) => { + setMenuVisibility(e.target.offsetWidth > 0 && Boolean(menu)); + }); + const handleMenuEvent = { + [isReduceMotionEnabled ? 'onMouseEnter' : 'onTransitionEnd']: handleMenu, + }; + + return ( + + {avatar && {avatar}} + + + {icon} + + {title} + + + {badges && {badges}} + {menu && ( + + {menuVisibility ? menu() : } + + )} + + {actions && ( + + {actions} + + )} + + ); +}; + +export default memo(Medium); diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomList.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomList.tsx new file mode 100644 index 000000000000..3f137d4709c7 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomList.tsx @@ -0,0 +1,135 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import { useUserPreference, useUserId, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; + +import { VirtuosoScrollbars } from '../../components/CustomScrollbars'; +import { useOpenedRoom } from '../../lib/RoomManager'; +import { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import { usePreventDefault } from '../hooks/usePreventDefault'; +import { useRoomList } from '../hooks/useRoomList'; +import { useShortcutOpenMenu } from '../hooks/useShortcutOpenMenu'; +import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; +import RoomListRow from './RoomListRow'; +import RoomListRowWrapper from './RoomListRowWrapper'; +import RoomListWrapper from './RoomListWrapper'; + +const computeItemKey = (index: number, room: IRoom): IRoom['_id'] | number => room._id || index; + +const RoomList = () => { + const t = useTranslation(); + const isAnonymous = !useUserId(); + const roomsList = useRoomList(); + const avatarTemplate = useAvatarTemplate(); + const sideBarItemTemplate = useTemplateByViewMode(); + const { ref } = useResizeObserver({ debounceDelay: 100 }); + const openedRoom = useOpenedRoom() ?? ''; + const sidebarViewMode = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode') || 'extended'; + + const extended = sidebarViewMode === 'extended'; + const itemData = useMemo( + () => ({ + extended, + t, + SideBarItemTemplate: sideBarItemTemplate, + AvatarTemplate: avatarTemplate, + openedRoom, + sidebarViewMode, + isAnonymous, + }), + [avatarTemplate, extended, isAnonymous, openedRoom, sideBarItemTemplate, sidebarViewMode, t], + ); + + usePreventDefault(ref); + useShortcutOpenMenu(ref); + + const roomsListStyle = css` + position: relative; + + display: flex; + + overflow-x: hidden; + overflow-y: hidden; + + flex: 1 1 auto; + + height: 100%; + + &--embedded { + margin-top: 2rem; + } + + &__list:not(:last-child) { + margin-bottom: 22px; + } + + &__type { + display: flex; + + flex-direction: row; + + padding: 0 var(--sidebar-default-padding) 1rem var(--sidebar-default-padding); + + color: var(--rooms-list-title-color); + + font-size: var(--rooms-list-title-text-size); + align-items: center; + justify-content: space-between; + + &-text--livechat { + flex: 1; + } + } + + &__empty-room { + padding: 0 var(--sidebar-default-padding); + + color: var(--rooms-list-empty-text-color); + + font-size: var(--rooms-list-empty-text-size); + } + + &__toolbar-search { + position: absolute; + z-index: 10; + left: 0; + + overflow-y: scroll; + + height: 100%; + + background-color: var(--sidebar-background); + + padding-block-start: 12px; + } + + @media (max-width: 400px) { + padding: 0 calc(var(--sidebar-small-default-padding) - 4px); + + &__type, + &__empty-room { + padding: 0 calc(var(--sidebar-small-default-padding) - 4px) 0.5rem calc(var(--sidebar-small-default-padding) - 4px); + } + } + `; + + return ( + + + } + /> + + + ); +}; + +export default RoomList; diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListRow.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListRow.tsx new file mode 100644 index 000000000000..64796d2e12e4 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListRow.tsx @@ -0,0 +1,63 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { SidebarSection } from '@rocket.chat/fuselage'; +import type { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, useMemo } from 'react'; + +import { useVideoConfAcceptCall, useVideoConfRejectIncomingCall, useVideoConfIncomingCalls } from '../../contexts/VideoConfContext'; +import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import type { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; +import SideBarItemTemplateWithData from './SideBarItemTemplateWithData'; + +type RoomListRowProps = { + data: { + extended: boolean; + t: ReturnType; + SideBarItemTemplate: ReturnType; + AvatarTemplate: ReturnType; + openedRoom: string; + sidebarViewMode: 'extended' | 'condensed' | 'medium'; + isAnonymous: boolean; + }; + item: ISubscription & IRoom; +}; + +const RoomListRow = ({ data, item }: RoomListRowProps) => { + const { extended, t, SideBarItemTemplate, AvatarTemplate, openedRoom, sidebarViewMode } = data; + + const acceptCall = useVideoConfAcceptCall(); + const rejectCall = useVideoConfRejectIncomingCall(); + const incomingCalls = useVideoConfIncomingCalls(); + const currentCall = incomingCalls.find((call) => call.rid === item.rid); + + const videoConfActions = useMemo( + () => + currentCall && { + acceptCall: (): void => acceptCall(currentCall.callId), + rejectCall: (): void => rejectCall(currentCall.callId), + }, + [acceptCall, rejectCall, currentCall], + ); + + if (typeof item === 'string') { + return ( + + {t(item)} + + ); + } + + return ( + + ); +}; + +export default memo(RoomListRow); diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListRowWrapper.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListRowWrapper.tsx new file mode 100644 index 000000000000..b2cd75193466 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListRowWrapper.tsx @@ -0,0 +1,10 @@ +import type { ForwardedRef, HTMLAttributes } from 'react'; +import React, { forwardRef } from 'react'; + +type RoomListRoomWrapperProps = HTMLAttributes; + +const RoomListRoomWrapper = forwardRef(function RoomListRoomWrapper(props: RoomListRoomWrapperProps, ref: ForwardedRef) { + return
; +}); + +export default RoomListRoomWrapper; diff --git a/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx b/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx new file mode 100644 index 000000000000..b4d4b4a44c98 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/RoomListWrapper.tsx @@ -0,0 +1,18 @@ +import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ForwardedRef, HTMLAttributes } from 'react'; +import React, { forwardRef } from 'react'; + +import { useSidebarListNavigation } from './useSidebarListNavigation'; + +type RoomListWrapperProps = HTMLAttributes; + +const RoomListWrapper = forwardRef(function RoomListWrapper(props: RoomListWrapperProps, ref: ForwardedRef) { + const t = useTranslation(); + const { sidebarListRef } = useSidebarListNavigation(); + const mergedRefs = useMergedRefs(ref, sidebarListRef); + + return
; +}); + +export default RoomListWrapper; diff --git a/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx new file mode 100644 index 000000000000..60444540f6fd --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx @@ -0,0 +1,275 @@ +import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { isDirectMessageRoom, isMultipleDirectMessageRoom, isOmnichannelRoom, isVideoConfMessage } from '@rocket.chat/core-typings'; +import { Badge, Sidebar, SidebarItemAction, SidebarItemActions, Margins } from '@rocket.chat/fuselage'; +import type { useTranslation } from '@rocket.chat/ui-contexts'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import type { AllHTMLAttributes, ComponentType, ReactElement, ReactNode } from 'react'; +import React, { memo, useMemo } from 'react'; + +import { RoomIcon } from '../../components/RoomIcon'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { useOmnichannelPriorities } from '../../omnichannel/hooks/useOmnichannelPriorities'; +import RoomMenu from '../RoomMenu'; +import { OmnichannelBadges } from '../badges/OmnichannelBadges'; +import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import { normalizeSidebarMessage } from './normalizeSidebarMessage'; + +const getMessage = (room: IRoom, lastMessage: IMessage | undefined, t: ReturnType): string | undefined => { + if (!lastMessage) { + return t('No_messages_yet'); + } + if (isVideoConfMessage(lastMessage)) { + return t('Call_started'); + } + if (!lastMessage.u) { + return normalizeSidebarMessage(lastMessage, t); + } + if (lastMessage.u?.username === room.u?.username) { + return `${t('You')}: ${normalizeSidebarMessage(lastMessage, t)}`; + } + if (isDirectMessageRoom(room) && !isMultipleDirectMessageRoom(room)) { + return normalizeSidebarMessage(lastMessage, t); + } + return `${lastMessage.u.name || lastMessage.u.username}: ${normalizeSidebarMessage(lastMessage, t)}`; +}; + +const getBadgeTitle = ( + userMentions: number, + threadUnread: number, + groupMentions: number, + unread: number, + t: ReturnType, +) => { + const title = [] as string[]; + if (userMentions) { + title.push(t('mentions_counter', { count: userMentions })); + } + if (threadUnread) { + title.push(t('threads_counter', { count: threadUnread })); + } + if (groupMentions) { + title.push(t('group_mentions_counter', { count: groupMentions })); + } + const count = unread - userMentions - groupMentions; + if (count > 0) { + title.push(t('unread_messages_counter', { count })); + } + return title.join(', '); +}; + +type RoomListRowProps = { + extended: boolean; + t: ReturnType; + SideBarItemTemplate: ComponentType< + { + icon: ReactNode; + title: ReactNode; + avatar: ReactNode; + actions: unknown; + href: string; + time?: Date; + menu?: ReactNode; + menuOptions?: unknown; + subtitle?: ReactNode; + titleIcon?: string; + badges?: ReactNode; + threadUnread?: boolean; + unread?: boolean; + selected?: boolean; + is?: string; + } & AllHTMLAttributes + >; + AvatarTemplate: ReturnType; + openedRoom?: string; + // sidebarViewMode: 'extended'; + isAnonymous?: boolean; + + room: ISubscription & IRoom; + id?: string; + /* @deprecated */ + style?: AllHTMLAttributes['style']; + + selected?: boolean; + + sidebarViewMode?: unknown; + videoConfActions?: { + [action: string]: () => void; + }; +}; + +const SideBarItemTemplateWithData = ({ + room, + id, + selected, + style, + extended, + SideBarItemTemplate, + AvatarTemplate, + t, + isAnonymous, + videoConfActions, +}: RoomListRowProps) => { + const { sidebar } = useLayout(); + + const href = roomCoordinator.getRouteLink(room.t, room) || ''; + const title = roomCoordinator.getRoomName(room.t, room) || ''; + + const { + lastMessage, + hideUnreadStatus, + hideMentionStatus, + unread = 0, + alert, + userMentions, + groupMentions, + tunread = [], + tunreadUser = [], + rid, + t: type, + cl, + } = room; + + const highlighted = Boolean(!hideUnreadStatus && (alert || unread)); + const icon = ( + // TODO: Remove icon='at' + + + + ); + + const actions = useMemo( + () => + videoConfActions && ( + + + + + ), + [videoConfActions], + ); + + const isQueued = isOmnichannelRoom(room) && room.status === 'queued'; + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + const message = extended && getMessage(room, lastMessage, t); + const subtitle = message ? : null; + + const threadUnread = tunread.length > 0; + const variant = + ((userMentions || tunreadUser.length) && 'danger') || (threadUnread && 'primary') || (groupMentions && 'warning') || 'secondary'; + + const isUnread = unread > 0 || threadUnread; + const showBadge = !hideUnreadStatus || (!hideMentionStatus && (Boolean(userMentions) || tunreadUser.length > 0)); + + const badgeTitle = getBadgeTitle(userMentions, tunread.length, groupMentions, unread, t); + + const badges = ( + + {showBadge && isUnread && ( + + {unread + tunread?.length} + + )} + {isOmnichannelRoom(room) && } + + ); + + return ( + { + !selected && sidebar.toggle(); + }} + aria-label={title} + title={title} + time={lastMessage?.ts} + subtitle={subtitle} + icon={icon} + style={style} + badges={badges} + avatar={AvatarTemplate && } + actions={actions} + menu={ + !isAnonymous && + (!isQueued || (isQueued && isPriorityEnabled)) && + ((): ReactElement => ( + + )) + } + /> + ); +}; + +function safeDateNotEqualCheck(a: Date | string | undefined, b: Date | string | undefined): boolean { + if (!a || !b) { + return a !== b; + } + return new Date(a).toISOString() !== new Date(b).toISOString(); +} + +const keys: (keyof RoomListRowProps)[] = [ + 'id', + 'style', + 'extended', + 'selected', + 'SideBarItemTemplate', + 'AvatarTemplate', + 't', + 'sidebarViewMode', + 'videoConfActions', +]; + +// eslint-disable-next-line react/no-multi-comp +export default memo(SideBarItemTemplateWithData, (prevProps, nextProps) => { + if (keys.some((key) => prevProps[key] !== nextProps[key])) { + return false; + } + + if (prevProps.room === nextProps.room) { + return true; + } + + if (prevProps.room._id !== nextProps.room._id) { + return false; + } + if (prevProps.room._updatedAt?.toISOString() !== nextProps.room._updatedAt?.toISOString()) { + return false; + } + if (safeDateNotEqualCheck(prevProps.room.lastMessage?._updatedAt, nextProps.room.lastMessage?._updatedAt)) { + return false; + } + if (prevProps.room.alert !== nextProps.room.alert) { + return false; + } + if (isOmnichannelRoom(prevProps.room) && isOmnichannelRoom(nextProps.room) && prevProps.room?.v?.status !== nextProps.room?.v?.status) { + return false; + } + if (prevProps.room.teamMain !== nextProps.room.teamMain) { + return false; + } + + if ( + isOmnichannelRoom(prevProps.room) && + isOmnichannelRoom(nextProps.room) && + prevProps.room.priorityWeight !== nextProps.room.priorityWeight + ) { + return false; + } + + return true; +}); diff --git a/apps/meteor/client/sidebarv2/RoomList/index.ts b/apps/meteor/client/sidebarv2/RoomList/index.ts new file mode 100644 index 000000000000..5b0cd3b4b0f8 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/index.ts @@ -0,0 +1 @@ +export { default } from './RoomList'; diff --git a/apps/meteor/client/sidebarv2/RoomList/normalizeSidebarMessage.ts b/apps/meteor/client/sidebarv2/RoomList/normalizeSidebarMessage.ts new file mode 100644 index 000000000000..9a506b861e56 --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/normalizeSidebarMessage.ts @@ -0,0 +1,26 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import type { useTranslation } from '@rocket.chat/ui-contexts'; +import emojione from 'emojione'; + +import { filterMarkdown } from '../../../app/markdown/lib/markdown'; + +export const normalizeSidebarMessage = (message: IMessage, t: ReturnType): string | undefined => { + if (message.msg) { + return escapeHTML(filterMarkdown(emojione.shortnameToUnicode(message.msg))); + } + + if (message.attachments) { + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return escapeHTML(attachment.description); + } + + if (attachment?.title) { + return escapeHTML(attachment.title); + } + + return t('Sent_an_attachment'); + } +}; diff --git a/apps/meteor/client/sidebarv2/RoomList/useSidebarListNavigation.ts b/apps/meteor/client/sidebarv2/RoomList/useSidebarListNavigation.ts new file mode 100644 index 000000000000..f5c2d00d4b2c --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomList/useSidebarListNavigation.ts @@ -0,0 +1,99 @@ +import { useFocusManager } from '@react-aria/focus'; +import { useCallback } from 'react'; + +const isListItem = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item'); +const isListItemMenu = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item__menu'); + +/** + * Custom hook to provide the sidebar navigation by keyboard. + * @param ref - A ref to the message list DOM element. + */ +export const useSidebarListNavigation = () => { + const sidebarListFocusManager = useFocusManager(); + + const sidebarListRef = useCallback( + (node: HTMLElement | null) => { + let lastItemFocused: HTMLElement | null = null; + + if (!node) { + return; + } + + node.addEventListener('keydown', (e) => { + if (!e.target) { + return; + } + + if (!isListItem(e.target)) { + return; + } + + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + + if (e.shiftKey) { + sidebarListFocusManager.focusPrevious({ + accept: (node) => !isListItem(node) && !isListItemMenu(node), + }); + } else if (isListItemMenu(e.target)) { + sidebarListFocusManager.focusNext({ + accept: (node) => !isListItem(node) && !isListItemMenu(node), + }); + } else { + sidebarListFocusManager.focusNext({ + accept: (node) => !isListItem(node), + }); + } + } + + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.key === 'ArrowUp') { + sidebarListFocusManager.focusPrevious({ accept: (node) => isListItem(node) }); + } + + if (e.key === 'ArrowDown') { + sidebarListFocusManager.focusNext({ accept: (node) => isListItem(node) }); + } + + lastItemFocused = document.activeElement as HTMLElement; + } + }); + + node.addEventListener( + 'blur', + (e) => { + if ( + !(e.relatedTarget as HTMLElement)?.classList.contains('focus-visible') || + !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement) + ) { + return; + } + + if (!e.currentTarget.contains(e.relatedTarget) && !lastItemFocused) { + lastItemFocused = e.target as HTMLElement; + } + }, + { capture: true }, + ); + + node.addEventListener( + 'focus', + (e) => { + const triggeredByKeyboard = (e.target as HTMLElement)?.classList.contains('focus-visible'); + if (!triggeredByKeyboard || !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)) { + return; + } + + if (lastItemFocused && !e.currentTarget.contains(e.relatedTarget) && node.contains(e.target as HTMLElement)) { + lastItemFocused?.focus(); + } + }, + { capture: true }, + ); + }, + [sidebarListFocusManager], + ); + + return { sidebarListRef }; +}; diff --git a/apps/meteor/client/sidebarv2/RoomMenu.tsx b/apps/meteor/client/sidebarv2/RoomMenu.tsx new file mode 100644 index 000000000000..e88225df40ca --- /dev/null +++ b/apps/meteor/client/sidebarv2/RoomMenu.tsx @@ -0,0 +1,260 @@ +import type { RoomType } from '@rocket.chat/core-typings'; +import { Option, Menu } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey, Fields } from '@rocket.chat/ui-contexts'; +import { + useRouter, + useSetModal, + useToastMessageDispatch, + useUserSubscription, + useSetting, + usePermission, + useMethod, + useTranslation, + useEndpoint, +} from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { memo, useMemo } from 'react'; + +import { LegacyRoomManager } from '../../app/ui-utils/client'; +import { UiTextContext } from '../../definition/IRoomTypeConfig'; +import { GenericModalDoNotAskAgain } from '../components/GenericModal'; +import WarningModal from '../components/WarningModal'; +import { useDontAskAgain } from '../hooks/useDontAskAgain'; +import { roomCoordinator } from '../lib/rooms/roomCoordinator'; +import { useOmnichannelPrioritiesMenu } from '../omnichannel/hooks/useOmnichannelPrioritiesMenu'; + +const fields: Fields = { + f: true, + t: true, + name: true, +}; + +type RoomMenuProps = { + rid: string; + unread?: boolean; + threadUnread?: boolean; + alert?: boolean; + roomOpen?: boolean; + type: RoomType; + cl?: boolean; + name?: string; + hideDefaultOptions: boolean; +}; + +const closeEndpoints = { + p: '/v1/groups.close', + c: '/v1/channels.close', + d: '/v1/im.close', + + v: '/v1/channels.close', + l: '/v1/groups.close', +} as const; + +const leaveEndpoints = { + p: '/v1/groups.leave', + c: '/v1/channels.leave', + d: '/v1/im.leave', + + v: '/v1/channels.leave', + l: '/v1/groups.leave', +} as const; + +const RoomMenu = ({ + rid, + unread, + threadUnread, + alert, + roomOpen, + type, + cl, + name = '', + hideDefaultOptions = false, +}: RoomMenuProps): ReactElement | null => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + + const closeModal = useEffectEvent(() => setModal()); + + const router = useRouter(); + + const subscription = useUserSubscription(rid, fields); + const canFavorite = useSetting('Favorite_Rooms'); + const isFavorite = Boolean(subscription?.f); + + const dontAskHideRoom = useDontAskAgain('hideRoom'); + + const hideRoom = useEndpoint('POST', closeEndpoints[type]); + const readMessages = useEndpoint('POST', '/v1/subscriptions.read'); + const toggleFavorite = useEndpoint('POST', '/v1/rooms.favorite'); + const leaveRoom = useEndpoint('POST', leaveEndpoints[type]); + + const unreadMessages = useMethod('unreadMessages'); + + const isUnread = alert || unread || threadUnread; + + const canLeaveChannel = usePermission('leave-c'); + const canLeavePrivate = usePermission('leave-p'); + + const isOmnichannelRoom = type === 'l'; + const prioritiesMenu = useOmnichannelPrioritiesMenu(rid); + + const canLeave = ((): boolean => { + if (type === 'c' && !canLeaveChannel) { + return false; + } + if (type === 'p' && !canLeavePrivate) { + return false; + } + return !((cl != null && !cl) || ['d', 'l'].includes(type)); + })(); + + const handleLeave = useEffectEvent(() => { + const leave = async (): Promise => { + try { + await leaveRoom({ roomId: rid }); + if (roomOpen) { + router.navigate('/home'); + } + LegacyRoomManager.close(rid); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + closeModal(); + }; + + const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.LEAVE_WARNING); + + setModal( + , + ); + }); + + const handleHide = useEffectEvent(async () => { + const hide = async (): Promise => { + try { + await hideRoom({ roomId: rid }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + closeModal(); + }; + + const warnText = roomCoordinator.getRoomDirectives(type).getUiText(UiTextContext.HIDE_WARNING); + + if (dontAskHideRoom) { + return hide(); + } + + setModal( + + {t(warnText as TranslationKey, name)} + , + ); + }); + + const handleToggleRead = useEffectEvent(async () => { + try { + if (isUnread) { + await readMessages({ rid, readThreads: true }); + return; + } + await unreadMessages(undefined, rid); + if (subscription == null) { + return; + } + LegacyRoomManager.close(subscription.t + subscription.name); + + router.navigate('/home'); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const handleToggleFavorite = useEffectEvent(async () => { + try { + await toggleFavorite({ roomId: rid, favorite: !isFavorite }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const menuOptions = useMemo( + () => ({ + ...(!hideDefaultOptions && { + hideRoom: { + label: { label: t('Hide'), icon: 'eye-off' }, + action: handleHide, + }, + toggleRead: { + label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, + action: handleToggleRead, + }, + ...(canFavorite + ? { + toggleFavorite: { + label: { + label: isFavorite ? t('Unfavorite') : t('Favorite'), + icon: isFavorite ? 'star-filled' : 'star', + }, + action: handleToggleFavorite, + }, + } + : {}), + ...(canLeave && { + leaveRoom: { + label: { label: t('Leave_room'), icon: 'sign-out' }, + action: handleLeave, + }, + }), + }), + ...(isOmnichannelRoom && prioritiesMenu), + }), + [ + hideDefaultOptions, + t, + handleHide, + isUnread, + handleToggleRead, + canFavorite, + isFavorite, + handleToggleFavorite, + canLeave, + handleLeave, + isOmnichannelRoom, + prioritiesMenu, + ], + ); + + return ( +