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 ( + setForceLogin(true)} {...props}> + {t('Login')} + + ); +}; + +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} + + + + + + + {t('Cancel')} + + + {t('Save')} + + + + + ); +}; + +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 ( + } + /> + ); +}; + +export default memo(RoomMenu); diff --git a/apps/meteor/client/sidebarv2/Sidebar.stories.tsx b/apps/meteor/client/sidebarv2/Sidebar.stories.tsx new file mode 100644 index 000000000000..d8c5788bae86 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Sidebar.stories.tsx @@ -0,0 +1,111 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { UserContext, SettingsContext } from '@rocket.chat/ui-contexts'; +import type { Meta, Story } from '@storybook/react'; +import type { ObjectId } from 'mongodb'; +import type { ContextType } from 'react'; +import React from 'react'; + +import Sidebar from './SidebarRegion'; + +export default { + title: 'Sidebar', +} as Meta; + +const settings: Record = { + UI_Use_Real_Name: { + _id: 'UI_Use_Real_Name', + blocked: false, + createdAt: new Date(), + env: true, + i18nLabel: 'Use real name', + packageValue: false, + sorter: 1, + ts: new Date(), + type: 'boolean', + value: true, + public: true, + _updatedAt: new Date(), + }, +}; + +const settingContextValue: ContextType = { + hasPrivateAccess: true, + isLoading: false, + querySetting: (_id) => [() => () => undefined, () => settings[_id]], + querySettings: () => [() => () => undefined, () => []], + dispatch: async () => undefined, +}; + +const userPreferences: Record = { + sidebarViewMode: 'medium', + sidebarDisplayAvatar: true, + sidebarGroupByType: true, + sidebarShowFavorites: true, + sidebarShowUnread: true, + sidebarSortby: 'activity', +}; + +const subscriptions: SubscriptionWithRoom[] = [ + { + _id: '3Bysd8GrmkWBdS9RT', + open: true, + alert: true, + unread: 0, + userMentions: 0, + groupMentions: 0, + ts: new Date(), + rid: 'GENERAL', + name: 'general', + t: 'c', + u: { + _id: '5yLFEABCSoqR5vozz', + username: 'yyy', + name: 'yyy', + }, + _updatedAt: new Date(), + ls: new Date(), + lr: new Date(), + tunread: [], + lowerCaseName: 'general', + lowerCaseFName: 'general', + estimatedWaitingTimeQueue: 0, + livechatData: undefined, + priorityWeight: 3, + responseBy: undefined, + usersCount: 0, + waitingResponse: undefined, + }, +]; + +const userContextValue: ContextType = { + userId: 'john.doe', + user: { + _id: 'john.doe', + username: 'john.doe', + name: 'John Doe', + createdAt: new Date(), + active: true, + _updatedAt: new Date(), + roles: ['admin'], + type: 'user', + }, + queryPreference: (pref: string | ObjectId, defaultValue: T) => [ + () => () => undefined, + () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), + ], + querySubscriptions: () => [() => () => undefined, () => subscriptions], + querySubscription: () => [() => () => undefined, () => undefined], + queryRoom: () => [() => () => undefined, () => undefined], + + logout: () => Promise.resolve(), +}; + +export const SidebarStory: Story = () => ; +SidebarStory.decorators = [ + (fn) => ( + + {fn()} + + ), +]; diff --git a/apps/meteor/client/sidebarv2/Sidebar.tsx b/apps/meteor/client/sidebarv2/Sidebar.tsx new file mode 100644 index 000000000000..573d90dd0d23 --- /dev/null +++ b/apps/meteor/client/sidebarv2/Sidebar.tsx @@ -0,0 +1,46 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; +import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; +import React, { memo } from 'react'; + +import SidebarRoomList from './RoomList'; +import SidebarFooter from './footer'; +import SearchSection from './header/SearchSection'; +import StatusDisabledSection from './sections/StatusDisabledSection'; + +const Sidebar = () => { + const sidebarViewMode = useUserPreference('sidebarViewMode'); + const sidebarHideAvatar = !useUserPreference('sidebarDisplayAvatar'); + const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false); + const presenceDisabled = useSetting('Presence_broadcast_disabled'); + + const sidebarLink = css` + a { + text-decoration: none; + } + `; + + return ( + + + {presenceDisabled && !bannerDismissed && setBannerDismissed(true)} />} + + + + ); +}; + +export default memo(Sidebar); diff --git a/apps/meteor/client/sidebarv2/SidebarPortal.tsx b/apps/meteor/client/sidebarv2/SidebarPortal.tsx new file mode 100644 index 000000000000..fc23fdf4dbff --- /dev/null +++ b/apps/meteor/client/sidebarv2/SidebarPortal.tsx @@ -0,0 +1,18 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; +import React, { memo } from 'react'; +import { createPortal } from 'react-dom'; + +type SidebarPortalProps = { children?: ReactNode }; + +const SidebarPortal = ({ children }: SidebarPortalProps) => { + const sidebarRoot = document.getElementById('sidebar-region'); + + if (!sidebarRoot) { + return null; + } + + return <>{createPortal({children}, sidebarRoot)}>; +}; + +export default memo(SidebarPortal); diff --git a/apps/meteor/client/sidebarv2/SidebarRegion.tsx b/apps/meteor/client/sidebarv2/SidebarRegion.tsx new file mode 100644 index 000000000000..5bd4d0c1a6d8 --- /dev/null +++ b/apps/meteor/client/sidebarv2/SidebarRegion.tsx @@ -0,0 +1,109 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import React, { memo } from 'react'; +import { FocusScope } from 'react-aria'; + +import Sidebar from './Sidebar'; + +const SidebarRegion = () => { + const { isMobile, sidebar } = useLayout(); + + const sidebarMobileClass = css` + position: absolute; + user-select: none; + transform: translate3d(-100%, 0, 0); + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + -webkit-user-drag: none; + touch-action: pan-y; + will-change: transform; + + .rtl & { + transform: translate3d(200%, 0, 0); + + &.opened { + box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 15px 1px; + transform: translate3d(0px, 0px, 0px); + } + } + `; + + const sideBarStyle = css` + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + transition: transform 0.3s; + width: var(--sidebar-width); + min-width: var(--sidebar-width); + + > .rcx-sidebar:not(:last-child) { + visibility: hidden; + } + + &.opened { + box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 15px 1px; + transform: translate3d(0px, 0px, 0px); + } + + /* // 768px to 1599px + // using em unit base 16 + @media (max-width: 48em) { + width: 80%; + min-width: 80%; + } */ + + // 1600px to 1919px + // using em unit base 16 + @media (min-width: 100em) { + width: var(--sidebar-md-width); + min-width: var(--sidebar-md-width); + } + + // 1920px and up + // using em unit base 16 + @media (min-width: 120em) { + width: var(--sidebar-lg-width); + min-width: var(--sidebar-lg-width); + } + `; + + const sidebarWrapStyle = css` + position: absolute; + z-index: 1; + top: 0; + left: 0; + height: 100%; + user-select: none; + transition: opacity 0.3s; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + touch-action: pan-y; + -webkit-user-drag: none; + + &.opened { + width: 100%; + background-color: rgb(0, 0, 0); + opacity: 0.8; + } + `; + + return ( + + + + + {isMobile && ( + sidebar.toggle()}> + )} + + ); +}; + +export default memo(SidebarRegion); diff --git a/apps/meteor/client/sidebarv2/badges/OmnichannelBadges.tsx b/apps/meteor/client/sidebarv2/badges/OmnichannelBadges.tsx new file mode 100644 index 000000000000..9f2580b74b77 --- /dev/null +++ b/apps/meteor/client/sidebarv2/badges/OmnichannelBadges.tsx @@ -0,0 +1,22 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { RoomActivityIcon } from '../../omnichannel/components/RoomActivityIcon'; +import { useOmnichannelPriorities } from '../../omnichannel/hooks/useOmnichannelPriorities'; +import { PriorityIcon } from '../../omnichannel/priorities/PriorityIcon'; + +export const OmnichannelBadges = ({ room }: { room: ISubscription & IRoom }) => { + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + if (!isOmnichannelRoom(room)) { + return null; + } + + return ( + <> + {isPriorityEnabled ? : null} + + > + ); +}; diff --git a/apps/meteor/client/sidebarv2/footer/SidebarFooter.tsx b/apps/meteor/client/sidebarv2/footer/SidebarFooter.tsx new file mode 100644 index 000000000000..f31f1a3e6b8f --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/SidebarFooter.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +import { useIsCallEnabled, useIsCallReady } from '../../contexts/CallContext'; +import SidebarFooterDefault from './SidebarFooterDefault'; +import { VoipFooter } from './voip'; + +const SidebarFooter = (): ReactElement => { + const isCallEnabled = useIsCallEnabled(); + const ready = useIsCallReady(); + + if (isCallEnabled && ready) { + return ; + } + + return ; +}; + +export default SidebarFooter; diff --git a/apps/meteor/client/sidebarv2/footer/SidebarFooterDefault.tsx b/apps/meteor/client/sidebarv2/footer/SidebarFooterDefault.tsx new file mode 100644 index 000000000000..fbf987fa78af --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/SidebarFooterDefault.tsx @@ -0,0 +1,44 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, SidebarDivider, Palette, SidebarFooter as Footer } from '@rocket.chat/fuselage'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useThemeMode } from '@rocket.chat/ui-theming/src/hooks/useThemeMode'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { SidebarFooterWatermark } from './SidebarFooterWatermark'; + +const SidebarFooterDefault = (): ReactElement => { + const [, , theme] = useThemeMode(); + const logo = String(useSetting(theme === 'dark' ? 'Layout_Sidenav_Footer_Dark' : 'Layout_Sidenav_Footer')).trim(); + + const sidebarFooterStyle = css` + & img { + max-width: 100%; + height: 100%; + } + + & a:any-link { + color: ${Palette.text['font-info']}; + } + `; + + return ( + + ); +}; + +export default SidebarFooterDefault; diff --git a/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx b/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx new file mode 100644 index 000000000000..bf7736b5899a --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/SidebarFooterWatermark.tsx @@ -0,0 +1,41 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { useLicense, useLicenseName } from '../../hooks/useLicense'; + +export const SidebarFooterWatermark = (): ReactElement | null => { + const t = useTranslation(); + + const response = useLicense(); + + const licenseName = useLicenseName(); + + if (response.isLoading || response.isError) { + return null; + } + + if (licenseName.isError || licenseName.isLoading) { + return null; + } + + const license = response.data; + + if (license.activeModules.includes('hide-watermark') && !license.trial) { + return null; + } + + return ( + + + + {t('Powered_by_RocketChat')} + + + {licenseName.data} + + + + ); +}; diff --git a/apps/meteor/client/sidebarv2/footer/index.ts b/apps/meteor/client/sidebarv2/footer/index.ts new file mode 100644 index 000000000000..98845267e83c --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/index.ts @@ -0,0 +1 @@ +export { default } from './SidebarFooter'; diff --git a/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.stories.tsx b/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.stories.tsx new file mode 100644 index 000000000000..be99ae8ada72 --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.stories.tsx @@ -0,0 +1,130 @@ +import type { VoIpCallerInfo } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ComponentStory } from '@storybook/react'; +import React, { useState } from 'react'; + +import VoipFooter from './VoipFooter'; + +const callActions = { + mute: () => ({}), + unmute: () => ({}), + pause: () => ({}), + resume: () => ({}), + end: () => ({}), + pickUp: () => ({}), + reject: () => ({}), +}; + +const callerDefault = { + callerName: '', + callerId: '+5551999999999', + host: '', +}; + +export default { + title: 'Sidebar/Footer/VoipFooter', + component: VoipFooter, + parameters: { + controls: { expanded: true }, + }, + args: { + isEnterprise: true, + }, + argTypes: { + caller: { control: false }, + callerState: { control: false }, + callActions: { control: false }, + title: { control: false }, + subtitle: { control: false }, + muted: { control: false }, + paused: { control: false }, + toggleMic: { control: false }, + togglePause: { control: false }, + tooltips: { control: false }, + createRoom: { control: false }, + openRoom: { control: false }, + callsInQueue: { control: false }, + dispatchEvent: { control: false }, + openedRoomInfo: { control: false }, + anonymousText: { control: false }, + options: { control: false }, + }, +}; + +const VoipFooterTemplate: ComponentStory = (args) => { + const [muted, toggleMic] = useState(false); + const [paused, togglePause] = useState(false); + + const getSubtitle = (state: VoIpCallerInfo['state']): string => { + const subtitles: Record = { + IN_CALL: 'In Progress', + OFFER_RECEIVED: 'Ringing', + OFFER_SENT: 'Calling', + ON_HOLD: 'On Hold', + }; + + return subtitles[state] || ''; + }; + + return ( + + ''} + openRoom={() => ''} + callsInQueue='2 Calls In Queue' + dispatchEvent={() => null} + openedRoomInfo={{ v: { token: '' }, rid: '' }} + options={{ + deviceSettings: { + label: ( + + + Device Settings + + ), + }, + }} + /> + + ); +}; + +export const IncomingCall = VoipFooterTemplate.bind({}); +IncomingCall.args = { + title: 'Sales Department', + callerState: 'OFFER_RECEIVED', + caller: callerDefault, +}; + +export const OutboundCall = VoipFooterTemplate.bind({}); +OutboundCall.args = { + title: 'Phone Call', + callerState: 'OFFER_SENT', + caller: { + callerName: '', + callerId: '+5551999999999', + host: '', + }, +}; + +export const InCall = VoipFooterTemplate.bind({}); +InCall.args = { + title: 'Sales Department', + callerState: 'IN_CALL', + caller: callerDefault, +}; + +export const NoEnterpriseLicence = VoipFooterTemplate.bind({}); +NoEnterpriseLicence.args = { + title: 'Sales Department', + callerState: 'IN_CALL', + isEnterprise: false, + caller: callerDefault, +}; diff --git a/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx b/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx new file mode 100644 index 000000000000..6e67baefdec8 --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/voip/VoipFooter.tsx @@ -0,0 +1,179 @@ +import type { IVoipRoom, ICallerInfo, VoIpCallerInfo } from '@rocket.chat/core-typings'; +import { VoipClientEvents } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, ButtonGroup, SidebarFooter, Menu, IconButton } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement, MouseEvent, ReactNode } from 'react'; +import React from 'react'; + +import type { CallActionsType } from '../../../contexts/CallContext'; +import type { VoipFooterMenuOptions } from '../../../hooks/useVoipFooterMenu'; +import { useOmnichannelContactLabel } from './hooks/useOmnichannelContactLabel'; + +type VoipFooterProps = { + caller: ICallerInfo; + callerState: VoIpCallerInfo['state']; + callActions: CallActionsType; + title: string; + subtitle: string; + muted: boolean; + paused: boolean; + toggleMic: (state: boolean) => void; + togglePause: (state: boolean) => void; + callsInQueue: string; + createRoom: (caller: ICallerInfo, callDirection?: IVoipRoom['direction']) => Promise; + openRoom: (rid: IVoipRoom['_id']) => void; + dispatchEvent: (params: { event: VoipClientEvents; rid: string; comment?: string }) => void; + openedRoomInfo: { v: { token?: string | undefined }; rid: string }; + isEnterprise: boolean; + children?: ReactNode; + options: VoipFooterMenuOptions; +}; + +const VoipFooter = ({ + caller, + callerState, + callActions, + title, + subtitle, + muted, + paused, + toggleMic, + togglePause, + createRoom, + openRoom, + callsInQueue, + dispatchEvent, + openedRoomInfo, + isEnterprise = false, + children, + options, +}: VoipFooterProps): ReactElement => { + const contactLabel = useOmnichannelContactLabel(caller); + const t = useTranslation(); + + const cssClickable = + callerState === 'IN_CALL' || callerState === 'ON_HOLD' + ? css` + cursor: pointer; + ` + : ''; + + const handleHold = (e: MouseEvent): void => { + e.stopPropagation(); + const eventName = paused ? 'VOIP-CALL-UNHOLD' : 'VOIP-CALL-ON-HOLD'; + dispatchEvent({ event: VoipClientEvents[eventName], rid: openedRoomInfo.rid }); + togglePause(!paused); + }; + + const holdTitle = ((): string => { + if (!isEnterprise) { + return t('Hold_Premium_only'); + } + return paused ? t('Resume') : t('Hold'); + })(); + + return ( + + { + if (callerState === 'IN_CALL' || callerState === 'ON_HOLD') { + openRoom(openedRoomInfo.rid); + } + }} + > + + {callsInQueue} + + + + {title} + + {(callerState === 'IN_CALL' || callerState === 'ON_HOLD') && ( + e.stopPropagation()}> + { + e.stopPropagation(); + toggleMic(!muted); + }} + /> + + {options && } + + )} + + + + + {contactLabel || t('Anonymous')} + + + {subtitle} + + + + + {(callerState === 'IN_CALL' || callerState === 'ON_HOLD' || callerState === 'OFFER_SENT') && ( + { + e.stopPropagation(); + muted && toggleMic(false); + paused && togglePause(false); + return callActions.end(); + }} + /> + )} + {callerState === 'OFFER_RECEIVED' && ( + + )} + {callerState === 'OFFER_RECEIVED' && ( + => { + callActions.pickUp(); + const rid = await createRoom(caller); + dispatchEvent({ event: VoipClientEvents['VOIP-CALL-STARTED'], rid }); + }} + /> + )} + + + + {children} + + ); +}; + +export default VoipFooter; diff --git a/apps/meteor/client/sidebarv2/footer/voip/hooks/useOmnichannelContactLabel.ts b/apps/meteor/client/sidebarv2/footer/voip/hooks/useOmnichannelContactLabel.ts new file mode 100644 index 000000000000..6b3d59830d1e --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/voip/hooks/useOmnichannelContactLabel.ts @@ -0,0 +1,16 @@ +import type { ICallerInfo } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +import { parseOutboundPhoneNumber } from '../../../../lib/voip/parseOutboundPhoneNumber'; + +export const useOmnichannelContactLabel = (caller: ICallerInfo): string => { + const getContactBy = useEndpoint('GET', '/v1/omnichannel/contact.search'); + const phone = parseOutboundPhoneNumber(caller.callerId); + + const { data } = useQuery(['getContactsByPhone', phone], async () => getContactBy({ phone }).then(({ contact }) => contact), { + enabled: !!phone, + }); + + return data?.name || caller.callerName || phone; +}; diff --git a/apps/meteor/client/sidebarv2/footer/voip/index.tsx b/apps/meteor/client/sidebarv2/footer/voip/index.tsx new file mode 100644 index 000000000000..bc6226201511 --- /dev/null +++ b/apps/meteor/client/sidebarv2/footer/voip/index.tsx @@ -0,0 +1,89 @@ +import type { VoIpCallerInfo } from '@rocket.chat/core-typings'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; + +import { + useCallActions, + useCallCreateRoom, + useCallerInfo, + useCallOpenRoom, + useIsVoipEnterprise, + useOpenedRoomInfo, + useQueueCounter, + useQueueName, +} from '../../../contexts/CallContext'; +import { useVoipFooterMenu } from '../../../hooks/useVoipFooterMenu'; +import SidebarFooterDefault from '../SidebarFooterDefault'; +import VoipFooterComponent from './VoipFooter'; + +export const VoipFooter = (): ReactElement | null => { + const t = useTranslation(); + const callerInfo = useCallerInfo(); + const callActions = useCallActions(); + const dispatchEvent = useEndpoint('POST', '/v1/voip/events'); + + const createRoom = useCallCreateRoom(); + const openRoom = useCallOpenRoom(); + const queueCounter = useQueueCounter(); + const queueName = useQueueName(); + const openedRoomInfo = useOpenedRoomInfo(); + const options = useVoipFooterMenu(); + + const [muted, setMuted] = useState(false); + const [paused, setPaused] = useState(false); + const isEnterprise = useIsVoipEnterprise(); + + const toggleMic = useCallback( + (state: boolean) => { + state ? callActions.mute() : callActions.unmute(); + setMuted(state); + }, + [callActions], + ); + + const togglePause = useCallback( + (state: boolean) => { + state ? callActions.pause() : callActions.resume(); + setMuted(false); + setPaused(state); + }, + [callActions], + ); + + const getSubtitle = (state: VoIpCallerInfo['state']): string => { + const subtitles: Record = { + IN_CALL: t('In_progress'), + OFFER_RECEIVED: t('Ringing'), + OFFER_SENT: t('Calling'), + ON_HOLD: t('On_Hold'), + }; + + return subtitles[state] || ''; + }; + + if (!('caller' in callerInfo)) { + return ; + } + + return ( + + ); +}; diff --git a/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx b/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx new file mode 100644 index 000000000000..a395f784b70a --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/CreateChannelModal.tsx @@ -0,0 +1,376 @@ +import { + Box, + Modal, + Button, + TextInput, + Icon, + Field, + ToggleSwitch, + FieldGroup, + FieldLabel, + FieldRow, + FieldError, + FieldHint, + FieldDescription, +} from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { + useSetting, + useTranslation, + useEndpoint, + usePermission, + useToastMessageDispatch, + usePermissionWithScopedRoles, +} from '@rocket.chat/ui-contexts'; +import type { ComponentProps, ReactElement } from 'react'; +import React, { useEffect, useMemo } from 'react'; +import { useForm, Controller } from 'react-hook-form'; + +import UserAutoCompleteMultipleFederated from '../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +import { goToRoomById } from '../../lib/utils/goToRoomById'; +import { useEncryptedRoomDescription } from './hooks/useEncryptedRoomDescription'; + +type CreateChannelModalProps = { + teamId?: string; + onClose: () => void; + reload?: () => void; +}; + +type CreateChannelModalPayload = { + name: string; + isPrivate: boolean; + topic?: string; + members: string[]; + readOnly: boolean; + encrypted: boolean; + broadcast: boolean; + federated: boolean; +}; + +const getFederationHintKey = (licenseModule: ReturnType, featureToggle: boolean): TranslationKey => { + if (licenseModule === 'loading' || !licenseModule) { + return 'error-this-is-a-premium-feature'; + } + if (!featureToggle) { + return 'Federation_Matrix_Federated_Description_disabled'; + } + return 'Federation_Matrix_Federated_Description'; +}; + +const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModalProps) => { + const t = useTranslation(); + const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']); + const e2eEnabled = useSetting('E2E_Enable'); + const namesValidation = useSetting('UTF8_Channel_Names_Validation'); + const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); + const federationEnabled = useSetting('Federation_Matrix_enabled') || false; + const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled; + + const canCreateChannel = usePermission('create-c'); + const canCreatePrivateChannel = usePermission('create-p'); + const getEncryptedHint = useEncryptedRoomDescription('channel'); + + const channelNameRegex = useMemo(() => new RegExp(`^${namesValidation}$`), [namesValidation]); + const federatedModule = useHasLicenseModule('federation'); + const canUseFederation = federatedModule !== 'loading' && federatedModule && federationEnabled; + + const channelNameExists = useEndpoint('GET', '/v1/rooms.nameExists'); + const createChannel = useEndpoint('POST', '/v1/channels.create'); + const createPrivateChannel = useEndpoint('POST', '/v1/groups.create'); + + const dispatchToastMessage = useToastMessageDispatch(); + + const canOnlyCreateOneType = useMemo(() => { + if (!canCreateChannel && canCreatePrivateChannel) { + return 'p'; + } + if (canCreateChannel && !canCreatePrivateChannel) { + return 'c'; + } + return false; + }, [canCreateChannel, canCreatePrivateChannel]); + + const { + register, + formState: { errors }, + handleSubmit, + control, + setValue, + watch, + } = useForm({ + mode: 'onBlur', + defaultValues: { + members: [], + name: '', + topic: '', + isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true, + readOnly: false, + encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false, + broadcast: false, + federated: false, + }, + }); + + const { isPrivate, broadcast, readOnly, federated, encrypted } = watch(); + + useEffect(() => { + if (!isPrivate) { + setValue('encrypted', false); + } + + if (broadcast) { + setValue('encrypted', false); + } + + if (federated) { + // if room is federated, it cannot be encrypted or broadcast or readOnly + setValue('encrypted', false); + setValue('broadcast', false); + setValue('readOnly', false); + } + + setValue('readOnly', broadcast); + }, [federated, setValue, broadcast, isPrivate]); + + const validateChannelName = async (name: string): Promise => { + if (!name) { + return; + } + + if (!allowSpecialNames && !channelNameRegex.test(name)) { + return t('Name_cannot_have_special_characters'); + } + + const { exists } = await channelNameExists({ roomName: name }); + if (exists) { + return t('Channel_already_exist', name); + } + }; + + const handleCreateChannel = async ({ name, members, readOnly, topic, broadcast, encrypted, federated }: CreateChannelModalPayload) => { + let roomData; + const params = { + name, + members, + readOnly, + extraData: { + topic, + broadcast, + encrypted, + ...(federated && { federated }), + ...(teamId && { teamId }), + }, + }; + + try { + if (isPrivate) { + roomData = await createPrivateChannel(params); + !teamId && goToRoomById(roomData.group._id); + } else { + roomData = await createChannel(params); + !teamId && goToRoomById(roomData.channel._id); + } + + dispatchToastMessage({ type: 'success', message: t('Room_has_been_created') }); + reload?.(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + onClose(); + } + }; + + const e2eDisabled = useMemo( + () => !isPrivate || broadcast || Boolean(!e2eEnabled) || Boolean(e2eEnabledForPrivateByDefault), + [e2eEnabled, e2eEnabledForPrivateByDefault, broadcast, isPrivate], + ); + + const createChannelFormId = useUniqueId(); + const nameId = useUniqueId(); + const topicId = useUniqueId(); + const privateId = useUniqueId(); + const federatedId = useUniqueId(); + const readOnlyId = useUniqueId(); + const encryptedId = useUniqueId(); + const broadcastId = useUniqueId(); + const addMembersId = useUniqueId(); + + return ( + ) => ( + + )} + > + + {t('Create_channel')} + + + + + + + {t('Name')} + + + validateChannelName(value), + })} + error={errors.name?.message} + addon={} + aria-invalid={errors.name ? 'true' : 'false'} + aria-describedby={`${nameId}-error ${nameId}-hint`} + aria-required='true' + /> + + {errors.name && ( + + {errors.name.message} + + )} + {!allowSpecialNames && {t('No_spaces')}} + + + {t('Topic')} + + + + {t('Displayed_next_to_name')} + + + {t('Members')} + ( + + )} + /> + + + + {t('Private')} + ( + + )} + /> + + + {isPrivate ? t('People_can_only_join_by_being_invited') : t('Anyone_can_access')} + + + + + {t('Federation_Matrix_Federated')} + ( + + )} + /> + + {t(getFederationHintKey(federatedModule, federationEnabled))} + + + + {t('Encrypted')} + ( + + )} + /> + + {getEncryptedHint({ isPrivate, broadcast, encrypted })} + + + + {t('Read_only')} + ( + + )} + /> + + + {readOnly ? t('Read_only_field_hint_enabled', { roomType: 'channel' }) : t('Anyone_can_send_new_messages')} + + + + + {t('Broadcast')} + ( + + )} + /> + + {broadcast && {t('Broadcast_hint_enabled', { roomType: 'channel' })}} + + + + + + {t('Cancel')} + + {t('Create')} + + + + + ); +}; + +export default CreateChannelModal; diff --git a/apps/meteor/client/sidebarv2/header/CreateDirectMessage.tsx b/apps/meteor/client/sidebarv2/header/CreateDirectMessage.tsx new file mode 100644 index 000000000000..070c363a0273 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/CreateDirectMessage.tsx @@ -0,0 +1,101 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Box, Modal, Button, FieldGroup, Field, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useEndpoint, useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import React, { memo } from 'react'; +import { useForm, Controller } from 'react-hook-form'; + +import UserAutoCompleteMultipleFederated from '../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated'; +import { goToRoomById } from '../../lib/utils/goToRoomById'; + +type CreateDirectMessageProps = { onClose: () => void }; + +const CreateDirectMessage = ({ onClose }: CreateDirectMessageProps) => { + const t = useTranslation(); + const directMaxUsers = useSetting('DirectMesssage_maxUsers') || 1; + const membersFieldId = useUniqueId(); + const dispatchToastMessage = useToastMessageDispatch(); + + const createDirectAction = useEndpoint('POST', '/v1/dm.create'); + + const { + control, + handleSubmit, + formState: { isSubmitting, isValidating, errors }, + } = useForm({ mode: 'onBlur', defaultValues: { users: [] } }); + + const mutateDirectMessage = useMutation({ + mutationFn: createDirectAction, + onSuccess: ({ room: { rid } }) => { + goToRoomById(rid); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + onClose(); + }, + }); + + const handleCreate = async ({ users }: { users: IUser['username'][] }) => { + return mutateDirectMessage.mutateAsync({ usernames: users.join(',') }); + }; + + return ( + }> + + {t('Create_direct_message')} + + + + + + {t('Direct_message_creation_description')} + + + users.length + 1 > directMaxUsers + ? t('error-direct-message-max-user-exceeded', { maxUsers: directMaxUsers }) + : undefined, + }} + control={control} + render={({ field: { name, onChange, value, onBlur } }) => ( + + )} + /> + + {errors.users && ( + + {errors.users.message} + + )} + {t('Direct_message_creation_description_hint')} + + + + + + {t('Cancel')} + + {t('Create')} + + + + + ); +}; + +export default memo(CreateDirectMessage); diff --git a/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx b/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx new file mode 100644 index 000000000000..70717d01f37e --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/CreateTeamModal.tsx @@ -0,0 +1,303 @@ +import { + Box, + Button, + Field, + Icon, + Modal, + TextInput, + ToggleSwitch, + FieldGroup, + FieldLabel, + FieldRow, + FieldError, + FieldDescription, + FieldHint, +} from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { + useEndpoint, + usePermission, + usePermissionWithScopedRoles, + useSetting, + useToastMessageDispatch, + useTranslation, +} from '@rocket.chat/ui-contexts'; +import type { ComponentProps, ReactElement } from 'react'; +import React, { memo, useEffect, useMemo } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple'; +import { goToRoomById } from '../../lib/utils/goToRoomById'; +import { useEncryptedRoomDescription } from './hooks/useEncryptedRoomDescription'; + +type CreateTeamModalInputs = { + name: string; + topic: string; + isPrivate: boolean; + readOnly: boolean; + encrypted: boolean; + broadcast: boolean; + members?: string[]; +}; + +type CreateTeamModalProps = { onClose: () => void }; + +const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => { + const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); + const namesValidation = useSetting('UTF8_Channel_Names_Validation'); + const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); + const dispatchToastMessage = useToastMessageDispatch(); + const canCreateTeam = usePermission('create-team'); + const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']); + + const checkTeamNameExists = useEndpoint('GET', '/v1/rooms.nameExists'); + const createTeamAction = useEndpoint('POST', '/v1/teams.create'); + + const teamNameRegex = useMemo(() => { + if (allowSpecialNames) { + return null; + } + + return new RegExp(`^${namesValidation}$`); + }, [allowSpecialNames, namesValidation]); + + const validateTeamName = async (name: string): Promise => { + if (!name) { + return; + } + + if (teamNameRegex && !teamNameRegex?.test(name)) { + return t('Name_cannot_have_special_characters'); + } + + const { exists } = await checkTeamNameExists({ roomName: name }); + if (exists) { + return t('Teams_Errors_Already_exists', { name }); + } + }; + + const { + register, + control, + handleSubmit, + setValue, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + isPrivate: true, + readOnly: false, + encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false, + broadcast: false, + members: [], + }, + }); + + const { isPrivate, broadcast, readOnly, encrypted } = watch(); + + useEffect(() => { + if (!isPrivate) { + setValue('encrypted', false); + } + + if (broadcast) { + setValue('encrypted', false); + } + + setValue('readOnly', broadcast); + }, [watch, setValue, broadcast, isPrivate]); + + const canChangeReadOnly = !broadcast; + const canChangeEncrypted = isPrivate && !broadcast && e2eEnabled && !e2eEnabledForPrivateByDefault; + const getEncryptedHint = useEncryptedRoomDescription('team'); + + const handleCreateTeam = async ({ + name, + members, + isPrivate, + readOnly, + topic, + broadcast, + encrypted, + }: CreateTeamModalInputs): Promise => { + const params = { + name, + members, + type: isPrivate ? 1 : 0, + room: { + readOnly, + extraData: { + topic, + broadcast, + encrypted, + }, + }, + }; + + try { + const { team } = await createTeamAction(params); + dispatchToastMessage({ type: 'success', message: t('Team_has_been_created') }); + goToRoomById(team.roomId); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + onClose(); + } + }; + + const createTeamFormId = useUniqueId(); + const nameId = useUniqueId(); + const topicId = useUniqueId(); + const privateId = useUniqueId(); + const readOnlyId = useUniqueId(); + const encryptedId = useUniqueId(); + const broadcastId = useUniqueId(); + const addMembersId = useUniqueId(); + + return ( + ) => ( + + )} + > + + {t('Teams_New_Title')} + + + + + {t('Teams_new_description')} + + + + + {t('Teams_New_Name_Label')} + + + validateTeamName(value), + })} + addon={} + error={errors.name?.message} + aria-describedby={`${nameId}-error ${nameId}-hint`} + aria-required='true' + /> + + {errors?.name && ( + + {errors.name.message} + + )} + {!allowSpecialNames && {t('No_spaces')}} + + + {t('Topic')} + + + + + {t('Displayed_next_to_name')} + + + + {t('Teams_New_Add_members_Label')} + ( + + )} + /> + + + + {t('Teams_New_Private_Label')} + ( + + )} + /> + + + {isPrivate ? t('People_can_only_join_by_being_invited') : t('Anyone_can_access')} + + + + + {t('Teams_New_Read_only_Label')} + ( + + )} + /> + + + {readOnly ? t('Read_only_field_hint_enabled', { roomType: 'team' }) : t('Anyone_can_send_new_messages')} + + + + + {t('Teams_New_Encrypted_Label')} + ( + + )} + /> + + {getEncryptedHint({ isPrivate, broadcast, encrypted })} + + + + {t('Teams_New_Broadcast_Label')} + ( + + )} + /> + + {broadcast && {t('Teams_New_Broadcast_Description')}} + + + + + + {t('Cancel')} + + {t('Create')} + + + + + ); +}; + +export default memo(CreateTeamModal); diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomList.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomList.tsx new file mode 100644 index 000000000000..b4ddbf32419d --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomList.tsx @@ -0,0 +1,82 @@ +import { Throbber, Box } from '@rocket.chat/fuselage'; +import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; +import { useSetModal, useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import React from 'react'; +import { Virtuoso } from 'react-virtuoso'; + +import { VirtuosoScrollbars } from '../../../components/CustomScrollbars'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import FederatedRoomListEmptyPlaceholder from './FederatedRoomListEmptyPlaceholder'; +import FederatedRoomListItem from './FederatedRoomListItem'; +import { useInfiniteFederationSearchPublicRooms } from './useInfiniteFederationSearchPublicRooms'; + +type FederatedRoomListProps = { + serverName: string; + roomName?: string; + pageToken?: string; + count?: number; +}; + +const FederatedRoomList = ({ serverName, roomName, count }: FederatedRoomListProps) => { + const joinExternalPublicRoom = useEndpoint('POST', '/v1/federation/joinExternalPublicRoom'); + + const setModal = useSetModal(); + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const { data, isLoading, isFetchingNextPage, fetchNextPage } = useInfiniteFederationSearchPublicRooms(serverName, roomName, count); + + const { mutate: onClickJoin, isLoading: isLoadingMutation } = useMutation( + ['federation/joinExternalPublicRoom'], + async ({ id, pageToken }: IFederationPublicRooms) => { + return joinExternalPublicRoom({ externalRoomId: id as `!${string}:${string}`, roomName, pageToken }); + }, + { + onSuccess: (_, data) => { + dispatchToastMessage({ + type: 'success', + message: t('Your_request_to_join__roomName__has_been_made_it_could_take_up_to_15_minutes_to_be_processed', { + roomName: data.name, + }), + }); + setModal(null); + }, + onError: (error, { id }) => { + if (error instanceof Error && error.message === 'already-joined') { + setModal(null); + roomCoordinator.openRouteLink('c', { rid: id }); + return; + } + + dispatchToastMessage({ type: 'error', message: error }); + }, + }, + ); + + if (isLoading) { + return ; + } + + const flattenedData = data?.pages.flatMap((page) => page.rooms); + return ( + + room?.id || index} + overscan={4} + components={{ + // eslint-disable-next-line react/no-multi-comp + Footer: () => (isFetchingNextPage ? : null), + Scroller: VirtuosoScrollbars, + EmptyPlaceholder: FederatedRoomListEmptyPlaceholder, + }} + endReached={isLoading || isFetchingNextPage ? () => undefined : () => fetchNextPage()} + itemContent={(_, room) => ( + onClickJoin(room)} {...room} disabled={isLoadingMutation} key={room.id} /> + )} + /> + + ); +}; + +export default FederatedRoomList; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx new file mode 100644 index 000000000000..8f0a26222679 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx @@ -0,0 +1,17 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import GenericNoResults from '../../../components/GenericNoResults'; + +const FederatedRoomListEmptyPlaceholder = () => { + const t = useTranslation(); + + return ( + + + + ); +}; + +export default FederatedRoomListEmptyPlaceholder; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx new file mode 100644 index 000000000000..6931c98f219e --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx @@ -0,0 +1,45 @@ +import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +type FederatedRoomListErrorBoundaryProps = { + children?: ReactNode; + resetKeys?: unknown[]; +}; + +const FederatedRoomListErrorBoundary = ({ children, resetKeys }: FederatedRoomListErrorBoundaryProps) => { + const t = useTranslation(); + + return ( + + {({ reset }) => ( + ( + + + {t('Error')} + {t('Error_something_went_wrong')} + + { + reset(); + resetErrorBoundary(); + }} + > + {t('Reload')} + + + + )} + /> + )} + + ); +}; + +export default FederatedRoomListErrorBoundary; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx new file mode 100644 index 000000000000..dfaa79ed44de --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/FederatedRoomListItem.tsx @@ -0,0 +1,64 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { IFederationPublicRooms } from '@rocket.chat/rest-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +type FederatedRoomListItemProps = IFederationPublicRooms & { + disabled: boolean; + onClickJoin: () => void; +}; + +const clampLine = css` + line-clamp: 6; +`; + +const FederatedRoomListItem = ({ + name, + topic, + canonicalAlias, + joinedMembers, + onClickJoin, + canJoin, + disabled, +}: FederatedRoomListItemProps) => { + const t = useTranslation(); + + return ( + + + + {name} + + {canJoin && ( + + {t('Join')} + + )} + {/* Currently canJoin is only false when the ammount of members is too big. This property will be used in the future + in case the matrix room is knock only. When that happens, the check for this should be based on the limit setting. */} + {!canJoin && ( + + {t('Cant_join')} + + )} + + + {topic && ( + + {topic} + + )} + + + {canonicalAlias}{' '} + + + {joinedMembers} + + + + ); +}; + +export default FederatedRoomListItem; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx new file mode 100644 index 000000000000..e3c953dcb950 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx @@ -0,0 +1,98 @@ +import { Divider, Modal, ButtonGroup, Button, Field, TextInput, FieldLabel, FieldRow, FieldError, FieldHint } from '@rocket.chat/fuselage'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useSetModal, useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { FormEvent } from 'react'; +import React, { useState } from 'react'; + +import MatrixFederationRemoveServerList from './MatrixFederationRemoveServerList'; +import MatrixFederationSearch from './MatrixFederationSearch'; +import { useMatrixServerList } from './useMatrixServerList'; + +type MatrixFederationAddServerModalProps = { + onClickClose: () => void; +}; + +const getErrorKey = (error: any): TranslationKey | undefined => { + if (!error) { + return; + } + if (error.error === 'invalid-server-name') { + return 'Server_doesnt_exist'; + } + if (error.error === 'invalid-server-name') { + return 'Server_already_added'; + } +}; + +const MatrixFederationAddServerModal = ({ onClickClose }: MatrixFederationAddServerModalProps) => { + const t = useTranslation(); + const addMatrixServer = useEndpoint('POST', '/v1/federation/addServerByUser'); + const [serverName, setServerName] = useState(''); + const [errorKey, setErrorKey] = useState(); + const setModal = useSetModal(); + const queryClient = useQueryClient(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { + mutate: addServer, + isLoading, + isError, + } = useMutation(['v1/federation/addServerByUser', serverName], () => addMatrixServer({ serverName }), { + onSuccess: async () => { + await queryClient.invalidateQueries(['federation/listServersByUsers']); + setModal(); + }, + onError: (error) => { + const errorKey = getErrorKey(error); + if (!errorKey) { + dispatchToastMessage({ type: 'error', message: error }); + return; + } + setErrorKey(errorKey); + }, + }); + + const { data, isLoading: isLoadingServerList } = useMatrixServerList(); + + return ( + + + {t('Manage_servers')} + + + + + {t('Server_name')} + + ) => { + setServerName(e.currentTarget.value); + if (errorKey) { + setErrorKey(undefined); + } + }} + mie={4} + /> + addServer()} primary loading={isLoading}> + {t('Add')} + + + {isError && errorKey && {t(errorKey)}} + {t('Federation_Example_matrix_server')} + + + {!isLoadingServerList && data?.servers && } + + + + {t('Cancel')} + + + + ); +}; + +export default MatrixFederationAddServerModal; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx new file mode 100644 index 000000000000..361950cd39c9 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx @@ -0,0 +1,60 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Option, Icon } from '@rocket.chat/fuselage'; +import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import React from 'react'; + +type MatrixFederationRemoveServerListProps = { + servers: Array<{ name: string; default: boolean; local: boolean }>; +}; + +const style = css` + i { + visibility: hidden; + } + li { + cursor: default; + } + li:hover { + i { + cursor: pointer; + visibility: visible; + } + } +`; + +const MatrixFederationRemoveServerList = ({ servers }: MatrixFederationRemoveServerListProps) => { + const removeMatrixServer = useEndpoint('POST', '/v1/federation/removeServerByUser'); + + const queryClient = useQueryClient(); + + const { mutate: removeServer, isLoading: isRemovingServer } = useMutation( + ['federation/removeServerByUser'], + (serverName: string) => removeMatrixServer({ serverName }), + { onSuccess: () => queryClient.invalidateQueries(['federation/listServersByUsers']) }, + ); + + const t = useTranslation(); + + return ( + + + {t('Servers')} + + {servers.map(({ name, default: isDefault }) => ( + + {!isDefault && ( + (isRemovingServer ? null : removeServer(name))} + /> + )} + + ))} + + ); +}; + +export default MatrixFederationRemoveServerList; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx new file mode 100644 index 000000000000..f3dc779d28c1 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearch.tsx @@ -0,0 +1,41 @@ +import { Modal, Skeleton } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import MatrixFederationSearchModalContent from './MatrixFederationSearchModalContent'; +import { useMatrixServerList } from './useMatrixServerList'; + +type MatrixFederationSearchProps = { + onClose: () => void; + defaultSelectedServer?: string; +}; + +const MatrixFederationSearch = ({ onClose, defaultSelectedServer }: MatrixFederationSearchProps) => { + const t = useTranslation(); + const { data, isLoading } = useMatrixServerList(); + + return ( + + + {t('Federation_Federated_room_search')} + + + + {isLoading && ( + <> + + + + + > + )} + {!isLoading && data?.servers && ( + + )} + + + + ); +}; + +export default MatrixFederationSearch; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx new file mode 100644 index 000000000000..ec6396a83440 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx @@ -0,0 +1,68 @@ +import type { SelectOption } from '@rocket.chat/fuselage'; +import { Box, Select, TextInput } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; +import type { FormEvent } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; + +import FederatedRoomList from './FederatedRoomList'; +import FederatedRoomListErrorBoundary from './FederatedRoomListErrorBoundary'; +import MatrixFederationManageServersModal from './MatrixFederationManageServerModal'; +import MatrixFederationSearch from './MatrixFederationSearch'; + +type MatrixFederationSearchModalContentProps = { + servers: Array<{ + name: string; + default: boolean; + local: boolean; + }>; + defaultSelectedServer?: string; +}; + +const MatrixFederationSearchModalContent = ({ defaultSelectedServer, servers }: MatrixFederationSearchModalContentProps) => { + const [serverName, setServerName] = useState(() => { + const defaultServer = servers.find((server) => server.name === defaultSelectedServer); + return defaultServer?.name ?? servers[0].name; + }); + + const [roomName, setRoomName] = useState(''); + + const setModal = useSetModal(); + + const debouncedRoomName = useDebouncedValue(roomName, 400); + + const t = useTranslation(); + + const serverOptions = useMemo>(() => servers.map((server): SelectOption => [server.name, server.name]), [servers]); + + const manageServers = useCallback(() => { + setModal( + setModal( setModal(null)} />)} />, + ); + }, [setModal]); + + return ( + <> + + + setServerName(String(value))} /> + + ) => setRoomName(e.currentTarget.value)} + /> + + + {t('Manage_server_list')} + + + + + > + ); +}; + +export default MatrixFederationSearchModalContent; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/index.ts b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/index.ts new file mode 100644 index 000000000000..8447180665a0 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/index.ts @@ -0,0 +1 @@ +export { default } from './MatrixFederationSearch'; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/useInfiniteFederationSearchPublicRooms.ts b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/useInfiniteFederationSearchPublicRooms.ts new file mode 100644 index 000000000000..6d80a7a9b383 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/useInfiniteFederationSearchPublicRooms.ts @@ -0,0 +1,18 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +const tenMinutes = 10 * 60 * 1000; + +export const useInfiniteFederationSearchPublicRooms = (serverName: string, roomName?: string, count?: number) => { + const fetchRoomList = useEndpoint('GET', '/v1/federation/searchPublicRooms'); + return useInfiniteQuery( + ['federation/searchPublicRooms', serverName, roomName, count], + async ({ pageParam }) => fetchRoomList({ serverName, roomName, count, pageToken: pageParam }), + { + getNextPageParam: (lastPage) => lastPage.nextPageToken, + useErrorBoundary: true, + staleTime: tenMinutes, + cacheTime: tenMinutes, + }, + ); +}; diff --git a/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/useMatrixServerList.ts b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/useMatrixServerList.ts new file mode 100644 index 000000000000..4f9ba64848f3 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/MatrixFederationSearch/useMatrixServerList.ts @@ -0,0 +1,10 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +export const useMatrixServerList = () => { + const fetchServerList = useEndpoint('GET', '/v1/federation/listServersByUser'); + return useQuery(['federation/listServersByUsers'], async () => fetchServerList(), { + useErrorBoundary: true, + staleTime: Infinity, + }); +}; diff --git a/apps/meteor/client/sidebarv2/header/SearchList.tsx b/apps/meteor/client/sidebarv2/header/SearchList.tsx new file mode 100644 index 000000000000..70c666fead50 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/SearchList.tsx @@ -0,0 +1,79 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation, useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; +import type { MouseEventHandler, ReactElement } from 'react'; +import React, { useMemo, useRef } from 'react'; +import { Virtuoso } from 'react-virtuoso'; + +import { VirtuosoScrollbars } from '../../components/CustomScrollbars'; +import RoomListWrapper from '../RoomList/RoomListWrapper'; +import { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import { usePreventDefault } from '../hooks/usePreventDefault'; +import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; +import Row from '../search/Row'; +import { useSearchItems } from './hooks/useSearchItems'; + +type SearchListProps = { filterText: string; onEscSearch: () => void }; + +const SearchList = ({ filterText, onEscSearch }: SearchListProps) => { + const t = useTranslation(); + + const boxRef = useRef(null); + usePreventDefault(boxRef); + + const { data: items = [], isLoading } = useSearchItems(filterText); + + const sidebarViewMode = useUserPreference('sidebarViewMode'); + const useRealName = useSetting('UI_Use_Real_Name'); + + const sideBarItemTemplate = useTemplateByViewMode(); + const avatarTemplate = useAvatarTemplate(); + + const extended = sidebarViewMode === 'extended'; + + const itemData = useMemo( + () => ({ + items, + t, + SideBarItemTemplate: sideBarItemTemplate, + avatarTemplate, + useRealName, + extended, + sidebarViewMode, + }), + [avatarTemplate, extended, items, useRealName, sideBarItemTemplate, sidebarViewMode, t], + ); + + const handleClick: MouseEventHandler = (e): void => { + if (e.target instanceof Element && [e.target.tagName, e.target.parentElement?.tagName].includes('BUTTON')) { + return; + } + return onEscSearch(); + }; + + return ( + + room._id} + itemContent={(_, data): ReactElement => } + /> + + ); +}; + +export default SearchList; diff --git a/apps/meteor/client/sidebarv2/header/SearchSection.tsx b/apps/meteor/client/sidebarv2/header/SearchSection.tsx new file mode 100644 index 000000000000..66d8eb65371f --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/SearchSection.tsx @@ -0,0 +1,104 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Icon, TextInput, Palette, Sidebar } from '@rocket.chat/fuselage'; +import { useMergedRefs, useOutsideClick } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useUser } from '@rocket.chat/ui-contexts'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import tinykeys from 'tinykeys'; + +import SearchList from './SearchList'; +import CreateRoom from './actions/CreateRoom'; +import Sort from './actions/Sort'; + +const wrapperStyle = css` + position: absolute; + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + z-index: 99; + top: 0; + left: 0; + background-color: ${Palette.surface['surface-sidebar']}; +`; + +const SearchSection = () => { + const t = useTranslation(); + const user = useUser(); + + const { + formState: { isDirty }, + register, + watch, + resetField, + setFocus, + } = useForm({ defaultValues: { filterText: '' } }); + const { filterText } = watch(); + const { ref: filterRef, ...rest } = register('filterText'); + + const inputRef = useRef(null); + const wrapperRef = useRef(null); + const mergedRefs = useMergedRefs(filterRef, inputRef); + + const handleEscSearch = useCallback(() => { + resetField('filterText'); + inputRef.current?.blur(); + }, [resetField]); + + useOutsideClick([wrapperRef], handleEscSearch); + + useEffect(() => { + const unsubscribe = tinykeys(window, { + '$mod+K': (event) => { + event.preventDefault(); + setFocus('filterText'); + }, + '$mod+P': (event) => { + event.preventDefault(); + setFocus('filterText'); + }, + 'Escape': (event) => { + event.preventDefault(); + handleEscSearch(); + }, + }); + + return (): void => { + unsubscribe(); + }; + }, [handleEscSearch, setFocus]); + + return ( + + + } + /> + + {user && !isDirty && ( + <> + + + > + )} + + + {isDirty && } + + ); +}; + +export default SearchSection; diff --git a/apps/meteor/client/sidebarv2/header/actions/CreateRoom.tsx b/apps/meteor/client/sidebarv2/header/actions/CreateRoom.tsx new file mode 100644 index 000000000000..478e7cce33e1 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/CreateRoom.tsx @@ -0,0 +1,19 @@ +import { Sidebar } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../../components/GenericMenu/GenericMenu'; +import { useCreateRoom } from './hooks/useCreateRoomMenu'; + +type CreateRoomProps = Omit, 'is'>; + +const CreateRoom = (props: CreateRoomProps) => { + const t = useTranslation(); + + const sections = useCreateRoom(); + + return ; +}; + +export default CreateRoom; diff --git a/apps/meteor/client/sidebarv2/header/actions/Search.tsx b/apps/meteor/client/sidebarv2/header/actions/Search.tsx new file mode 100644 index 000000000000..06d42114d76b --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/Search.tsx @@ -0,0 +1,50 @@ +import { Sidebar } from '@rocket.chat/fuselage'; +import { useEffectEvent, useOutsideClick } from '@rocket.chat/fuselage-hooks'; +import type { HTMLAttributes } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import tinykeys from 'tinykeys'; + +import SearchList from '../../search/SearchList'; + +type SearchProps = Omit, 'is'>; + +const Search = (props: SearchProps) => { + const [searchOpen, setSearchOpen] = useState(false); + + const ref = useRef(null); + const handleCloseSearch = useEffectEvent(() => { + setSearchOpen(false); + }); + + useOutsideClick([ref], handleCloseSearch); + + const openSearch = useEffectEvent(() => { + setSearchOpen(true); + }); + + useEffect(() => { + const unsubscribe = tinykeys(window, { + '$mod+K': (event) => { + event.preventDefault(); + openSearch(); + }, + '$mod+P': (event) => { + event.preventDefault(); + openSearch(); + }, + }); + + return (): void => { + unsubscribe(); + }; + }, [openSearch]); + + return ( + <> + + {searchOpen && } + > + ); +}; + +export default Search; diff --git a/apps/meteor/client/sidebarv2/header/actions/Sort.tsx b/apps/meteor/client/sidebarv2/header/actions/Sort.tsx new file mode 100644 index 000000000000..e7f3b398e5f6 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/Sort.tsx @@ -0,0 +1,21 @@ +import { Sidebar } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { HTMLAttributes } from 'react'; +import React from 'react'; + +import GenericMenu from '../../../components/GenericMenu/GenericMenu'; +import { useSortMenu } from './hooks/useSortMenu'; + +type SortProps = Omit, 'is'>; + +const Sort = (props: SortProps) => { + const t = useTranslation(); + + const sections = useSortMenu(); + + return ( + + ); +}; + +export default Sort; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx new file mode 100644 index 000000000000..3935ad0039df --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomItems.tsx @@ -0,0 +1,68 @@ +import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts'; + +import CreateDiscussion from '../../../../components/CreateDiscussion'; +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import CreateChannelModal from '../../CreateChannelModal'; +import CreateDirectMessage from '../../CreateDirectMessage'; +import CreateTeamModal from '../../CreateTeamModal'; +import { useCreateRoomModal } from '../../hooks/useCreateRoomModal'; + +const CREATE_CHANNEL_PERMISSIONS = ['create-c', 'create-p']; +const CREATE_TEAM_PERMISSIONS = ['create-team']; +const CREATE_DIRECT_PERMISSIONS = ['create-d']; +const CREATE_DISCUSSION_PERMISSIONS = ['start-discussion', 'start-discussion-other-user']; + +export const useCreateRoomItems = (): GenericMenuItemProps[] => { + const t = useTranslation(); + const discussionEnabled = useSetting('Discussion_enabled'); + + const canCreateChannel = useAtLeastOnePermission(CREATE_CHANNEL_PERMISSIONS); + const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS); + const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS); + const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS); + + const createChannel = useCreateRoomModal(CreateChannelModal); + const createTeam = useCreateRoomModal(CreateTeamModal); + const createDiscussion = useCreateRoomModal(CreateDiscussion); + const createDirectMessage = useCreateRoomModal(CreateDirectMessage); + + const createChannelItem: GenericMenuItemProps = { + id: 'channel', + content: t('Channel'), + icon: 'hashtag', + onClick: () => { + createChannel(); + }, + }; + const createTeamItem: GenericMenuItemProps = { + id: 'team', + content: t('Team'), + icon: 'team', + onClick: () => { + createTeam(); + }, + }; + const createDirectMessageItem: GenericMenuItemProps = { + id: 'direct', + content: t('Direct_message'), + icon: 'balloon', + onClick: () => { + createDirectMessage(); + }, + }; + const createDiscussionItem: GenericMenuItemProps = { + id: 'discussion', + content: t('Discussion'), + icon: 'discussion', + onClick: () => { + createDiscussion(); + }, + }; + + return [ + ...(canCreateDirectMessages ? [createDirectMessageItem] : []), + ...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []), + ...(canCreateChannel ? [createChannelItem] : []), + ...(canCreateTeam ? [createTeamItem] : []), + ]; +}; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomMenu.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomMenu.tsx new file mode 100644 index 000000000000..6a0c58b36311 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useCreateRoomMenu.tsx @@ -0,0 +1,25 @@ +import { useAtLeastOnePermission, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; + +import { useIsEnterprise } from '../../../../hooks/useIsEnterprise'; +import { useCreateRoomItems } from './useCreateRoomItems'; +import { useMatrixFederationItems } from './useMatrixFederationItems'; + +const CREATE_ROOM_PERMISSIONS = ['create-c', 'create-p', 'create-d', 'start-discussion', 'start-discussion-other-user']; + +export const useCreateRoom = () => { + const t = useTranslation(); + const showCreate = useAtLeastOnePermission(CREATE_ROOM_PERMISSIONS); + + const { data } = useIsEnterprise(); + const isMatrixEnabled = useSetting('Federation_Matrix_enabled') && data?.isEnterprise; + + const createRoomItems = useCreateRoomItems(); + const matrixFederationSearchItems = useMatrixFederationItems({ isMatrixEnabled }); + + const sections = [ + { title: t('Create_new'), items: createRoomItems, permission: showCreate }, + { title: t('Explore'), items: matrixFederationSearchItems, permission: showCreate && isMatrixEnabled }, + ]; + + return sections.filter((section) => section.permission); +}; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.spec.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.spec.tsx new file mode 100644 index 000000000000..b5779d825202 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.spec.tsx @@ -0,0 +1,25 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useGroupingListItems } from './useGroupingListItems'; + +it('should render groupingList items', async () => { + const { result } = renderHook(() => useGroupingListItems()); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'unread', + }), + ); + + expect(result.current[1]).toEqual( + expect.objectContaining({ + id: 'favorites', + }), + ); + + expect(result.current[2]).toEqual( + expect.objectContaining({ + id: 'types', + }), + ); +}); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx new file mode 100644 index 000000000000..646b85c838be --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useGroupingListItems.tsx @@ -0,0 +1,43 @@ +import { CheckBox } from '@rocket.chat/fuselage'; +import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useCallback } from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; + +export const useGroupingListItems = (): GenericMenuItemProps[] => { + const t = useTranslation(); + + const sidebarGroupByType = useUserPreference('sidebarGroupByType'); + const sidebarShowFavorites = useUserPreference('sidebarShowFavorites'); + const sidebarShowUnread = useUserPreference('sidebarShowUnread'); + + const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + + const useHandleChange = (key: 'sidebarGroupByType' | 'sidebarShowFavorites' | 'sidebarShowUnread', value: boolean): (() => void) => + useCallback(() => saveUserPreferences({ data: { [key]: value } }), [key, value]); + + const handleChangeGroupByType = useHandleChange('sidebarGroupByType', !sidebarGroupByType); + const handleChangeShoFavorite = useHandleChange('sidebarShowFavorites', !sidebarShowFavorites); + const handleChangeShowUnread = useHandleChange('sidebarShowUnread', !sidebarShowUnread); + + return [ + { + id: 'unread', + content: t('Unread'), + icon: 'flag', + addon: , + }, + { + id: 'favorites', + content: t('Favorites'), + icon: 'star', + addon: , + }, + { + id: 'types', + content: t('Types'), + icon: 'group-by-type', + addon: , + }, + ]; +}; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts b/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts new file mode 100644 index 000000000000..b3ac63f13773 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useMatrixFederationItems.ts @@ -0,0 +1,26 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import MatrixFederationSearch from '../../MatrixFederationSearch'; +import { useCreateRoomModal } from '../../hooks/useCreateRoomModal'; + +export const useMatrixFederationItems = ({ + isMatrixEnabled, +}: { + isMatrixEnabled: string | number | boolean | null | undefined; +}): GenericMenuItemProps[] => { + const t = useTranslation(); + + const searchFederatedRooms = useCreateRoomModal(MatrixFederationSearch); + + const matrixFederationSearchItem: GenericMenuItemProps = { + id: 'matrix-federation-search', + content: t('Federation_Search_federated_rooms'), + icon: 'magnifier', + onClick: () => { + searchFederatedRooms(); + }, + }; + + return [...(isMatrixEnabled ? [matrixFederationSearchItem] : [])]; +}; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx new file mode 100644 index 000000000000..bea1d999997e --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortMenu.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; + +import { useGroupingListItems } from './useGroupingListItems'; +import { useSortModeItems } from './useSortModeItems'; +import { useViewModeItems } from './useViewModeItems'; + +export const useSortMenu = () => { + const t = useTranslation(); + + const viewModeItems = useViewModeItems(); + const sortModeItems = useSortModeItems(); + const groupingListItems = useGroupingListItems(); + + const sections = [ + { title: t('Display'), items: viewModeItems }, + { title: t('Sort_By'), items: sortModeItems }, + { title: t('Group_by'), items: groupingListItems }, + ]; + + return sections; +}; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.spec.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.spec.tsx new file mode 100644 index 000000000000..143d228fe7ca --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.spec.tsx @@ -0,0 +1,19 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useSortModeItems } from './useSortModeItems'; + +it('should render sortMode items', async () => { + const { result } = renderHook(() => useSortModeItems()); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'activity', + }), + ); + + expect(result.current[1]).toEqual( + expect.objectContaining({ + id: 'name', + }), + ); +}); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx new file mode 100644 index 000000000000..56041ab4e571 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useSortModeItems.tsx @@ -0,0 +1,40 @@ +import { RadioButton } from '@rocket.chat/fuselage'; +import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useCallback } from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import { + OmnichannelSortingDisclaimer, + useOmnichannelSortingDisclaimer, +} from '../../../../components/Omnichannel/OmnichannelSortingDisclaimer'; + +export const useSortModeItems = (): GenericMenuItemProps[] => { + const t = useTranslation(); + + const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + const sidebarSortBy = useUserPreference<'activity' | 'alphabetical'>('sidebarSortby', 'activity'); + const isOmnichannelEnabled = useOmnichannelSortingDisclaimer(); + + const useHandleChange = (value: 'alphabetical' | 'activity'): (() => void) => + useCallback(() => saveUserPreferences({ data: { sidebarSortby: value } }), [value]); + + const setToAlphabetical = useHandleChange('alphabetical'); + const setToActivity = useHandleChange('activity'); + + return [ + { + id: 'activity', + content: t('Activity'), + icon: 'clock', + addon: , + description: sidebarSortBy === 'activity' && isOmnichannelEnabled && , + }, + { + id: 'name', + content: t('Name'), + icon: 'sort-az', + addon: , + description: sidebarSortBy === 'alphabetical' && isOmnichannelEnabled && , + }, + ]; +}; diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.spec.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.spec.tsx new file mode 100644 index 000000000000..6c6dd7532e7e --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.spec.tsx @@ -0,0 +1,31 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useViewModeItems } from './useViewModeItems'; + +it('should render viewMode items', async () => { + const { result } = renderHook(() => useViewModeItems()); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'extended', + }), + ); + + expect(result.current[1]).toEqual( + expect.objectContaining({ + id: 'medium', + }), + ); + + expect(result.current[2]).toEqual( + expect.objectContaining({ + id: 'condensed', + }), + ); + + expect(result.current[3]).toEqual( + expect.objectContaining({ + id: 'avatars', + }), + ); +}); diff --git a/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx b/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx new file mode 100644 index 000000000000..ca2855d09db5 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/actions/hooks/useViewModeItems.tsx @@ -0,0 +1,53 @@ +import { RadioButton, ToggleSwitch } from '@rocket.chat/fuselage'; +import { useEndpoint, useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; +import React, { useCallback } from 'react'; + +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; + +export const useViewModeItems = (): GenericMenuItemProps[] => { + const t = useTranslation(); + + const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences'); + + const useHandleChange = (value: 'medium' | 'extended' | 'condensed'): (() => void) => + useCallback(() => saveUserPreferences({ data: { sidebarViewMode: value } }), [value]); + + const sidebarViewMode = useUserPreference<'medium' | 'extended' | 'condensed'>('sidebarViewMode', 'extended'); + const sidebarDisplayAvatar = useUserPreference('sidebarDisplayAvatar', false); + + const setToExtended = useHandleChange('extended'); + const setToMedium = useHandleChange('medium'); + const setToCondensed = useHandleChange('condensed'); + + const handleChangeSidebarDisplayAvatar = useCallback( + () => saveUserPreferences({ data: { sidebarDisplayAvatar: !sidebarDisplayAvatar } }), + [saveUserPreferences, sidebarDisplayAvatar], + ); + + return [ + { + id: 'extended', + content: t('Extended'), + icon: 'extended-view', + addon: , + }, + { + id: 'medium', + content: t('Medium'), + icon: 'medium-view', + addon: , + }, + { + id: 'condensed', + content: t('Condensed'), + icon: 'condensed-view', + addon: , + }, + { + id: 'avatars', + content: t('Avatars'), + icon: 'user-rounded', + addon: , + }, + ]; +}; diff --git a/apps/meteor/client/sidebarv2/header/hooks/useCreateRoomModal.tsx b/apps/meteor/client/sidebarv2/header/hooks/useCreateRoomModal.tsx new file mode 100644 index 000000000000..70e14f80adf6 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/hooks/useCreateRoomModal.tsx @@ -0,0 +1,16 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import type { FC } from 'react'; +import React from 'react'; + +export const useCreateRoomModal = (Component: FC): (() => void) => { + const setModal = useSetModal(); + + return useEffectEvent(() => { + const handleClose = (): void => { + setModal(null); + }; + + setModal(() => ); + }); +}; diff --git a/apps/meteor/client/sidebarv2/header/hooks/useDropdownVisibility.ts b/apps/meteor/client/sidebarv2/header/hooks/useDropdownVisibility.ts new file mode 100644 index 000000000000..390486d1727d --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/hooks/useDropdownVisibility.ts @@ -0,0 +1,38 @@ +import { useToggle, useOutsideClick } from '@rocket.chat/fuselage-hooks'; +import type { RefObject } from 'react'; +import { useCallback } from 'react'; + +/** + * useDropdownVisibility + * is used to control the visibility of a dropdown + * also checks if the user clicked outside the dropdown, but ignores if the click was on the anchor + * @param {Object} props + * @param {Object} props.reference - The reference where the dropdown will be attached to + * @param {Object} props.target - The target, the dropdown itself + * @returns {Object} + * @returns {Boolean} isVisible - The visibility of the dropdown + * @returns {Function} toggle - The function to toggle the dropdown + */ + +export const useDropdownVisibility = ({ + reference, + target, +}: { + reference: RefObject; + target: RefObject; +}): { + isVisible: boolean; + toggle: (state?: boolean) => void; +} => { + const [isVisible, toggle] = useToggle(false); + + useOutsideClick( + [target, reference], + useCallback(() => toggle(false), [toggle]), + ); + + return { + isVisible, + toggle, + }; +}; diff --git a/apps/meteor/client/sidebarv2/header/hooks/useEncryptedRoomDescription.tsx b/apps/meteor/client/sidebarv2/header/hooks/useEncryptedRoomDescription.tsx new file mode 100644 index 000000000000..09796dd7a6b7 --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/hooks/useEncryptedRoomDescription.tsx @@ -0,0 +1,23 @@ +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; + +export const useEncryptedRoomDescription = (roomType: 'channel' | 'team') => { + const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); + + return ({ isPrivate, broadcast, encrypted }: { isPrivate: boolean; broadcast: boolean; encrypted: boolean }) => { + if (!e2eEnabled) { + return t('Not_available_for_this_workspace'); + } + if (!isPrivate) { + return t('Encrypted_not_available', { roomType }); + } + if (broadcast) { + return t('Not_available_for_broadcast', { roomType }); + } + if (e2eEnabledForPrivateByDefault || encrypted) { + return t('Encrypted_messages', { roomType }); + } + return t('Encrypted_messages_false'); + }; +}; diff --git a/apps/meteor/client/sidebarv2/header/hooks/useSearchItems.ts b/apps/meteor/client/sidebarv2/header/hooks/useSearchItems.ts new file mode 100644 index 000000000000..7dfdb577dd9f --- /dev/null +++ b/apps/meteor/client/sidebarv2/header/hooks/useSearchItems.ts @@ -0,0 +1,114 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { useMethod, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { getConfig } from '../../../lib/utils/getConfig'; + +const LIMIT = parseInt(String(getConfig('Sidebar_Search_Spotlight_LIMIT', 20))); + +const options = { + sort: { + lm: -1, + name: 1, + }, + limit: LIMIT, +} as const; + +export const useSearchItems = (filterText: string): UseQueryResult<(ISubscription & IRoom)[] | undefined, Error> => { + const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]); + const query = useMemo(() => { + const filterRegex = new RegExp(escapeRegExp(name), 'i'); + + return { + $or: [{ name: filterRegex }, { fname: filterRegex }], + ...(mention && { + t: mention === '@' ? 'd' : { $ne: 'd' }, + }), + }; + }, [name, mention]); + + const localRooms = useUserSubscriptions(query, options); + + const usernamesFromClient = [...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean) as string[]; + + const searchForChannels = mention === '#'; + const searchForDMs = mention === '@'; + + const type = useMemo(() => { + if (searchForChannels) { + return { users: false, rooms: true, includeFederatedRooms: true }; + } + if (searchForDMs) { + return { users: true, rooms: false }; + } + return { users: true, rooms: true, includeFederatedRooms: true }; + }, [searchForChannels, searchForDMs]); + + const getSpotlight = useMethod('spotlight'); + + return useQuery( + ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)], + async () => { + if (localRooms.length === LIMIT) { + return localRooms; + } + + const spotlight = await getSpotlight(name, usernamesFromClient, type); + + const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => + index === arr.findIndex((user) => _id === user._id); + + const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => + !localRooms.find( + (item) => + (room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || + [item.rid, item._id].includes(room._id), + ); + const usersFilter = (user: { _id: string }): boolean => + !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); + + const userMap = (user: { + _id: string; + name: string; + username: string; + avatarETag?: string; + }): { + _id: string; + t: string; + name: string; + fname: string; + avatarETag?: string; + } => ({ + _id: user._id, + t: 'd', + name: user.username, + fname: user.name, + avatarETag: user.avatarETag, + }); + + type resultsFromServerType = { + _id: string; + t: string; + name: string; + teamMain?: boolean; + fname?: string; + avatarETag?: string | undefined; + uids?: string[] | undefined; + }[]; + + const resultsFromServer: resultsFromServerType = []; + resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap)); + resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); + + const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); + return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])); + }, + { + staleTime: 60_000, + keepPreviousData: true, + placeholderData: localRooms, + }, + ); +}; diff --git a/apps/meteor/client/sidebarv2/hooks/useAvatarTemplate.tsx b/apps/meteor/client/sidebarv2/hooks/useAvatarTemplate.tsx new file mode 100644 index 000000000000..9fd1023a32e7 --- /dev/null +++ b/apps/meteor/client/sidebarv2/hooks/useAvatarTemplate.tsx @@ -0,0 +1,39 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ComponentType } from 'react'; +import React, { useMemo } from 'react'; + +export const useAvatarTemplate = ( + sidebarViewMode?: 'extended' | 'medium' | 'condensed', + sidebarDisplayAvatar?: boolean, +): null | ComponentType => { + const sidebarViewModeFromSettings = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode'); + const sidebarDisplayAvatarFromSettings = useUserPreference('sidebarDisplayAvatar'); + + const viewMode = sidebarViewMode ?? sidebarViewModeFromSettings; + const displayAvatar = sidebarDisplayAvatar ?? sidebarDisplayAvatarFromSettings; + return useMemo(() => { + if (!displayAvatar) { + return null; + } + + const size = ((): 'x36' | 'x28' | 'x16' => { + switch (viewMode) { + case 'extended': + return 'x36'; + case 'medium': + return 'x28'; + case 'condensed': + default: + return 'x16'; + } + })(); + + const renderRoomAvatar: ComponentType = (room) => ( + + ); + + return renderRoomAvatar; + }, [displayAvatar, viewMode]); +}; diff --git a/apps/meteor/client/sidebarv2/hooks/usePreventDefault.ts b/apps/meteor/client/sidebarv2/hooks/usePreventDefault.ts new file mode 100644 index 000000000000..9d3ca18da35e --- /dev/null +++ b/apps/meteor/client/sidebarv2/hooks/usePreventDefault.ts @@ -0,0 +1,21 @@ +import type { RefObject } from 'react'; +import { useEffect } from 'react'; + +export const usePreventDefault = (ref: RefObject): { ref: RefObject } => { + // Flowrouter uses an addEventListener on the document to capture any clink link, since the react synthetic event use an addEventListener on the document too, + // it is impossible/hard to determine which one will happen before and prevent/stop propagation, so feel free to remove this effect after remove flow router :) + + useEffect(() => { + const { current } = ref; + const stopPropagation: EventListener = (e) => { + if ([(e.target as HTMLElement).nodeName, (e.target as HTMLElement).parentElement?.nodeName].includes('BUTTON')) { + e.preventDefault(); + } + }; + current?.addEventListener('click', stopPropagation); + + return (): void => current?.addEventListener('click', stopPropagation); + }, [ref]); + + return { ref }; +}; diff --git a/apps/meteor/client/sidebarv2/hooks/useQueryOptions.ts b/apps/meteor/client/sidebarv2/hooks/useQueryOptions.ts new file mode 100644 index 000000000000..55fd137759d1 --- /dev/null +++ b/apps/meteor/client/sidebarv2/hooks/useQueryOptions.ts @@ -0,0 +1,32 @@ +import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +export const useQueryOptions = (): { + sort: + | { + lm?: -1 | 1 | undefined; + } + | { + lowerCaseFName: -1 | 1; + lm?: -1 | 1 | undefined; + } + | { + lowerCaseName: -1 | 1; + lm?: -1 | 1 | undefined; + }; +} => { + const sortBy = useUserPreference('sidebarSortby'); + const showRealName = useSetting('UI_Use_Real_Name'); + + return useMemo( + () => ({ + sort: { + ...(sortBy === 'activity' && { lm: -1 }), + ...(sortBy !== 'activity' && { + ...(showRealName ? { lowerCaseFName: 1 } : { lowerCaseName: 1 }), + }), + }, + }), + [sortBy, showRealName], + ); +}; diff --git a/apps/meteor/client/sidebarv2/hooks/useRoomList.ts b/apps/meteor/client/sidebarv2/hooks/useRoomList.ts new file mode 100644 index 000000000000..fa5dfd2797cb --- /dev/null +++ b/apps/meteor/client/sidebarv2/hooks/useRoomList.ts @@ -0,0 +1,122 @@ +import type { ILivechatInquiryRecord, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { useDebouncedState } from '@rocket.chat/fuselage-hooks'; +import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { useVideoConfIncomingCalls } from '../../contexts/VideoConfContext'; +import { useOmnichannelEnabled } from '../../hooks/omnichannel/useOmnichannelEnabled'; +import { useQueuedInquiries } from '../../hooks/omnichannel/useQueuedInquiries'; +import { useQueryOptions } from './useQueryOptions'; + +const query = { open: { $ne: false } }; + +const emptyQueue: ILivechatInquiryRecord[] = []; + +export const useRoomList = (): Array => { + const [roomList, setRoomList] = useDebouncedState<(ISubscription & IRoom)[]>([], 150); + + const showOmnichannel = useOmnichannelEnabled(); + const sidebarGroupByType = useUserPreference('sidebarGroupByType'); + const favoritesEnabled = useUserPreference('sidebarShowFavorites'); + const isDiscussionEnabled = useSetting('Discussion_enabled'); + const sidebarShowUnread = useUserPreference('sidebarShowUnread'); + + const options = useQueryOptions(); + + const rooms = useUserSubscriptions(query, options); + + const inquiries = useQueuedInquiries(); + + const incomingCalls = useVideoConfIncomingCalls(); + + let queue = emptyQueue; + if (inquiries.enabled) { + queue = inquiries.queue; + } + + useEffect(() => { + setRoomList(() => { + const incomingCall = new Set(); + const favorite = new Set(); + const team = new Set(); + const omnichannel = new Set(); + const unread = new Set(); + const channels = new Set(); + const direct = new Set(); + const discussion = new Set(); + const conversation = new Set(); + const onHold = new Set(); + + rooms.forEach((room) => { + if (room.archived) { + return; + } + + if (incomingCalls.find((call) => call.rid === room.rid)) { + return incomingCall.add(room); + } + + if (sidebarShowUnread && (room.alert || room.unread) && !room.hideUnreadStatus) { + return unread.add(room); + } + + if (favoritesEnabled && room.f) { + return favorite.add(room); + } + + if (sidebarGroupByType && room.teamMain) { + return team.add(room); + } + + if (sidebarGroupByType && isDiscussionEnabled && room.prid) { + return discussion.add(room); + } + + if (room.t === 'c' || room.t === 'p') { + channels.add(room); + } + + if (room.t === 'l' && room.onHold) { + return showOmnichannel && onHold.add(room); + } + + if (room.t === 'l') { + return showOmnichannel && omnichannel.add(room); + } + + if (room.t === 'd') { + direct.add(room); + } + + conversation.add(room); + }); + + const groups = new Map(); + incomingCall.size && groups.set('Incoming Calls', incomingCall); + showOmnichannel && inquiries.enabled && queue.length && groups.set('Incoming_Livechats', queue); + showOmnichannel && omnichannel.size && groups.set('Open_Livechats', omnichannel); + showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); + sidebarShowUnread && unread.size && groups.set('Unread', unread); + favoritesEnabled && favorite.size && groups.set('Favorites', favorite); + sidebarGroupByType && team.size && groups.set('Teams', team); + sidebarGroupByType && isDiscussionEnabled && discussion.size && groups.set('Discussions', discussion); + sidebarGroupByType && channels.size && groups.set('Channels', channels); + sidebarGroupByType && direct.size && groups.set('Direct_Messages', direct); + !sidebarGroupByType && groups.set('Conversations', conversation); + return [...groups.entries()].flatMap(([key, group]) => [key, ...group]); + }); + }, [ + rooms, + showOmnichannel, + incomingCalls, + inquiries.enabled, + queue, + sidebarShowUnread, + favoritesEnabled, + sidebarGroupByType, + setRoomList, + isDiscussionEnabled, + ]); + + return roomList; +}; diff --git a/apps/meteor/client/sidebarv2/hooks/useShortcutOpenMenu.ts b/apps/meteor/client/sidebarv2/hooks/useShortcutOpenMenu.ts new file mode 100644 index 000000000000..9898e67040e1 --- /dev/null +++ b/apps/meteor/client/sidebarv2/hooks/useShortcutOpenMenu.ts @@ -0,0 +1,21 @@ +import type { RefObject } from 'react'; +import { useEffect } from 'react'; +import tinykeys from 'tinykeys'; + +// used to open the menu option by keyboard +export const useShortcutOpenMenu = (ref: RefObject): void => { + useEffect(() => { + const unsubscribe = tinykeys(ref.current as HTMLElement, { + Alt: (event) => { + if (!(event.target as HTMLElement).className.includes('rcx-sidebar-item')) { + return; + } + event.preventDefault(); + (event.target as HTMLElement).querySelector('button')?.click(); + }, + }); + return (): void => { + unsubscribe(); + }; + }, [ref]); +}; diff --git a/apps/meteor/client/sidebarv2/hooks/useTemplateByViewMode.ts b/apps/meteor/client/sidebarv2/hooks/useTemplateByViewMode.ts new file mode 100644 index 000000000000..2362669f3ebd --- /dev/null +++ b/apps/meteor/client/sidebarv2/hooks/useTemplateByViewMode.ts @@ -0,0 +1,22 @@ +import { useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ComponentType } from 'react'; +import { useMemo } from 'react'; + +import Condensed from '../Item/Condensed'; +import Extended from '../Item/Extended'; +import Medium from '../Item/Medium'; + +export const useTemplateByViewMode = (): ComponentType => { + const sidebarViewMode = useUserPreference('sidebarViewMode'); + return useMemo(() => { + switch (sidebarViewMode) { + case 'extended': + return Extended; + case 'medium': + return Medium; + case 'condensed': + default: + return Condensed; + } + }, [sidebarViewMode]); +}; diff --git a/apps/meteor/client/sidebarv2/index.ts b/apps/meteor/client/sidebarv2/index.ts new file mode 100644 index 000000000000..55cd4f79dbf8 --- /dev/null +++ b/apps/meteor/client/sidebarv2/index.ts @@ -0,0 +1 @@ +export { default } from './SidebarRegion'; diff --git a/apps/meteor/client/sidebarv2/search/Row.tsx b/apps/meteor/client/sidebarv2/search/Row.tsx new file mode 100644 index 000000000000..68ceecd2ad88 --- /dev/null +++ b/apps/meteor/client/sidebarv2/search/Row.tsx @@ -0,0 +1,40 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import type { ReactElement } from 'react'; +import React, { memo } from 'react'; + +import SideBarItemTemplateWithData from '../RoomList/SideBarItemTemplateWithData'; +import UserItem from './UserItem'; + +type RowProps = { + item: ISubscription & IRoom; + data: Record; +}; + +const Row = ({ item, data }: RowProps): ReactElement => { + const { t, SideBarItemTemplate, avatarTemplate: AvatarTemplate, useRealName, extended } = data; + + if (item.t === 'd' && !item.u) { + return ( + + ); + } + return ( + + ); +}; + +export default memo(Row); diff --git a/apps/meteor/client/sidebarv2/search/SearchList.tsx b/apps/meteor/client/sidebarv2/search/SearchList.tsx new file mode 100644 index 000000000000..c43fe854ac30 --- /dev/null +++ b/apps/meteor/client/sidebarv2/search/SearchList.tsx @@ -0,0 +1,382 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Sidebar, TextInput, Box, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback, useDebouncedValue, useAutoFocus, useUniqueId, useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { useUserPreference, useUserSubscriptions, useSetting, useTranslation, useMethod } from '@rocket.chat/ui-contexts'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { + ReactElement, + MutableRefObject, + SetStateAction, + Dispatch, + FormEventHandler, + Ref, + MouseEventHandler, + ForwardedRef, +} from 'react'; +import React, { forwardRef, useState, useMemo, useEffect, useRef } from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; +import tinykeys from 'tinykeys'; + +import { VirtuosoScrollbars } from '../../components/CustomScrollbars'; +import { getConfig } from '../../lib/utils/getConfig'; +import { useAvatarTemplate } from '../hooks/useAvatarTemplate'; +import { usePreventDefault } from '../hooks/usePreventDefault'; +import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; +import Row from './Row'; + +const mobileCheck = function () { + let check = false; + (function (a: string) { + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( + a, + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + a.substr(0, 4), + ) + ) + check = true; + })(navigator.userAgent || navigator.vendor || window.opera || ''); + return check; +}; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + opera?: string; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Navigator { + userAgentData?: { + mobile: boolean; + }; + } +} + +const shortcut = ((): string => { + if (navigator.userAgentData?.mobile || mobileCheck()) { + return ''; + } + if (window.navigator.platform.toLowerCase().includes('mac')) { + return '(\u2318+K)'; + } + return '(Ctrl+K)'; +})(); + +const LIMIT = parseInt(String(getConfig('Sidebar_Search_Spotlight_LIMIT', 20))); + +const options = { + sort: { + lm: -1, + name: 1, + }, + limit: LIMIT, +} as const; + +const useSearchItems = (filterText: string): UseQueryResult<(ISubscription & IRoom)[] | undefined, Error> => { + const [, mention, name] = useMemo(() => filterText.match(/(@|#)?(.*)/i) || [], [filterText]); + const query = useMemo(() => { + const filterRegex = new RegExp(escapeRegExp(name), 'i'); + + return { + $or: [{ name: filterRegex }, { fname: filterRegex }], + ...(mention && { + t: mention === '@' ? 'd' : { $ne: 'd' }, + }), + }; + }, [name, mention]); + + const localRooms = useUserSubscriptions(query, options); + + const usernamesFromClient = [...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean) as string[]; + + const searchForChannels = mention === '#'; + const searchForDMs = mention === '@'; + + const type = useMemo(() => { + if (searchForChannels) { + return { users: false, rooms: true, includeFederatedRooms: true }; + } + if (searchForDMs) { + return { users: true, rooms: false }; + } + return { users: true, rooms: true, includeFederatedRooms: true }; + }, [searchForChannels, searchForDMs]); + + const getSpotlight = useMethod('spotlight'); + + return useQuery( + ['sidebar/search/spotlight', name, usernamesFromClient, type, localRooms.map(({ _id, name }) => _id + name)], + async () => { + if (localRooms.length === LIMIT) { + return localRooms; + } + + const spotlight = await getSpotlight(name, usernamesFromClient, type); + + const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => + index === arr.findIndex((user) => _id === user._id); + + const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => + !localRooms.find( + (item) => + (room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || + [item.rid, item._id].includes(room._id), + ); + const usersFilter = (user: { _id: string }): boolean => + !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); + + const userMap = (user: { + _id: string; + name: string; + username: string; + avatarETag?: string; + }): { + _id: string; + t: string; + name: string; + fname: string; + avatarETag?: string; + } => ({ + _id: user._id, + t: 'd', + name: user.username, + fname: user.name, + avatarETag: user.avatarETag, + }); + + type resultsFromServerType = { + _id: string; + t: string; + name: string; + teamMain?: boolean; + fname?: string; + avatarETag?: string | undefined; + uids?: string[] | undefined; + }[]; + + const resultsFromServer: resultsFromServerType = []; + resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersFilter).map(userMap)); + resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); + + const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); + return Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])); + }, + { + staleTime: 60_000, + keepPreviousData: true, + placeholderData: localRooms, + }, + ); +}; + +const useInput = (initial: string): { value: string; onChange: FormEventHandler; setValue: Dispatch> } => { + const [value, setValue] = useState(initial); + const onChange = useMutableCallback((e) => { + setValue(e.currentTarget.value); + }); + return { value, onChange, setValue }; +}; + +const toggleSelectionState = (next: HTMLElement, current: HTMLElement | undefined, input: HTMLElement | undefined): void => { + input?.setAttribute('aria-activedescendant', next.id); + next.setAttribute('aria-selected', 'true'); + next.classList.add('rcx-sidebar-item--selected'); + if (current) { + current.removeAttribute('aria-selected'); + current.classList.remove('rcx-sidebar-item--selected'); + } +}; + +type SearchListProps = { + onClose: () => void; +}; + +const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, ref: ForwardedRef) { + const listId = useUniqueId(); + const t = useTranslation(); + const { setValue: setFilterValue, ...filter } = useInput(''); + + const cursorRef = useRef(null); + const autofocus: Ref = useMergedRefs(useAutoFocus(), cursorRef); + + const listRef = useRef(null); + const boxRef = useRef(null); + + const selectedElement: MutableRefObject = useRef(null); + const itemIndexRef = useRef(0); + + const sidebarViewMode = useUserPreference('sidebarViewMode'); + const useRealName = useSetting('UI_Use_Real_Name'); + + const sideBarItemTemplate = useTemplateByViewMode(); + const avatarTemplate = useAvatarTemplate(); + + const extended = sidebarViewMode === 'extended'; + + const filterText = useDebouncedValue(filter.value, 100); + + const placeholder = [t('Search'), shortcut].filter(Boolean).join(' '); + + const { data: items = [], isLoading } = useSearchItems(filterText); + + const itemData = useMemo( + () => ({ + items, + t, + SideBarItemTemplate: sideBarItemTemplate, + avatarTemplate, + useRealName, + extended, + sidebarViewMode, + }), + [avatarTemplate, extended, items, useRealName, sideBarItemTemplate, sidebarViewMode, t], + ); + + const changeSelection = useMutableCallback((dir) => { + let nextSelectedElement = null; + + if (dir === 'up') { + const potentialElement = selectedElement.current?.parentElement?.previousSibling as HTMLElement; + if (potentialElement) { + nextSelectedElement = potentialElement.querySelector('a'); + } + } else { + const potentialElement = selectedElement.current?.parentElement?.nextSibling as HTMLElement; + if (potentialElement) { + nextSelectedElement = potentialElement.querySelector('a'); + } + } + + if (nextSelectedElement) { + toggleSelectionState(nextSelectedElement, selectedElement.current || undefined, cursorRef?.current || undefined); + return nextSelectedElement; + } + return selectedElement.current; + }); + + const resetCursor = useMutableCallback(() => { + setTimeout(() => { + itemIndexRef.current = 0; + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); + selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); + if (selectedElement.current) { + toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); + } + }, 0); + }); + + usePreventDefault(boxRef); + + useEffect(() => { + resetCursor(); + }); + + useEffect(() => { + resetCursor(); + }, [filterText, resetCursor]); + + useEffect(() => { + if (!cursorRef?.current) { + return; + } + return tinykeys(cursorRef?.current, { + Escape: (event) => { + event.preventDefault(); + setFilterValue((value) => { + if (!value) { + onClose(); + } + resetCursor(); + return ''; + }); + }, + Tab: onClose, + ArrowUp: () => { + const currentElement = changeSelection('up'); + itemIndexRef.current = Math.max(itemIndexRef.current - 1, 0); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); + selectedElement.current = currentElement; + }, + ArrowDown: () => { + const currentElement = changeSelection('down'); + itemIndexRef.current = Math.min(itemIndexRef.current + 1, items.length + 1); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); + selectedElement.current = currentElement; + }, + Enter: (event) => { + event.preventDefault(); + if (selectedElement.current && items.length > 0) { + selectedElement.current.click(); + } else { + onClose(); + } + }, + }); + }, [cursorRef, changeSelection, items.length, onClose, resetCursor, setFilterValue]); + + const handleClick: MouseEventHandler = (e): void => { + if (e.target instanceof Element && [e.target.tagName, e.target.parentElement?.tagName].includes('BUTTON')) { + return; + } + return onClose(); + }; + + return ( + + + } + /> + + + room._id} + itemContent={(_, data): ReactElement => } + ref={listRef} + /> + + + ); +}); + +export default SearchList; diff --git a/apps/meteor/client/sidebarv2/search/UserItem.tsx b/apps/meteor/client/sidebarv2/search/UserItem.tsx new file mode 100644 index 000000000000..8b9667913311 --- /dev/null +++ b/apps/meteor/client/sidebarv2/search/UserItem.tsx @@ -0,0 +1,46 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Sidebar } from '@rocket.chat/fuselage'; +import React, { memo } from 'react'; + +import { ReactiveUserStatus } from '../../components/UserStatus'; +import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; + +type UserItemProps = { + item: { + name?: string; + fname?: string; + _id: IUser['_id']; + t: string; + }; + t: (value: string) => string; + SideBarItemTemplate: any; + AvatarTemplate: any; + id: string; + style?: CSSStyleRule; + useRealName?: boolean; +}; + +const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }: UserItemProps) => { + const title = useRealName ? item.fname || item.name : item.name || item.fname; + const icon = ( + + + + ); + const href = roomCoordinator.getRouteLink(item.t, { name: item.name }); + + return ( + } + icon={icon} + /> + ); +}; + +export default memo(UserItem); diff --git a/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx b/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx new file mode 100644 index 000000000000..50367d7db3e5 --- /dev/null +++ b/apps/meteor/client/sidebarv2/sections/StatusDisabledSection.tsx @@ -0,0 +1,23 @@ +import { SidebarBanner } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { useStatusDisabledModal } from '../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; + +type StatusDisabledSectionProps = { onDismiss: () => void }; + +const StatusDisabledSection = ({ onDismiss }: StatusDisabledSectionProps) => { + const t = useTranslation(); + const handleStatusDisabledModal = useStatusDisabledModal(); + + return ( + + ); +}; + +export default StatusDisabledSection; diff --git a/apps/meteor/client/views/room/Header/FederatedRoomOriginServer.tsx b/apps/meteor/client/views/room/Header/FederatedRoomOriginServer.tsx index 811619f7f450..1f22f6466719 100644 --- a/apps/meteor/client/views/room/Header/FederatedRoomOriginServer.tsx +++ b/apps/meteor/client/views/room/Header/FederatedRoomOriginServer.tsx @@ -1,7 +1,7 @@ -import { HeaderTag, HeaderTagIcon } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import { HeaderTag, HeaderTagIcon } from '../../../components/Header'; import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext'; type FederatedRoomProps = { diff --git a/apps/meteor/client/views/room/Header/Header.tsx b/apps/meteor/client/views/room/Header/Header.tsx index c350544e8157..298076c65ce6 100644 --- a/apps/meteor/client/views/room/Header/Header.tsx +++ b/apps/meteor/client/views/room/Header/Header.tsx @@ -1,10 +1,10 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { isDirectMessageRoom, isVoipRoom } from '@rocket.chat/core-typings'; -import { HeaderToolbar } from '@rocket.chat/ui-client'; import { useLayout, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { lazy, memo, useMemo } from 'react'; +import { HeaderToolbar } from '../../../components/Header'; import SidebarToggler from '../../../components/SidebarToggler'; const OmnichannelRoomHeader = lazy(() => import('./Omnichannel/OmnichannelRoomHeader')); diff --git a/apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx b/apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx index ded978bd6335..10de9166d964 100644 --- a/apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx +++ b/apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx @@ -1,9 +1,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; -import { HeaderIcon } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React from 'react'; +import { HeaderIcon } from '../../../components/Header'; import { OmnichannelRoomIcon } from '../../../components/RoomIcon/OmnichannelRoomIcon'; import { useRoomIcon } from '../../../hooks/useRoomIcon'; diff --git a/apps/meteor/client/views/room/Header/HeaderSkeleton.tsx b/apps/meteor/client/views/room/Header/HeaderSkeleton.tsx index 222be6cce5aa..af69131ea897 100644 --- a/apps/meteor/client/views/room/Header/HeaderSkeleton.tsx +++ b/apps/meteor/client/views/room/Header/HeaderSkeleton.tsx @@ -1,7 +1,8 @@ import { Skeleton } from '@rocket.chat/fuselage'; -import { Header, HeaderAvatar, HeaderContent, HeaderContentRow } from '@rocket.chat/ui-client'; import React from 'react'; +import { Header, HeaderAvatar, HeaderContent, HeaderContentRow } from '../../../components/Header'; + const HeaderSkeleton = () => { return ( diff --git a/apps/meteor/client/views/room/Header/Omnichannel/BackButton.tsx b/apps/meteor/client/views/room/Header/Omnichannel/BackButton.tsx index 31a913ecec7b..54470a2b0f64 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/BackButton.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/BackButton.tsx @@ -1,9 +1,10 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { HeaderToolbarAction } from '@rocket.chat/ui-client'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { HeaderToolbarAction } from '../../../../components/Header'; + export const BackButton = ({ routeName }: { routeName?: string }): ReactElement => { const router = useRouter(); const t = useTranslation(); diff --git a/apps/meteor/client/views/room/Header/Omnichannel/OmnichannelRoomHeader.tsx b/apps/meteor/client/views/room/Header/Omnichannel/OmnichannelRoomHeader.tsx index 2d54fb1ec478..e6ae9a3747d7 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/OmnichannelRoomHeader.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/OmnichannelRoomHeader.tsx @@ -1,8 +1,8 @@ -import { HeaderToolbar } from '@rocket.chat/ui-client'; import { useLayout, useRouter } from '@rocket.chat/ui-contexts'; import React, { useCallback, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { HeaderToolbar } from '../../../../components/Header'; import SidebarToggler from '../../../../components/SidebarToggler'; import { useOmnichannelRoom } from '../../contexts/RoomContext'; import RoomHeader from '../RoomHeader'; diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx index b26a4d36e248..b1da1323df65 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActionOptions.tsx @@ -1,9 +1,9 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { Box, Dropdown, Option } from '@rocket.chat/fuselage'; -import { HeaderToolbarAction } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo, useRef } from 'react'; +import { HeaderToolbarAction } from '../../../../../components/Header'; import { useDropdownVisibility } from '../../../../../sidebar/header/hooks/useDropdownVisibility'; import type { QuickActionsActionOptions } from '../../../lib/quickActions'; diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx index 3ebf79b81d44..13805850a13d 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx @@ -1,9 +1,9 @@ import type { Box } from '@rocket.chat/fuselage'; -import { HeaderToolbar, HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import React, { memo } from 'react'; +import { HeaderToolbar, HeaderToolbarAction, HeaderToolbarDivider } from '../../../../../components/Header'; import { useOmnichannelRoom } from '../../../contexts/RoomContext'; import QuickActionOptions from './QuickActionOptions'; import { useQuickActions } from './hooks/useQuickActions'; diff --git a/apps/meteor/client/views/room/Header/Omnichannel/VoipRoomHeader.tsx b/apps/meteor/client/views/room/Header/Omnichannel/VoipRoomHeader.tsx index 05a28f07f5c4..235d50ffa499 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/VoipRoomHeader.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/VoipRoomHeader.tsx @@ -1,9 +1,9 @@ import type { IVoipRoom } from '@rocket.chat/core-typings'; -import { HeaderToolbar } from '@rocket.chat/ui-client'; import { useLayout, useRouter } from '@rocket.chat/ui-contexts'; import React, { useCallback, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { HeaderToolbar } from '../../../../components/Header'; import SidebarToggler from '../../../../components/SidebarToggler'; import { parseOutboundPhoneNumber } from '../../../../lib/voip/parseOutboundPhoneNumber'; import type { RoomHeaderProps } from '../RoomHeader'; diff --git a/apps/meteor/client/views/room/Header/ParentRoom.tsx b/apps/meteor/client/views/room/Header/ParentRoom.tsx index 3d598cce4c26..5907b3019084 100644 --- a/apps/meteor/client/views/room/Header/ParentRoom.tsx +++ b/apps/meteor/client/views/room/Header/ParentRoom.tsx @@ -1,8 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { HeaderTag, HeaderTagIcon } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React from 'react'; +import { HeaderTag, HeaderTagIcon } from '../../../components/Header'; import { useRoomIcon } from '../../../hooks/useRoomIcon'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; diff --git a/apps/meteor/client/views/room/Header/ParentRoomWithEndpointData.tsx b/apps/meteor/client/views/room/Header/ParentRoomWithEndpointData.tsx index 491d26be1434..06571dd02cce 100644 --- a/apps/meteor/client/views/room/Header/ParentRoomWithEndpointData.tsx +++ b/apps/meteor/client/views/room/Header/ParentRoomWithEndpointData.tsx @@ -1,8 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { HeaderTagSkeleton } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React from 'react'; +import { HeaderTagSkeleton } from '../../../components/Header'; import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint'; import ParentRoom from './ParentRoom'; diff --git a/apps/meteor/client/views/room/Header/ParentTeam.tsx b/apps/meteor/client/views/room/Header/ParentTeam.tsx index 33ef98bbe81b..ed4a4588ef21 100644 --- a/apps/meteor/client/views/room/Header/ParentTeam.tsx +++ b/apps/meteor/client/views/room/Header/ParentTeam.tsx @@ -1,11 +1,11 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { TEAM_TYPE } from '@rocket.chat/core-typings'; -import { HeaderTag, HeaderTagIcon, HeaderTagSkeleton } from '@rocket.chat/ui-client'; import { useUserId, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React from 'react'; +import { HeaderTag, HeaderTagIcon, HeaderTagSkeleton } from '../../../components/Header'; import { goToRoomById } from '../../../lib/utils/goToRoomById'; type APIErrorResult = { success: boolean; error: string }; diff --git a/apps/meteor/client/views/room/Header/RoomHeader.tsx b/apps/meteor/client/views/room/Header/RoomHeader.tsx index fee9be6a55a6..2e38e2110bbe 100644 --- a/apps/meteor/client/views/room/Header/RoomHeader.tsx +++ b/apps/meteor/client/views/room/Header/RoomHeader.tsx @@ -1,10 +1,10 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; import { RoomAvatar } from '@rocket.chat/ui-avatar'; -import { Header, HeaderAvatar, HeaderContent, HeaderContentRow, HeaderSubtitle, HeaderToolbar } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { Suspense } from 'react'; +import { Header, HeaderAvatar, HeaderContent, HeaderContentRow, HeaderSubtitle, HeaderToolbar } from '../../../components/Header'; import MarkdownText from '../../../components/MarkdownText'; import FederatedRoomOriginServer from './FederatedRoomOriginServer'; import ParentRoomWithData from './ParentRoomWithData'; diff --git a/apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx b/apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx index 2b868c28882d..c9bfe325be92 100644 --- a/apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx +++ b/apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx @@ -1,29 +1,23 @@ -import { isDirectMessageRoom } from '@rocket.chat/core-typings'; import React, { lazy } from 'react'; import { E2EEState } from '../../../../app/e2e/client/E2EEState'; import { E2ERoomState } from '../../../../app/e2e/client/E2ERoomState'; import { useE2EERoomState } from '../hooks/useE2EERoomState'; import { useE2EEState } from '../hooks/useE2EEState'; -import DirectRoomHeader from './DirectRoomHeader'; import RoomHeader from './RoomHeader'; import type { RoomHeaderProps } from './RoomHeader'; const RoomToolboxE2EESetup = lazy(() => import('./RoomToolbox/RoomToolboxE2EESetup')); -const RoomHeaderE2EESetup = ({ room, topic = '', slots = {} }: RoomHeaderProps) => { +const RoomHeaderE2EESetup = ({ room, slots = {} }: RoomHeaderProps) => { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); if (e2eeState === E2EEState.SAVE_PASSWORD || e2eeState === E2EEState.ENTER_PASSWORD || e2eRoomState === E2ERoomState.WAITING_KEYS) { - return } />; + return } />; } - if (isDirectMessageRoom(room) && (room.uids?.length ?? 0) < 3) { - return ; - } - - return ; + return ; }; export default RoomHeaderE2EESetup; diff --git a/apps/meteor/client/views/room/Header/RoomTitle.tsx b/apps/meteor/client/views/room/Header/RoomTitle.tsx index 4d81d077c154..9a3e810a46b7 100644 --- a/apps/meteor/client/views/room/Header/RoomTitle.tsx +++ b/apps/meteor/client/views/room/Header/RoomTitle.tsx @@ -1,9 +1,10 @@ import { isTeamRoom, type IRoom } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { HeaderTitle, HeaderTitleButton, useDocumentTitle } from '@rocket.chat/ui-client'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; import type { KeyboardEvent, ReactElement } from 'react'; import React from 'react'; +import { HeaderTitle, HeaderTitleButton } from '../../../components/Header'; import { useRoomToolbox } from '../contexts/RoomToolboxContext'; import HeaderIconWithRoom from './HeaderIconWithRoom'; diff --git a/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx b/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx index a12dcb25b826..bdda6f33f0e3 100644 --- a/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx +++ b/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx @@ -1,12 +1,12 @@ import type { Box } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import React, { memo } from 'react'; import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import { HeaderToolbarAction, HeaderToolbarDivider } from '../../../../components/Header'; import { useRoomToolbox } from '../../contexts/RoomToolboxContext'; import type { RoomToolboxActionConfig } from '../../contexts/RoomToolboxContext'; diff --git a/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolboxE2EESetup.tsx b/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolboxE2EESetup.tsx index 366f7322a135..58e1f9f59ef7 100644 --- a/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolboxE2EESetup.tsx +++ b/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolboxE2EESetup.tsx @@ -1,8 +1,8 @@ import { useStableArray } from '@rocket.chat/fuselage-hooks'; -import { HeaderToolbarAction } from '@rocket.chat/ui-client'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; +import { HeaderToolbarAction } from '../../../../components/Header'; import { roomActionHooksForE2EESetup } from '../../../../ui'; import { useRoom } from '../../contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../contexts/RoomToolboxContext'; diff --git a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx index bd380c5d8af2..5de558135198 100644 --- a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx +++ b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx @@ -1,10 +1,10 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import colors from '@rocket.chat/fuselage-tokens/colors'; -import { HeaderState } from '@rocket.chat/ui-client'; import { useSetting, usePermission, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; +import { HeaderState } from '../../../../components/Header'; import { dispatchToastMessage } from '../../../../lib/toast'; const Encrypted = ({ room }: { room: IRoom }) => { diff --git a/apps/meteor/client/views/room/Header/icons/Favorite.tsx b/apps/meteor/client/views/room/Header/icons/Favorite.tsx index 1023a04947c3..f6d17cb0e7b7 100644 --- a/apps/meteor/client/views/room/Header/icons/Favorite.tsx +++ b/apps/meteor/client/views/room/Header/icons/Favorite.tsx @@ -1,9 +1,9 @@ import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { HeaderState } from '@rocket.chat/ui-client'; import { useSetting, useMethod, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; +import { HeaderState } from '../../../../components/Header'; import { useUserIsSubscribed } from '../../contexts/RoomContext'; const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: { room: IRoom & { f?: ISubscription['f'] } }) => { diff --git a/apps/meteor/client/views/room/Header/icons/Translate.tsx b/apps/meteor/client/views/room/Header/icons/Translate.tsx index e4b394bfbbbb..701de69cb679 100644 --- a/apps/meteor/client/views/room/Header/icons/Translate.tsx +++ b/apps/meteor/client/views/room/Header/icons/Translate.tsx @@ -1,8 +1,9 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { HeaderState } from '@rocket.chat/ui-client'; import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; +import { HeaderState } from '../../../../components/Header'; + type TranslateProps = { room: IRoom; }; diff --git a/apps/meteor/client/views/room/HeaderV2/FederatedRoomOriginServer.tsx b/apps/meteor/client/views/room/HeaderV2/FederatedRoomOriginServer.tsx new file mode 100644 index 000000000000..a8731663b28b --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/FederatedRoomOriginServer.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; + +import { HeaderTag, HeaderTagIcon } from '../../../components/Header'; +import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext'; + +type FederatedRoomOriginServerProps = { + room: IRoomWithFederationOriginalName; +}; + +const FederatedRoomOriginServer = ({ room }: FederatedRoomOriginServerProps): ReactElement | null => { + const originServerName = useMemo(() => room.federationOriginalName?.split(':')[1], [room.federationOriginalName]); + if (!originServerName) { + return null; + } + return ( + + + {originServerName} + + ); +}; + +export default FederatedRoomOriginServer; diff --git a/apps/meteor/client/views/room/HeaderV2/Header.tsx b/apps/meteor/client/views/room/HeaderV2/Header.tsx new file mode 100644 index 000000000000..25394b703280 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Header.tsx @@ -0,0 +1,55 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { isVoipRoom } from '@rocket.chat/core-typings'; +import { useLayout, useSetting } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { lazy, memo, useMemo } from 'react'; + +import { HeaderToolbar } from '../../../components/Header'; +import SidebarToggler from '../../../components/SidebarToggler'; + +const OmnichannelRoomHeader = lazy(() => import('./Omnichannel/OmnichannelRoomHeader')); +const VoipRoomHeader = lazy(() => import('./Omnichannel/VoipRoomHeader')); +const RoomHeaderE2EESetup = lazy(() => import('./RoomHeaderE2EESetup')); +const RoomHeader = lazy(() => import('./RoomHeader')); + +type HeaderProps = { + room: IRoom; +}; + +const Header = ({ room }: HeaderProps): ReactElement | null => { + const { isMobile, isEmbedded, showTopNavbarEmbeddedLayout } = useLayout(); + const encrypted = Boolean(room.encrypted); + const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages'); + const shouldDisplayE2EESetup = encrypted && !unencryptedMessagesAllowed; + + const slots = useMemo( + () => ({ + start: isMobile && ( + + + + ), + }), + [isMobile], + ); + + if (isEmbedded && !showTopNavbarEmbeddedLayout) { + return null; + } + + if (room.t === 'l') { + return ; + } + + if (isVoipRoom(room)) { + return ; + } + + if (shouldDisplayE2EESetup) { + return ; + } + + return ; +}; + +export default memo(Header); diff --git a/apps/meteor/client/views/room/HeaderV2/HeaderIconWithRoom.tsx b/apps/meteor/client/views/room/HeaderV2/HeaderIconWithRoom.tsx new file mode 100644 index 000000000000..91cb397f30cc --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/HeaderIconWithRoom.tsx @@ -0,0 +1,23 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { HeaderIcon } from '../../../components/Header'; +import { OmnichannelRoomIcon } from '../../../components/RoomIcon/OmnichannelRoomIcon'; +import { useRoomIcon } from '../../../hooks/useRoomIcon'; + +type HeaderIconWithRoomProps = { + room: IRoom; +}; + +const HeaderIconWithRoom = ({ room }: HeaderIconWithRoomProps): ReactElement => { + const icon = useRoomIcon(room); + if (isOmnichannelRoom(room)) { + return ; + } + + return ; +}; + +export default HeaderIconWithRoom; diff --git a/apps/meteor/client/views/room/HeaderV2/HeaderSkeleton.tsx b/apps/meteor/client/views/room/HeaderV2/HeaderSkeleton.tsx new file mode 100644 index 000000000000..2c14c154c007 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/HeaderSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@rocket.chat/fuselage'; +import React from 'react'; + +import { Header, HeaderAvatar, HeaderContent, HeaderContentRow } from '../../../components/Header'; + +const HeaderSkeleton = () => { + return ( + + + + + + + + + + + ); +}; + +export default HeaderSkeleton; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/BackButton.tsx b/apps/meteor/client/views/room/HeaderV2/Omnichannel/BackButton.tsx new file mode 100644 index 000000000000..fc9ef65593d8 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/BackButton.tsx @@ -0,0 +1,35 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { HeaderToolbarAction } from '../../../../components/Header'; + +type BackButtonProps = { routeName?: string }; + +const BackButton = ({ routeName }: BackButtonProps): ReactElement => { + const router = useRouter(); + const t = useTranslation(); + + const back = useMutableCallback(() => { + switch (routeName) { + case 'omnichannel-directory': + router.navigate({ + name: 'omnichannel-directory', + params: { + ...router.getRouteParameters(), + bar: 'info', + }, + }); + break; + + case 'omnichannel-current-chats': + router.navigate({ name: 'omnichannel-current-chats' }); + break; + } + }); + + return ; +}; + +export default BackButton; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/OmnichannelRoomHeader.tsx b/apps/meteor/client/views/room/HeaderV2/Omnichannel/OmnichannelRoomHeader.tsx new file mode 100644 index 000000000000..3e4834318894 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/OmnichannelRoomHeader.tsx @@ -0,0 +1,55 @@ +import { useLayout, useRouter } from '@rocket.chat/ui-contexts'; +import React, { useCallback, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +import { HeaderToolbar } from '../../../../components/Header'; +import SidebarToggler from '../../../../components/SidebarToggler'; +import { useOmnichannelRoom } from '../../contexts/RoomContext'; +import RoomHeader from '../RoomHeader'; +import BackButton from './BackButton'; +import QuickActions from './QuickActions'; + +type OmnichannelRoomHeaderProps = { + slots: { + start?: unknown; + preContent?: unknown; + insideContent?: unknown; + posContent?: unknown; + end?: unknown; + toolbox?: { + pre?: unknown; + content?: unknown; + pos?: unknown; + }; + }; +}; + +const OmnichannelRoomHeader = ({ slots: parentSlot }: OmnichannelRoomHeaderProps) => { + const router = useRouter(); + + const currentRouteName = useSyncExternalStore( + router.subscribeToRouteChange, + useCallback(() => router.getRouteName(), [router]), + ); + + const { isMobile } = useLayout(); + const room = useOmnichannelRoom(); + + const slots = useMemo( + () => ({ + ...parentSlot, + start: (!!isMobile || currentRouteName === 'omnichannel-directory' || currentRouteName === 'omnichannel-current-chats') && ( + + {isMobile && } + + + ), + posContent: , + }), + [isMobile, currentRouteName, parentSlot], + ); + + return ; +}; + +export default OmnichannelRoomHeader; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/QuickActionOptions.tsx b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/QuickActionOptions.tsx new file mode 100644 index 000000000000..b1da1323df65 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/QuickActionOptions.tsx @@ -0,0 +1,48 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Box, Dropdown, Option } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, useRef } from 'react'; + +import { HeaderToolbarAction } from '../../../../../components/Header'; +import { useDropdownVisibility } from '../../../../../sidebar/header/hooks/useDropdownVisibility'; +import type { QuickActionsActionOptions } from '../../../lib/quickActions'; + +type QuickActionOptionsProps = { + options: QuickActionsActionOptions; + action: (id: string) => void; + room: IOmnichannelRoom; +}; + +const QuickActionOptions = ({ options, room, action, ...props }: QuickActionOptionsProps) => { + const t = useTranslation(); + const reference = useRef(null); + const target = useRef(null); + const { isVisible, toggle } = useDropdownVisibility({ reference, target }); + + const handleClick = (id: string) => (): void => { + toggle(); + action(id); + }; + + return ( + <> + toggle()} secondary={isVisible} {...props} /> + {isVisible && ( + + {options.map(({ id, label, validate }) => { + const { value: valid = true, tooltip } = validate?.(room) || {}; + return ( + + + {t(label)} + + + ); + })} + + )} + > + ); +}; + +export default memo(QuickActionOptions); diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/QuickActions.tsx b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/QuickActions.tsx new file mode 100644 index 000000000000..13805850a13d --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/QuickActions.tsx @@ -0,0 +1,46 @@ +import type { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +import { HeaderToolbar, HeaderToolbarAction, HeaderToolbarDivider } from '../../../../../components/Header'; +import { useOmnichannelRoom } from '../../../contexts/RoomContext'; +import QuickActionOptions from './QuickActionOptions'; +import { useQuickActions } from './hooks/useQuickActions'; + +type QuickActionsProps = { + className?: ComponentProps['className']; +}; + +const QuickActions = ({ className }: QuickActionsProps) => { + const t = useTranslation(); + const room = useOmnichannelRoom(); + const { quickActions, actionDefault } = useQuickActions(); + + return ( + + {quickActions.map(({ id, color, icon, title, action = actionDefault, options }, index) => { + const props = { + id, + icon, + color, + title: t(title), + className, + index, + primary: false, + action, + room, + }; + + if (options) { + return ; + } + + return ; + })} + {quickActions.length > 0 && } + + ); +}; + +export default memo(QuickActions); diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts new file mode 100644 index 000000000000..0c9dbc767952 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/usePutChatOnHoldMutation.ts @@ -0,0 +1,27 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export const usePutChatOnHoldMutation = ( + options?: Omit, 'mutationFn'>, +): UseMutationResult => { + const putChatOnHold = useEndpoint('POST', '/v1/livechat/room.onHold'); + + const queryClient = useQueryClient(); + + return useMutation( + async (rid) => { + await putChatOnHold({ roomId: rid }); + }, + { + ...options, + onSuccess: async (data, rid, context) => { + await queryClient.invalidateQueries(['current-chats']); + await queryClient.invalidateQueries(['rooms', rid]); + await queryClient.invalidateQueries(['subscriptions', { rid }]); + return options?.onSuccess?.(data, rid, context); + }, + }, + ); +}; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useQuickActions.tsx b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useQuickActions.tsx new file mode 100644 index 000000000000..7446d0630b09 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useQuickActions.tsx @@ -0,0 +1,360 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { + useSetModal, + useToastMessageDispatch, + useUserId, + useSetting, + usePermission, + useRole, + useEndpoint, + useMethod, + useTranslation, + useRouter, +} from '@rocket.chat/ui-contexts'; +import React, { useCallback, useState, useEffect } from 'react'; + +import PlaceChatOnHoldModal from '../../../../../../../app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal'; +import { LivechatInquiry } from '../../../../../../../app/livechat/client/collections/LivechatInquiry'; +import { LegacyRoomManager } from '../../../../../../../app/ui-utils/client'; +import CloseChatModal from '../../../../../../components/Omnichannel/modals/CloseChatModal'; +import CloseChatModalData from '../../../../../../components/Omnichannel/modals/CloseChatModalData'; +import ForwardChatModal from '../../../../../../components/Omnichannel/modals/ForwardChatModal'; +import ReturnChatQueueModal from '../../../../../../components/Omnichannel/modals/ReturnChatQueueModal'; +import TranscriptModal from '../../../../../../components/Omnichannel/modals/TranscriptModal'; +import { useIsRoomOverMacLimit } from '../../../../../../hooks/omnichannel/useIsRoomOverMacLimit'; +import { useOmnichannelRouteConfig } from '../../../../../../hooks/omnichannel/useOmnichannelRouteConfig'; +import { useHasLicenseModule } from '../../../../../../hooks/useHasLicenseModule'; +import { quickActionHooks } from '../../../../../../ui'; +import { useOmnichannelRoom } from '../../../../contexts/RoomContext'; +import type { QuickActionsActionConfig } from '../../../../lib/quickActions'; +import { QuickActionsEnum } from '../../../../lib/quickActions'; +import { usePutChatOnHoldMutation } from './usePutChatOnHoldMutation'; +import { useReturnChatToQueueMutation } from './useReturnChatToQueueMutation'; + +export const useQuickActions = (): { + quickActions: QuickActionsActionConfig[]; + actionDefault: (actionId: string) => void; +} => { + const room = useOmnichannelRoom(); + const setModal = useSetModal(); + const router = useRouter(); + + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [onHoldModalActive, setOnHoldModalActive] = useState(false); + + const visitorRoomId = room.v._id; + const rid = room._id; + const uid = useUserId(); + const roomLastMessage = room.lastMessage; + + const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info'); + + const getVisitorEmail = useMutableCallback(async () => { + if (!visitorRoomId) { + return; + } + + const { + visitor: { visitorEmails }, + } = await getVisitorInfo({ visitorId: visitorRoomId }); + + if (visitorEmails?.length && visitorEmails[0].address) { + return visitorEmails[0].address; + } + }); + + useEffect(() => { + if (onHoldModalActive && roomLastMessage?.token) { + setModal(null); + } + }, [roomLastMessage, onHoldModalActive, setModal]); + + const closeModal = useCallback(() => setModal(null), [setModal]); + + const requestTranscript = useEndpoint('POST', '/v1/livechat/transcript/:rid', { rid }); + + const handleRequestTranscript = useCallback( + async (email: string, subject: string) => { + try { + await requestTranscript({ email, subject }); + closeModal(); + dispatchToastMessage({ + type: 'success', + message: t('Livechat_email_transcript_has_been_requested'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeModal, dispatchToastMessage, requestTranscript, t], + ); + + const sendTranscriptPDF = useEndpoint('POST', '/v1/omnichannel/:rid/request-transcript', { rid }); + + const handleSendTranscriptPDF = useCallback(async () => { + try { + await sendTranscriptPDF(); + dispatchToastMessage({ + type: 'success', + message: t('Livechat_transcript_has_been_requested'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [dispatchToastMessage, sendTranscriptPDF, t]); + + const sendTranscript = useMethod('livechat:sendTranscript'); + + const handleSendTranscript = useCallback( + async (email: string, subject: string, token: string) => { + try { + await sendTranscript(token, rid, email, subject); + closeModal(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeModal, dispatchToastMessage, rid, sendTranscript], + ); + + const discardTranscript = useEndpoint('DELETE', '/v1/livechat/transcript/:rid', { rid }); + + const handleDiscardTranscript = useCallback(async () => { + try { + await discardTranscript(); + dispatchToastMessage({ + type: 'success', + message: t('Livechat_transcript_request_has_been_canceled'), + }); + closeModal(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [closeModal, discardTranscript, dispatchToastMessage, t]); + + const forwardChat = useEndpoint('POST', '/v1/livechat/room.forward'); + + const handleForwardChat = useCallback( + async (departmentId?: string, userId?: string, comment?: string) => { + if (departmentId && userId) { + return; + } + const transferData: { + roomId: string; + clientAction: boolean; + comment?: string; + departmentId?: string; + userId?: string; + } = { + roomId: rid, + comment, + clientAction: true, + }; + + if (departmentId) { + transferData.departmentId = departmentId; + } + if (userId) { + transferData.userId = userId; + } + + try { + await forwardChat(transferData); + dispatchToastMessage({ type: 'success', message: t('Transferred') }); + router.navigate('/home'); + LegacyRoomManager.close(room.t + rid); + closeModal(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeModal, dispatchToastMessage, forwardChat, room.t, rid, router, t], + ); + + const closeChat = useEndpoint('POST', '/v1/livechat/room.closeByUser'); + + const handleClose = useCallback( + async ( + comment?: string, + tags?: string[], + preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean }, + requestData?: { email: string; subject: string }, + ) => { + try { + await closeChat({ + rid, + ...(comment && { comment }), + ...(tags && { tags }), + ...(preferences?.omnichannelTranscriptPDF && { generateTranscriptPdf: true }), + ...(preferences?.omnichannelTranscriptEmail && requestData + ? { + transcriptEmail: { + sendToVisitor: preferences?.omnichannelTranscriptEmail, + requestData, + }, + } + : { transcriptEmail: { sendToVisitor: false } }), + }); + LivechatInquiry.remove({ rid }); + closeModal(); + dispatchToastMessage({ type: 'success', message: t('Chat_closed_successfully') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeChat, closeModal, dispatchToastMessage, rid, t], + ); + + const returnChatToQueueMutation = useReturnChatToQueueMutation({ + onSuccess: () => { + LegacyRoomManager.close(room.t + rid); + router.navigate('/home'); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + closeModal(); + }, + }); + + const putChatOnHoldMutation = usePutChatOnHoldMutation({ + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Chat_On_Hold_Successfully') }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + closeModal(); + }, + }); + + const handleAction = useMutableCallback(async (id: string) => { + switch (id) { + case QuickActionsEnum.MoveQueue: + setModal( + returnChatToQueueMutation.mutate(rid)} + onCancel={(): void => { + closeModal(); + }} + />, + ); + break; + case QuickActionsEnum.TranscriptPDF: + handleSendTranscriptPDF(); + break; + case QuickActionsEnum.TranscriptEmail: + const visitorEmail = await getVisitorEmail(); + + if (!visitorEmail) { + dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') }); + break; + } + + setModal( + , + ); + break; + case QuickActionsEnum.ChatForward: + setModal(); + break; + case QuickActionsEnum.CloseChat: + const email = await getVisitorEmail(); + setModal( + room.departmentId ? ( + + ) : ( + + ), + ); + break; + case QuickActionsEnum.OnHoldChat: + setModal( + putChatOnHoldMutation.mutate(rid)} + onCancel={(): void => { + closeModal(); + setOnHoldModalActive(false); + }} + />, + ); + setOnHoldModalActive(true); + break; + default: + break; + } + }); + + const omnichannelRouteConfig = useOmnichannelRouteConfig(); + + const manualOnHoldAllowed = useSetting('Livechat_allow_manual_on_hold'); + + const hasManagerRole = useRole('livechat-manager'); + const hasMonitorRole = useRole('livechat-monitor'); + + const roomOpen = room?.open && (room.u?._id === uid || hasManagerRole || hasMonitorRole) && room?.lastMessage?.t !== 'livechat-close'; + const canMoveQueue = !!omnichannelRouteConfig?.returnQueue && room?.u !== undefined; + const canForwardGuest = usePermission('transfer-livechat-guest'); + const canSendTranscriptEmail = usePermission('send-omnichannel-chat-transcript'); + const hasLicense = useHasLicenseModule('livechat-enterprise'); + const canSendTranscriptPDF = usePermission('request-pdf-transcript'); + const canCloseRoom = usePermission('close-livechat-room'); + const canCloseOthersRoom = usePermission('close-others-livechat-room'); + const restrictedOnHold = useSetting('Livechat_allow_manual_on_hold_upon_agent_engagement_only'); + const canRoomBePlacedOnHold = !room.onHold && room.u; + const canAgentPlaceOnHold = !room.lastMessage?.token; + const canPlaceChatOnHold = Boolean(manualOnHoldAllowed && canRoomBePlacedOnHold && (!restrictedOnHold || canAgentPlaceOnHold)); + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + + const hasPermissionButtons = (id: string): boolean => { + switch (id) { + case QuickActionsEnum.MoveQueue: + return !isRoomOverMacLimit && !!roomOpen && canMoveQueue; + case QuickActionsEnum.ChatForward: + return !isRoomOverMacLimit && !!roomOpen && canForwardGuest; + case QuickActionsEnum.Transcript: + return !isRoomOverMacLimit && (canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF)); + case QuickActionsEnum.TranscriptEmail: + return !isRoomOverMacLimit && canSendTranscriptEmail; + case QuickActionsEnum.TranscriptPDF: + return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF; + case QuickActionsEnum.CloseChat: + return !!roomOpen && (canCloseRoom || canCloseOthersRoom); + case QuickActionsEnum.OnHoldChat: + return !!roomOpen && canPlaceChatOnHold; + default: + break; + } + return false; + }; + + const quickActions = quickActionHooks + .map((quickActionHook) => quickActionHook()) + .filter((quickAction): quickAction is QuickActionsActionConfig => !!quickAction) + .filter((action) => { + const { options, id } = action; + if (options) { + action.options = options.filter(({ id }) => hasPermissionButtons(id)); + } + + return hasPermissionButtons(id); + }) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const actionDefault = useMutableCallback((actionId: string) => { + handleAction(actionId); + }); + + return { quickActions, actionDefault }; +}; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts new file mode 100644 index 000000000000..c037f200514b --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/hooks/useReturnChatToQueueMutation.ts @@ -0,0 +1,28 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useMethod } from '@rocket.chat/ui-contexts'; +import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export const useReturnChatToQueueMutation = ( + options?: Omit, 'mutationFn'>, +): UseMutationResult => { + const returnChatToQueue = useMethod('livechat:returnAsInquiry'); + + const queryClient = useQueryClient(); + + return useMutation( + async (rid) => { + await returnChatToQueue(rid); + }, + { + ...options, + onSuccess: async (data, rid, context) => { + await queryClient.invalidateQueries(['current-chats']); + await queryClient.removeQueries(['rooms', rid]); + await queryClient.removeQueries(['/v1/rooms.info', rid]); + await queryClient.removeQueries(['subscriptions', { rid }]); + return options?.onSuccess?.(data, rid, context); + }, + }, + ); +}; diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/index.ts b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/index.ts new file mode 100644 index 000000000000..5ec9f10150e3 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/QuickActions/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export default lazy(() => import('./QuickActions')); diff --git a/apps/meteor/client/views/room/HeaderV2/Omnichannel/VoipRoomHeader.tsx b/apps/meteor/client/views/room/HeaderV2/Omnichannel/VoipRoomHeader.tsx new file mode 100644 index 000000000000..c79f8999f1a8 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/Omnichannel/VoipRoomHeader.tsx @@ -0,0 +1,42 @@ +import type { IVoipRoom } from '@rocket.chat/core-typings'; +import { useLayout, useRouter } from '@rocket.chat/ui-contexts'; +import React, { useCallback, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +import { HeaderToolbar } from '../../../../components/Header'; +import SidebarToggler from '../../../../components/SidebarToggler'; +import { parseOutboundPhoneNumber } from '../../../../lib/voip/parseOutboundPhoneNumber'; +import type { RoomHeaderProps } from '../RoomHeader'; +import RoomHeader from '../RoomHeader'; +import BackButton from './BackButton'; + +type VoipRoomHeaderProps = { + room: IVoipRoom; +} & Omit; + +const VoipRoomHeader = ({ slots: parentSlot, room }: VoipRoomHeaderProps) => { + const router = useRouter(); + + const currentRouteName = useSyncExternalStore( + router.subscribeToRouteChange, + useCallback(() => router.getRouteName(), [router]), + ); + + const { isMobile } = useLayout(); + + const slots = useMemo( + () => ({ + ...parentSlot, + start: (!!isMobile || currentRouteName === 'omnichannel-directory') && ( + + {isMobile && } + {currentRouteName === 'omnichannel-directory' && } + + ), + }), + [isMobile, currentRouteName, parentSlot], + ); + return ; +}; + +export default VoipRoomHeader; diff --git a/apps/meteor/client/views/room/HeaderV2/ParentRoom.tsx b/apps/meteor/client/views/room/HeaderV2/ParentRoom.tsx new file mode 100644 index 000000000000..0c53d790caf8 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/ParentRoom.tsx @@ -0,0 +1,30 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { HeaderTag, HeaderTagIcon } from '../../../components/Header'; +import { useRoomIcon } from '../../../hooks/useRoomIcon'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; + +type ParentRoomProps = { + room: Pick; +}; + +const ParentRoom = ({ room }: ParentRoomProps) => { + const icon = useRoomIcon(room); + + const handleRedirect = (): void => roomCoordinator.openRouteLink(room.t, { rid: room._id, ...room }); + + return ( + (e.code === 'Space' || e.code === 'Enter') && handleRedirect()} + onClick={handleRedirect} + > + + {roomCoordinator.getRoomName(room.t, room)} + + ); +}; + +export default ParentRoom; diff --git a/apps/meteor/client/views/room/HeaderV2/ParentRoomWithData.tsx b/apps/meteor/client/views/room/HeaderV2/ParentRoomWithData.tsx new file mode 100644 index 000000000000..aed3adc53b39 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/ParentRoomWithData.tsx @@ -0,0 +1,28 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useUserSubscription } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import ParentRoom from './ParentRoom'; +import ParentRoomWithEndpointData from './ParentRoomWithEndpointData'; + +type ParentRoomWithDataProps = { + room: IRoom; +}; + +const ParentRoomWithData = ({ room }: ParentRoomWithDataProps) => { + const { prid } = room; + + if (!prid) { + throw new Error('Parent room ID is missing'); + } + + const subscription = useUserSubscription(prid); + + if (subscription) { + return ; + } + + return ; +}; + +export default ParentRoomWithData; diff --git a/apps/meteor/client/views/room/HeaderV2/ParentRoomWithEndpointData.tsx b/apps/meteor/client/views/room/HeaderV2/ParentRoomWithEndpointData.tsx new file mode 100644 index 000000000000..89d0ea1f8220 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/ParentRoomWithEndpointData.tsx @@ -0,0 +1,26 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { HeaderTagSkeleton } from '../../../components/Header'; +import { useRoomInfoEndpoint } from '../../../hooks/useRoomInfoEndpoint'; +import ParentRoom from './ParentRoom'; + +type ParentRoomWithEndpointDataProps = { + rid: IRoom['_id']; +}; + +const ParentRoomWithEndpointData = ({ rid }: ParentRoomWithEndpointDataProps) => { + const { data, isLoading, isError } = useRoomInfoEndpoint(rid); + + if (isLoading) { + return ; + } + + if (isError || !data?.room) { + return null; + } + + return ; +}; + +export default ParentRoomWithEndpointData; diff --git a/apps/meteor/client/views/room/HeaderV2/ParentTeam.tsx b/apps/meteor/client/views/room/HeaderV2/ParentTeam.tsx new file mode 100644 index 000000000000..2f8bfa57c566 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/ParentTeam.tsx @@ -0,0 +1,79 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { TEAM_TYPE } from '@rocket.chat/core-typings'; +import { useUserId, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import { HeaderTag, HeaderTagIcon, HeaderTagSkeleton } from '../../../components/Header'; +import { goToRoomById } from '../../../lib/utils/goToRoomById'; + +type APIErrorResult = { success: boolean; error: string }; + +type ParentTeamProps = { + room: IRoom; +}; + +const ParentTeam = ({ room }: ParentTeamProps) => { + const { teamId } = room; + const userId = useUserId(); + + if (!teamId) { + throw new Error('invalid rid'); + } + + if (!userId) { + throw new Error('invalid uid'); + } + + const teamsInfoEndpoint = useEndpoint('GET', '/v1/teams.info'); + const userTeamsListEndpoint = useEndpoint('GET', '/v1/users.listTeams'); + + const { + data: teamInfoData, + isLoading: teamInfoLoading, + isError: teamInfoError, + } = useQuery(['teamId', teamId], async () => teamsInfoEndpoint({ teamId }), { + keepPreviousData: true, + retry: (_, error) => (error as APIErrorResult)?.error === 'unauthorized' && false, + }); + + const { data: userTeams, isLoading: userTeamsLoading } = useQuery(['userId', userId], async () => userTeamsListEndpoint({ userId })); + + const userBelongsToTeam = userTeams?.teams?.find((team) => team._id === teamId) || false; + const isTeamPublic = teamInfoData?.teamInfo.type === TEAM_TYPE.PUBLIC; + + const redirectToMainRoom = (): void => { + const rid = teamInfoData?.teamInfo.roomId; + if (!rid) { + return; + } + + if (!(isTeamPublic || userBelongsToTeam)) { + return; + } + + goToRoomById(rid); + }; + + if (teamInfoLoading || userTeamsLoading) { + return ; + } + + if (teamInfoError) { + return null; + } + + return ( + (e.code === 'Space' || e.code === 'Enter') && redirectToMainRoom()} + onClick={redirectToMainRoom} + > + + {teamInfoData?.teamInfo.name} + + ); +}; + +export default ParentTeam; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomHeader.tsx b/apps/meteor/client/views/room/HeaderV2/RoomHeader.tsx new file mode 100644 index 000000000000..8ef21aecf0cd --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomHeader.tsx @@ -0,0 +1,69 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import { RoomAvatar } from '@rocket.chat/ui-avatar'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { Suspense } from 'react'; + +import { Header, HeaderAvatar, HeaderContent, HeaderContentRow, HeaderToolbar } from '../../../components/Header'; +import FederatedRoomOriginServer from './FederatedRoomOriginServer'; +import ParentRoomWithData from './ParentRoomWithData'; +import ParentTeam from './ParentTeam'; +import RoomTitle from './RoomTitle'; +import RoomToolbox from './RoomToolbox'; +import Encrypted from './icons/Encrypted'; +import Favorite from './icons/Favorite'; +import Translate from './icons/Translate'; + +export type RoomHeaderProps = { + room: IRoom; + slots: { + start?: unknown; + preContent?: unknown; + insideContent?: unknown; + posContent?: unknown; + end?: unknown; + toolbox?: { + pre?: unknown; + content?: unknown; + pos?: unknown; + }; + }; + roomToolbox?: JSX.Element; +}; + +const RoomHeader = ({ room, slots = {}, roomToolbox }: RoomHeaderProps) => { + const t = useTranslation(); + + return ( + + {slots?.start} + + + + {slots?.preContent} + + + + + {room.prid && } + {room.teamId && !room.teamMain && } + {isRoomFederated(room) && } + + + {slots?.insideContent} + + + {slots?.posContent} + + + {slots?.toolbox?.pre} + {slots?.toolbox?.content || roomToolbox || } + {slots?.toolbox?.pos} + + + {slots?.end} + + ); +}; + +export default RoomHeader; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx new file mode 100644 index 000000000000..c9bfe325be92 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx @@ -0,0 +1,23 @@ +import React, { lazy } from 'react'; + +import { E2EEState } from '../../../../app/e2e/client/E2EEState'; +import { E2ERoomState } from '../../../../app/e2e/client/E2ERoomState'; +import { useE2EERoomState } from '../hooks/useE2EERoomState'; +import { useE2EEState } from '../hooks/useE2EEState'; +import RoomHeader from './RoomHeader'; +import type { RoomHeaderProps } from './RoomHeader'; + +const RoomToolboxE2EESetup = lazy(() => import('./RoomToolbox/RoomToolboxE2EESetup')); + +const RoomHeaderE2EESetup = ({ room, slots = {} }: RoomHeaderProps) => { + const e2eeState = useE2EEState(); + const e2eRoomState = useE2EERoomState(room._id); + + if (e2eeState === E2EEState.SAVE_PASSWORD || e2eeState === E2EEState.ENTER_PASSWORD || e2eRoomState === E2ERoomState.WAITING_KEYS) { + return } />; + } + + return ; +}; + +export default RoomHeaderE2EESetup; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomLeader.tsx b/apps/meteor/client/views/room/HeaderV2/RoomLeader.tsx new file mode 100644 index 000000000000..cfe1f620afbf --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomLeader.tsx @@ -0,0 +1,64 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { UIEvent } from 'react'; +import React, { useCallback, useMemo } from 'react'; + +import { HeaderSubtitle } from '../../../components/Header'; +import { ReactiveUserStatus } from '../../../components/UserStatus'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import { useUserCard } from '../contexts/UserCardContext'; + +type RoomLeaderProps = { + _id: IUser['_id']; + name: IUser['name']; + username?: IUser['username']; +}; + +const RoomLeader = ({ _id, name, username }: RoomLeaderProps) => { + const t = useTranslation(); + + const { openUserCard, triggerProps } = useUserCard(); + + const onAvatarClick = useCallback( + (event: UIEvent, username: IUser['username']) => { + if (!username) { + return; + } + + openUserCard(event, username); + }, + [openUserCard], + ); + + const chatNowLink = useMemo(() => roomCoordinator.getRouteLink('d', { name: username }) || undefined, [username]); + + if (!username) { + throw new Error('username is required'); + } + + const roomLeaderStyle = css` + display: flex; + align-items: center; + flex-shrink: 0; + flex-grow: 0; + gap: 4px; + + [role='button'] { + cursor: pointer; + } + `; + + return ( + + onAvatarClick(event, username)} {...triggerProps} /> + + {name} + + + ); +}; + +export default RoomLeader; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomTitle.tsx b/apps/meteor/client/views/room/HeaderV2/RoomTitle.tsx new file mode 100644 index 000000000000..d728f8c03a3f --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomTitle.tsx @@ -0,0 +1,54 @@ +import { isTeamRoom, type IRoom } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useDocumentTitle } from '@rocket.chat/ui-client'; +import type { KeyboardEvent } from 'react'; +import React from 'react'; + +import { HeaderTitle, HeaderTitleButton } from '../../../components/Header'; +import { useRoomToolbox } from '../contexts/RoomToolboxContext'; +import HeaderIconWithRoom from './HeaderIconWithRoom'; + +type RoomTitleProps = { room: IRoom }; + +const RoomTitle = ({ room }: RoomTitleProps) => { + useDocumentTitle(room.name, false); + const { openTab } = useRoomToolbox(); + + const handleOpenRoomInfo = useEffectEvent(() => { + if (isTeamRoom(room)) { + return openTab('team-info'); + } + + switch (room.t) { + case 'l': + openTab('room-info'); + break; + + case 'v': + openTab('voip-room-info'); + break; + + case 'd': + (room.uids?.length ?? 0) > 2 ? openTab('user-info-group') : openTab('user-info'); + break; + + default: + openTab('channel-settings'); + break; + } + }); + + return ( + (e.code === 'Enter' || e.code === 'Space') && handleOpenRoomInfo()} + onClick={() => handleOpenRoomInfo()} + tabIndex={0} + role='button' + > + + {room.name} + + ); +}; + +export default RoomTitle; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomToolbox/RoomToolbox.tsx b/apps/meteor/client/views/room/HeaderV2/RoomToolbox/RoomToolbox.tsx new file mode 100644 index 000000000000..5fda368711c1 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomToolbox/RoomToolbox.tsx @@ -0,0 +1,101 @@ +import type { Box } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; +import React, { memo } from 'react'; + +import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; +import type { GenericMenuItemProps } from '../../../../components/GenericMenu/GenericMenuItem'; +import { HeaderToolbarAction, HeaderToolbarDivider } from '../../../../components/Header'; +import { useRoomToolbox } from '../../contexts/RoomToolboxContext'; +import type { RoomToolboxActionConfig } from '../../contexts/RoomToolboxContext'; + +type RoomToolboxProps = { + className?: ComponentProps['className']; +}; + +type MenuActionsProps = { + id: string; + items: GenericMenuItemProps[]; +}[]; + +const RoomToolbox = ({ className }: RoomToolboxProps) => { + const t = useTranslation(); + const { roomToolboxExpanded } = useLayout(); + + const toolbox = useRoomToolbox(); + const { actions, openTab } = toolbox; + + const featuredActions = actions.filter((action) => action.featured); + const normalActions = actions.filter((action) => !action.featured); + const visibleActions = !roomToolboxExpanded ? [] : normalActions.slice(0, 6); + + const hiddenActions = (!roomToolboxExpanded ? actions : normalActions.slice(6)) + .filter((item) => !item.disabled && !item.featured) + .map((item) => ({ + 'key': item.id, + 'content': t(item.title), + 'onClick': + item.action ?? + ((): void => { + openTab(item.id); + }), + 'data-qa-id': `ToolBoxAction-${item.icon}`, + ...item, + })) + .reduce((acc, item) => { + const group = item.type ? item.type : ''; + const section = acc.find((section: { id: string }) => section.id === group); + if (section) { + section.items.push(item); + return acc; + } + + const newSection = { id: group, key: item.key, title: group === 'apps' ? t('Apps') : '', items: [item] }; + acc.push(newSection); + + return acc; + }, [] as MenuActionsProps); + + const renderDefaultToolboxItem: RoomToolboxActionConfig['renderToolboxItem'] = useEffectEvent( + ({ id, className, index, icon, title, toolbox: { tab }, action, disabled, tooltip }) => { + return ( + + ); + }, + ); + + const mapToToolboxItem = (action: RoomToolboxActionConfig, index: number) => { + return (action.renderToolboxItem ?? renderDefaultToolboxItem)?.({ + ...action, + action: action.action ?? (() => toolbox.openTab(action.id)), + className, + index, + toolbox, + }); + }; + + return ( + <> + {featuredActions.map(mapToToolboxItem)} + {featuredActions.length > 0 && } + {visibleActions.map(mapToToolboxItem)} + {(normalActions.length > 6 || !roomToolboxExpanded) && !!hiddenActions.length && ( + + )} + > + ); +}; + +export default memo(RoomToolbox); diff --git a/apps/meteor/client/views/room/HeaderV2/RoomToolbox/RoomToolboxE2EESetup.tsx b/apps/meteor/client/views/room/HeaderV2/RoomToolbox/RoomToolboxE2EESetup.tsx new file mode 100644 index 000000000000..9c6afa33fc27 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomToolbox/RoomToolboxE2EESetup.tsx @@ -0,0 +1,41 @@ +import { useStableArray } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { HeaderToolbarAction } from '../../../../components/Header'; +import { roomActionHooksForE2EESetup } from '../../../../ui'; +import type { RoomToolboxActionConfig } from '../../contexts/RoomToolboxContext'; +import { useRoomToolbox } from '../../contexts/RoomToolboxContext'; + +const RoomToolboxE2EESetup = () => { + const t = useTranslation(); + const toolbox = useRoomToolbox(); + + const { tab } = toolbox; + + const actions = useStableArray( + roomActionHooksForE2EESetup + .map((roomActionHook) => roomActionHook()) + .filter((roomAction): roomAction is RoomToolboxActionConfig => !!roomAction), + ); + + return ( + <> + {actions.map(({ id, icon, title, action, disabled, tooltip }, index) => ( + toolbox.openTab(id))} + disabled={disabled} + tooltip={tooltip} + /> + ))} + > + ); +}; + +export default RoomToolboxE2EESetup; diff --git a/apps/meteor/client/views/room/HeaderV2/RoomToolbox/index.ts b/apps/meteor/client/views/room/HeaderV2/RoomToolbox/index.ts new file mode 100644 index 000000000000..d5a042756df4 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/RoomToolbox/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export default lazy(() => import('./RoomToolbox')); diff --git a/apps/meteor/client/views/room/HeaderV2/icons/Encrypted.tsx b/apps/meteor/client/views/room/HeaderV2/icons/Encrypted.tsx new file mode 100644 index 000000000000..5de558135198 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/icons/Encrypted.tsx @@ -0,0 +1,36 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import colors from '@rocket.chat/fuselage-tokens/colors'; +import { useSetting, usePermission, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import React, { memo } from 'react'; + +import { HeaderState } from '../../../../components/Header'; +import { dispatchToastMessage } from '../../../../lib/toast'; + +const Encrypted = ({ room }: { room: IRoom }) => { + const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const toggleE2E = useEndpoint('POST', '/v1/rooms.saveRoomSettings'); + const canToggleE2E = usePermission('toggle-room-e2e-encryption'); + const encryptedLabel = canToggleE2E ? t('Encrypted_key_title') : t('Encrypted'); + const handleE2EClick = useMutableCallback(async () => { + if (!canToggleE2E) { + return; + } + + const { success } = await toggleE2E({ rid: room._id, encrypted: !room.encrypted }); + if (!success) { + return; + } + + dispatchToastMessage({ + type: 'success', + message: t('E2E_Encryption_disabled_for_room', { roomName: room.name }), + }); + }); + return e2eEnabled && room?.encrypted ? ( + + ) : null; +}; + +export default memo(Encrypted); diff --git a/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx b/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx new file mode 100644 index 000000000000..f6d17cb0e7b7 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/icons/Favorite.tsx @@ -0,0 +1,52 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useSetting, useMethod, useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import React, { memo } from 'react'; + +import { HeaderState } from '../../../../components/Header'; +import { useUserIsSubscribed } from '../../contexts/RoomContext'; + +const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: { room: IRoom & { f?: ISubscription['f'] } }) => { + const t = useTranslation(); + const subscribed = useUserIsSubscribed(); + const dispatchToastMessage = useToastMessageDispatch(); + + const isFavoritesEnabled = useSetting('Favorite_Rooms') && ['c', 'p', 'd', 't'].includes(type); + const toggleFavorite = useMethod('toggleFavorite'); + + const handleFavoriteClick = useEffectEvent(() => { + if (!isFavoritesEnabled) { + return; + } + + try { + toggleFavorite(_id, !favorite); + dispatchToastMessage({ + type: 'success', + message: !favorite + ? t('__roomName__was_added_to_favorites', { roomName: name }) + : t('__roomName__was_removed_from_favorites', { roomName: name }), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const favoriteLabel = favorite ? `${t('Unfavorite')} ${name}` : `${t('Favorite')} ${name}`; + + if (!subscribed || !isFavoritesEnabled) { + return null; + } + + return ( + + ); +}; + +export default memo(Favorite); diff --git a/apps/meteor/client/views/room/HeaderV2/icons/Translate.tsx b/apps/meteor/client/views/room/HeaderV2/icons/Translate.tsx new file mode 100644 index 000000000000..0097c9f2e3d8 --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/icons/Translate.tsx @@ -0,0 +1,21 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import type { FC } from 'react'; +import React, { memo } from 'react'; + +import { HeaderState } from '../../../../components/Header'; + +type TranslateProps = { + room: IRoom; +}; + +const Translate: FC = ({ room: { autoTranslateLanguage, autoTranslate } }) => { + const t = useTranslation(); + const autoTranslateEnabled = useSetting('AutoTranslate_Enabled'); + const encryptedLabel = t('Translated'); + return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? ( + + ) : null; +}; + +export default memo(Translate); diff --git a/apps/meteor/client/views/room/HeaderV2/index.ts b/apps/meteor/client/views/room/HeaderV2/index.ts new file mode 100644 index 000000000000..a38c9709c31b --- /dev/null +++ b/apps/meteor/client/views/room/HeaderV2/index.ts @@ -0,0 +1 @@ +export { default as HeaderV2 } from './Header'; diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index 6bbeb9f9e230..55f8b4a82be8 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -1,3 +1,4 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { createElement, lazy, memo, Suspense } from 'react'; @@ -7,8 +8,10 @@ import { ErrorBoundary } from 'react-error-boundary'; import { ContextualbarSkeleton } from '../../components/Contextualbar'; import RoomE2EESetup from './E2EESetup/RoomE2EESetup'; import Header from './Header'; +import { HeaderV2 } from './HeaderV2'; import MessageHighlightProvider from './MessageList/providers/MessageHighlightProvider'; import RoomBody from './body/RoomBody'; +import RoomBodyV2 from './body/RoomBodyV2'; import { useRoom } from './contexts/RoomContext'; import { useRoomToolbox } from './contexts/RoomToolboxContext'; import { useAppsContextualBar } from './hooks/useAppsContextualBar'; @@ -40,8 +43,34 @@ const Room = (): ReactElement => { ? t('Conversation_with__roomName__', { roomName: room.name }) : t('Channel__roomName__', { roomName: room.name }) } - header={} - body={shouldDisplayE2EESetup ? : } + header={ + <> + + + + + + + + + > + } + body={ + shouldDisplayE2EESetup ? ( + + ) : ( + <> + + + + + + + + + > + ) + } aside={ (toolbox.tab?.tabComponent && ( diff --git a/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx b/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx new file mode 100644 index 000000000000..f9daf3d14816 --- /dev/null +++ b/apps/meteor/client/views/room/RoomAnnouncement/AnnouncementComponent.tsx @@ -0,0 +1,14 @@ +import { RoomBanner, RoomBannerContent } from '@rocket.chat/ui-client'; +import type { FC, MouseEvent } from 'react'; +import React from 'react'; + +type AnnouncementComponenttParams = { + onClickOpen: (e: MouseEvent) => void; +}; + +const AnnouncementComponent: FC = ({ children, onClickOpen }) => ( + + {children} + +); +export default AnnouncementComponent; diff --git a/apps/meteor/client/views/room/RoomAnnouncement/RoomAnnouncement.stories.tsx b/apps/meteor/client/views/room/RoomAnnouncement/RoomAnnouncement.stories.tsx new file mode 100644 index 000000000000..09a6d9ed695e --- /dev/null +++ b/apps/meteor/client/views/room/RoomAnnouncement/RoomAnnouncement.stories.tsx @@ -0,0 +1,17 @@ +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; + +import RoomAnnouncement from '.'; + +export default { + title: 'Room/Announcement', + component: RoomAnnouncement, +} as ComponentMeta; + +export const Default: ComponentStory = (args) => ; +Default.storyName = 'Announcement'; +Default.args = { + announcement: 'Lorem Ipsum Indolor', + announcementDetails: action('announcementDetails'), +}; diff --git a/apps/meteor/client/views/room/RoomAnnouncement/RoomAnnouncement.tsx b/apps/meteor/client/views/room/RoomAnnouncement/RoomAnnouncement.tsx new file mode 100644 index 000000000000..4c96b88bb348 --- /dev/null +++ b/apps/meteor/client/views/room/RoomAnnouncement/RoomAnnouncement.tsx @@ -0,0 +1,47 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; +import type { FC, MouseEvent } from 'react'; +import React from 'react'; + +import GenericModal from '../../../components/GenericModal'; +import MarkdownText from '../../../components/MarkdownText'; +import AnnouncementComponent from './AnnouncementComponent'; + +type RoomAnnouncementParams = { + announcement: string; + announcementDetails?: () => void; +}; + +const RoomAnnouncement: FC = ({ announcement, announcementDetails }) => { + const t = useTranslation(); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const handleClick = (e: MouseEvent): void => { + if ((e.target as HTMLAnchorElement).href) { + return; + } + + if (window?.getSelection()?.toString() !== '') { + return; + } + + announcementDetails + ? announcementDetails() + : setModal( + + + + + , + ); + }; + + return announcement ? ( + + + + ) : null; +}; + +export default RoomAnnouncement; diff --git a/apps/meteor/client/views/room/RoomAnnouncement/index.tsx b/apps/meteor/client/views/room/RoomAnnouncement/index.tsx new file mode 100644 index 000000000000..a6b289d12c9b --- /dev/null +++ b/apps/meteor/client/views/room/RoomAnnouncement/index.tsx @@ -0,0 +1 @@ +export { default } from './RoomAnnouncement'; diff --git a/apps/meteor/client/views/room/RoomOpener.tsx b/apps/meteor/client/views/room/RoomOpener.tsx index 734f0434bf0d..c30acf6f0e83 100644 --- a/apps/meteor/client/views/room/RoomOpener.tsx +++ b/apps/meteor/client/views/room/RoomOpener.tsx @@ -1,10 +1,10 @@ import type { RoomType } from '@rocket.chat/core-typings'; import { States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; -import { Header } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React, { lazy, Suspense } from 'react'; import { useTranslation } from 'react-i18next'; +import { Header } from '../../components/Header'; import { getErrorMessage } from '../../lib/errorHandling'; import { NotAuthorizedError } from '../../lib/errors/NotAuthorizedError'; import { OldUrlRoomError } from '../../lib/errors/OldUrlRoomError'; diff --git a/apps/meteor/client/views/room/body/LeaderBar.tsx b/apps/meteor/client/views/room/body/LeaderBar.tsx index 2d5fdb8bbdb8..b54d37ed5df9 100644 --- a/apps/meteor/client/views/room/body/LeaderBar.tsx +++ b/apps/meteor/client/views/room/body/LeaderBar.tsx @@ -19,7 +19,9 @@ type LeaderBarProps = { onAvatarClick?: (event: UIEvent, username: IUser['username']) => void; triggerProps: AriaButtonProps<'button'>; }; - +/** + * @deprecated on newNavigation feature. Remove after full migration. + */ const LeaderBar = ({ _id, name, username, visible, onAvatarClick, triggerProps }: LeaderBarProps): ReactElement => { const t = useTranslation(); diff --git a/apps/meteor/client/views/room/body/RoomBodyV2.tsx b/apps/meteor/client/views/room/body/RoomBodyV2.tsx new file mode 100644 index 000000000000..32b4288b3b0e --- /dev/null +++ b/apps/meteor/client/views/room/body/RoomBodyV2.tsx @@ -0,0 +1,300 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference } from '@rocket.chat/ui-contexts'; +import type { MouseEventHandler, ReactElement } from 'react'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; + +import { isTruthy } from '../../../../lib/isTruthy'; +import { CustomScrollbars } from '../../../components/CustomScrollbars'; +import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; +import { BubbleDate } from '../BubbleDate'; +import { MessageList } from '../MessageList'; +import MessageListErrorBoundary from '../MessageList/MessageListErrorBoundary'; +import RoomAnnouncement from '../RoomAnnouncement'; +import ComposerContainer from '../composer/ComposerContainer'; +import RoomComposer from '../composer/RoomComposer/RoomComposer'; +import { useChat } from '../contexts/ChatContext'; +import { useRoom, useRoomSubscription, useRoomMessages } from '../contexts/RoomContext'; +import { useRoomToolbox } from '../contexts/RoomToolboxContext'; +import { useDateScroll } from '../hooks/useDateScroll'; +import { useMessageListNavigation } from '../hooks/useMessageListNavigation'; +import { useRetentionPolicy } from '../hooks/useRetentionPolicy'; +import DropTargetOverlay from './DropTargetOverlay'; +import JumpToRecentMessageButton from './JumpToRecentMessageButton'; +import LoadingMessagesIndicator from './LoadingMessagesIndicator'; +import RetentionPolicyWarning from './RetentionPolicyWarning'; +import RoomForeword from './RoomForeword/RoomForeword'; +import { RoomTopic } from './RoomTopic'; +import UnreadMessagesIndicator from './UnreadMessagesIndicator'; +import UploadProgressIndicator from './UploadProgressIndicator'; +import { useBannerSection } from './hooks/useBannerSection'; +import { useFileUpload } from './hooks/useFileUpload'; +import { useGetMore } from './hooks/useGetMore'; +import { useGoToHomeOnRemoved } from './hooks/useGoToHomeOnRemoved'; +import { useHasNewMessages } from './hooks/useHasNewMessages'; +import { useListIsAtBottom } from './hooks/useListIsAtBottom'; +import { useQuoteMessageByUrl } from './hooks/useQuoteMessageByUrl'; +import { useReadMessageWindowEvents } from './hooks/useReadMessageWindowEvents'; +import { useRestoreScrollPosition } from './hooks/useRestoreScrollPosition'; +import { useHandleUnread } from './hooks/useUnreadMessages'; + +const RoomBody = (): ReactElement => { + const chat = useChat(); + if (!chat) { + throw new Error('No ChatContext provided'); + } + + const t = useTranslation(); + const isLayoutEmbedded = useEmbeddedLayout(); + const room = useRoom(); + const user = useUser(); + const toolbox = useRoomToolbox(); + const admin = useRole('admin'); + const subscription = useRoomSubscription(); + + const retentionPolicy = useRetentionPolicy(room); + + const hideFlexTab = useUserPreference('hideFlexTab') || undefined; + const hideUsernames = useUserPreference('hideUsernames'); + const displayAvatars = useUserPreference('displayAvatars'); + + const { hasMorePreviousMessages, hasMoreNextMessages, isLoadingMoreMessages } = useRoomMessages(); + + const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead') as boolean | undefined; + + const canPreviewChannelRoom = usePermission('preview-c-room'); + + const subscribed = !!subscription; + + const canPreview = useMemo(() => { + if (room && room.t !== 'c') { + return true; + } + + if (allowAnonymousRead === true) { + return true; + } + + if (canPreviewChannelRoom) { + return true; + } + + return subscribed; + }, [allowAnonymousRead, canPreviewChannelRoom, room, subscribed]); + + const innerBoxRef = useRef(null); + + const { + wrapperRef: unreadBarWrapperRef, + innerRef: unreadBarInnerRef, + handleUnreadBarJumpToButtonClick, + handleMarkAsReadButtonClick, + counter: [unread], + } = useHandleUnread(room, subscription); + + const { innerRef: dateScrollInnerRef, bubbleRef, listStyle, ...bubbleDate } = useDateScroll(); + + const { innerRef: isAtBottomInnerRef, atBottomRef, sendToBottom, sendToBottomIfNecessary, isAtBottom } = useListIsAtBottom(); + + const { innerRef: getMoreInnerRef } = useGetMore(room._id, atBottomRef); + + const { wrapperRef: sectionWrapperRef, hideSection, innerRef: sectionScrollRef } = useBannerSection(); + + const { + uploads, + handleUploadFiles, + handleUploadProgressClose, + targeDrop: [fileUploadTriggerProps, fileUploadOverlayProps], + } = useFileUpload(); + + const { innerRef: restoreScrollPositionInnerRef } = useRestoreScrollPosition(room._id); + + const { messageListRef } = useMessageListNavigation(); + + const { handleNewMessageButtonClick, handleJumpToRecentButtonClick, handleComposerResize, hasNewMessages, newMessagesScrollRef } = + useHasNewMessages(room._id, user?._id, atBottomRef, { + sendToBottom, + sendToBottomIfNecessary, + isAtBottom, + }); + + const innerRef = useMergedRefs( + dateScrollInnerRef, + innerBoxRef, + restoreScrollPositionInnerRef, + isAtBottomInnerRef, + newMessagesScrollRef, + sectionScrollRef, + unreadBarInnerRef, + getMoreInnerRef, + + messageListRef, + ); + + const wrapperBoxRefs = useMergedRefs(unreadBarWrapperRef); + + const handleNavigateToPreviousMessage = useCallback((): void => { + chat.messageEditing.toPreviousMessage(); + }, [chat.messageEditing]); + + const handleNavigateToNextMessage = useCallback((): void => { + chat.messageEditing.toNextMessage(); + }, [chat.messageEditing]); + + const handleCloseFlexTab: MouseEventHandler = useCallback( + (e): void => { + /* + * check if the element is a button or anchor + * it considers the role as well + * usually, the flex tab is closed when clicking outside of it + * but if the user clicks on a button or anchor, we don't want to close the flex tab + * because the user could be actually trying to open the flex tab through those elements + */ + + const checkElement = (element: HTMLElement | null): boolean => { + if (!element) { + return false; + } + if (element instanceof HTMLButtonElement || element.getAttribute('role') === 'button') { + return true; + } + if (element instanceof HTMLAnchorElement || element.getAttribute('role') === 'link') { + return true; + } + return checkElement(element.parentElement); + }; + + if (checkElement(e.target as HTMLElement)) { + return; + } + + toolbox.closeTab(); + }, + [toolbox], + ); + + useGoToHomeOnRemoved(room, user?._id); + useReadMessageWindowEvents(); + useQuoteMessageByUrl(); + + const wrapperStyle = css` + position: absolute; + width: 100%; + z-index: 5; + top: 0px; + + &.animated-hidden { + top: -88px; + } + `; + + return ( + <> + + + + {!isLayoutEmbedded && room.announcement && } + + + + + + + + + + + {uploads.map((upload) => ( + + ))} + + {Boolean(unread) && ( + + )} + + + + + + + + {!canPreview ? ( + + {t('You_must_join_to_view_messages_in_this_channel')} + + ) : null} + + + + + {canPreview ? ( + <> + {hasMorePreviousMessages ? ( + {isLoadingMoreMessages ? : null} + ) : ( + + {retentionPolicy?.isActive ? : null} + + + )} + > + ) : null} + + {hasMoreNextMessages ? ( + {isLoadingMoreMessages ? : null} + ) : null} + + + + + + + + + + + + + > + ); +}; + +export default memo(RoomBody); diff --git a/apps/meteor/client/views/room/body/RoomTopic.tsx b/apps/meteor/client/views/room/body/RoomTopic.tsx new file mode 100644 index 000000000000..fd385aff9fa6 --- /dev/null +++ b/apps/meteor/client/views/room/body/RoomTopic.tsx @@ -0,0 +1,67 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { isTeamRoom } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { RoomBanner, RoomBannerContent } from '@rocket.chat/ui-client'; +import { useSetting, useUserId, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import { RoomRoles } from '../../../../app/models/client'; +import MarkdownText from '../../../components/MarkdownText'; +import { usePresence } from '../../../hooks/usePresence'; +import { useReactiveQuery } from '../../../hooks/useReactiveQuery'; +import RoomLeader from '../HeaderV2/RoomLeader'; +import { useCanEditRoom } from '../contextualBar/Info/hooks/useCanEditRoom'; + +type RoomTopicProps = { + room: IRoom; + user: IUser | null; +}; + +export const RoomTopic = ({ room, user }: RoomTopicProps) => { + const t = useTranslation(); + const canEdit = useCanEditRoom(room); + const userId = useUserId(); + const directUserId = room.uids?.filter((uid) => uid !== userId).shift(); + const directUserData = usePresence(directUserId); + const useRealName = useSetting('UI_Use_Real_Name') as boolean; + const router = useRouter(); + + const currentRoute = router.getLocationPathname(); + const href = isTeamRoom(room) ? `${currentRoute}/team-info` : `${currentRoute}/channel-settings`; + + const { data: roomLeader } = useReactiveQuery(['rooms', room._id, 'leader', { not: user?._id }], () => { + const leaderRoomRole = RoomRoles.findOne({ + 'rid': room._id, + 'roles': 'leader', + 'u._id': { $ne: user?._id }, + }); + + if (!leaderRoomRole) { + return null; + } + + return { + ...leaderRoomRole.u, + name: useRealName ? leaderRoomRole.u.name || leaderRoomRole.u.username : leaderRoomRole.u.username, + }; + }); + + const topic = room.t === 'd' && (room.uids?.length ?? 0) < 3 ? directUserData?.statusText : room.topic; + + if (!topic && !roomLeader) return null; + + return ( + + + {roomLeader && !topic && canEdit ? ( + + {t('Add_topic')} + + ) : ( + + )} + + {roomLeader && } + + ); +}; diff --git a/apps/meteor/client/views/room/body/hooks/useBannerSection.ts b/apps/meteor/client/views/room/body/hooks/useBannerSection.ts new file mode 100644 index 000000000000..0c174c1218a7 --- /dev/null +++ b/apps/meteor/client/views/room/body/hooks/useBannerSection.ts @@ -0,0 +1,44 @@ +import { useCallback, useRef, useState } from 'react'; + +import { isAtBottom } from '../../../../../app/ui/client/views/app/lib/scrolling'; +import { withThrottling } from '../../../../../lib/utils/highOrderFunctions'; + +export const useBannerSection = () => { + const [hideSection, setHideSection] = useState(false); + + const wrapperBoxRef = useRef(null); + + const innerScrollRef = useCallback((node: HTMLElement | null) => { + if (!node) { + return; + } + let lastScrollTopRef = 0; + + wrapperBoxRef.current?.addEventListener('mouseover', () => setHideSection(false)); + + node.addEventListener( + 'scroll', + withThrottling({ wait: 100 })((event) => { + const roomLeader = wrapperBoxRef.current?.querySelector('.rcx-header-section'); + + if (roomLeader) { + if (isAtBottom(node, 0)) { + setHideSection(false); + } else if (event.target.scrollTop < lastScrollTopRef) { + setHideSection(true); + } else if (!isAtBottom(node, 100) && event.target.scrollTop > parseFloat(getComputedStyle(roomLeader).height)) { + setHideSection(true); + } + } + lastScrollTopRef = event.target.scrollTop; + }), + { passive: true }, + ); + }, []); + + return { + wrapperRef: wrapperBoxRef, + hideSection, + innerRef: innerScrollRef, + }; +}; diff --git a/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx b/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx index 7e81fb2e7ccb..e2d5c1f03fe0 100644 --- a/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx +++ b/apps/meteor/client/views/room/composer/ComposerUserActionIndicator/ComposerUserActionIndicator.tsx @@ -37,7 +37,15 @@ const ComposerUserActionIndicator = ({ rid, tmid }: { rid: string; tmid?: string }, [rid, tmid]), ); return ( - + {actions.map(({ action, users }, index) => ( {index > 0 && ', '} diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx index 141ee7211305..1bcf85c702c9 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsList.tsx @@ -13,6 +13,7 @@ import { ContextualbarClose, ContextualbarEmptyContent, ContextualbarTitle, + ContextualbarSection, } from '../../../../components/Contextualbar'; import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; import { goToRoomById } from '../../../../lib/utils/goToRoomById'; @@ -61,25 +62,16 @@ function DiscussionsList({ {t('Discussions')} + + } + addon={} + /> + - - } - addon={} - /> - - {loading && ( diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx index 2edc21fe63a6..e61bd736768b 100644 --- a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx @@ -8,6 +8,7 @@ import { ContextualbarHeader, ContextualbarTitle, ContextualbarIcon, + ContextualbarSection, } from '../../../../components/Contextualbar'; import { useRoomToolbox } from '../../contexts/RoomToolboxContext'; import MessageSearch from './components/MessageSearch'; @@ -30,13 +31,13 @@ const MessageSearchTab = () => { {t('Search_Messages')} + {providerQuery.data && ( + + + + )} - {providerQuery.isSuccess && ( - <> - - - > - )} + {providerQuery.isSuccess && } {providerQuery.isError && ( {t('Search_current_provider_not_active')} diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx index 9067c4c55ebf..b9c076e36781 100644 --- a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx @@ -1,5 +1,5 @@ import type { IMessageSearchProvider } from '@rocket.chat/core-typings'; -import { Box, Field, FieldLabel, FieldRow, FieldHint, Icon, TextInput, ToggleSwitch, Callout } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, FieldHint, Icon, TextInput, ToggleSwitch, Callout } from '@rocket.chat/fuselage'; import { useDebouncedCallback, useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; @@ -48,38 +48,23 @@ const MessageSearchForm = ({ provider, onSearch }: MessageSearchFormProps) => { const t = useTranslation(); return ( - - + + + } + placeholder={t('Search_Messages')} + aria-label={t('Search_Messages')} + autoComplete='off' + {...register('searchText')} + /> + {provider.description && } + + {globalSearchEnabled && ( - - } - placeholder={t('Search_Messages')} - aria-label={t('Search_Messages')} - autoComplete='off' - {...register('searchText')} - /> - - {provider.description && } + {t('Global_Search')} + - {globalSearchEnabled && ( - - - {t('Global_Search')} - - - - )} - + )} {room.encrypted && ( {t('Encrypted_RoomType', { roomType: getRoomTypeTranslation(room).toLowerCase() })} diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx index b18fc41e41da..be479ffe77bd 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx @@ -1,6 +1,6 @@ import type { IUpload, IUploadWithUser } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { Box, Icon, TextInput, Select, Throbber, Margins } from '@rocket.chat/fuselage'; +import { Box, Icon, TextInput, Select, Throbber, ContextualbarSection } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FormEvent } from 'react'; import React, { useMemo } from 'react'; @@ -63,23 +63,19 @@ const RoomFiles = ({ {t('Files')} {onClickClose && } - - - - - } - /> - - - - - + + } + /> + + + + {loading && ( diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx index cdbd8329eaa1..214dff25dfac 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/RoomMembers.tsx @@ -15,6 +15,7 @@ import { ContextualbarContent, ContextualbarFooter, ContextualbarEmptyContent, + ContextualbarSection, } from '../../../../components/Contextualbar'; import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; import InfiniteListAnchor from '../../../../components/InfiniteListAnchor'; @@ -92,20 +93,19 @@ const RoomMembers = ({ {isTeam ? t('Teams_members') : t('Members')} {onClickClose && } - - - } - /> - - setType(value as 'online' | 'all')} value={type} options={options} /> - + + } + /> + + setType(value as 'online' | 'all')} value={type} options={options} /> - + + {loading && ( diff --git a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx index f2796b322040..cb5c5875f00c 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx @@ -1,5 +1,5 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { Box, Icon, TextInput, Select, Margins, Callout, Throbber } from '@rocket.chat/fuselage'; +import { Box, Icon, TextInput, Select, Callout, Throbber } from '@rocket.chat/fuselage'; import { useResizeObserver, useAutoFocus, useLocalStorage, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useTranslation, useUserId } from '@rocket.chat/ui-contexts'; import type { FormEvent, ReactElement } from 'react'; @@ -13,6 +13,7 @@ import { ContextualbarIcon, ContextualbarTitle, ContextualbarEmptyContent, + ContextualbarSection, } from '../../../../components/Contextualbar'; import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; import { useRecordList } from '../../../../hooks/lists/useRecordList'; @@ -123,33 +124,19 @@ const ThreadList = () => { {t('Threads')} - - - - - - } - ref={autoFocusRef} - value={searchText} - onChange={handleSearchTextChange} - /> - - handleTypeChange(String(value))} /> - - - + + } + ref={autoFocusRef} + value={searchText} + onChange={handleSearchTextChange} + /> + + handleTypeChange(String(value))} /> - + + {phase === AsyncStatePhase.LOADING && ( diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebarV2.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebarV2.tsx new file mode 100644 index 000000000000..495cd469400f --- /dev/null +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebarV2.tsx @@ -0,0 +1,67 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { IRouterPaths } from '@rocket.chat/ui-contexts'; +import { useLayout, useSetting, useCurrentModal, useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; + +import NavBar from '../../../NavBarV2'; +import Sidebar from '../../../sidebarv2'; +import AccessibilityShortcut from './AccessibilityShortcut'; +import { MainLayoutStyleTags } from './MainLayoutStyleTags'; + +const LayoutWithSidebarV2 = ({ children }: { children: ReactNode }): ReactElement => { + const { isEmbedded: embeddedLayout } = useLayout(); + + const modal = useCurrentModal(); + const currentRoutePath = useCurrentRoutePath(); + const router = useRouter(); + const removeSidenav = embeddedLayout && !currentRoutePath?.startsWith('/admin'); + const readReceiptsEnabled = useSetting('Message_Read_Receipt_Store_Users'); + + const firstChannelAfterLogin = useSetting('First_Channel_After_Login'); + + const redirected = useRef(false); + + useEffect(() => { + const needToBeRedirect = currentRoutePath && ['/', '/home'].includes(currentRoutePath); + + if (!needToBeRedirect) { + return; + } + + if (!firstChannelAfterLogin || typeof firstChannelAfterLogin !== 'string') { + return; + } + + if (redirected.current) { + return; + } + redirected.current = true; + + router.navigate({ name: `/channel/${firstChannelAfterLogin}` as keyof IRouterPaths }); + }, [router, currentRoutePath, firstChannelAfterLogin]); + + return ( + <> + + + + + {!removeSidenav && } + + {children} + + + > + ); +}; + +export default LayoutWithSidebarV2; diff --git a/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx b/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx index c830047a3424..662ee415fa26 100644 --- a/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx +++ b/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx @@ -10,7 +10,7 @@ export const MainLayoutStyleTags = () => { return ( <> - + {theme === 'dark' && } > ); diff --git a/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx b/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx index a308a3a86296..d6fc7e9d14cf 100644 --- a/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx +++ b/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx @@ -1,3 +1,4 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import { useLayout, useUser, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; import React, { lazy, useCallback } from 'react'; @@ -5,6 +6,7 @@ import React, { lazy, useCallback } from 'react'; import { Roles } from '../../../../app/models/client'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; import LayoutWithSidebar from './LayoutWithSidebar'; +import LayoutWithSidebarV2 from './LayoutWithSidebarV2'; const AccountSecurityPage = lazy(() => import('../../account/security/AccountSecurityPage')); @@ -34,7 +36,16 @@ const TwoFactorAuthSetupCheck = ({ children }: { children: ReactNode }): ReactEl ); } - return {children}; + return ( + + + {children} + + + {children} + + + ); }; export default TwoFactorAuthSetupCheck; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx index d82dd19b1af8..8bad02c135c8 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx +++ b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx @@ -1,6 +1,6 @@ import type { IRoom } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { Box, Icon, TextInput, Margins, Select, Throbber, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback, useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, Dispatch, SetStateAction, SyntheticEvent } from 'react'; @@ -15,6 +15,7 @@ import { ContextualbarContent, ContextualbarFooter, ContextualbarEmptyContent, + ContextualbarSection, } from '../../../../components/Contextualbar'; import { VirtuosoScrollbars } from '../../../../components/CustomScrollbars'; import InfiniteListAnchor from '../../../../components/InfiniteListAnchor'; @@ -85,23 +86,13 @@ const TeamsChannels = ({ {t('Team_Channels')} {onClickClose && } - - - - - } - /> - - setType(val as 'all' | 'autoJoin')} value={type} options={options} /> - - - + + } /> + + setType(val as 'all' | 'autoJoin')} value={type} options={options} /> + + {loading && ( diff --git a/apps/meteor/package.json b/apps/meteor/package.json index be037850b47a..17eef6e29f64 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -242,7 +242,7 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.2", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.3", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.32.0", diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index ee06baf84b83..199f6b1c4388 100644 --- a/apps/meteor/tests/e2e/channel-management.spec.ts +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -76,8 +76,8 @@ test.describe.serial('channel-management', () => { await poHomeChannel.dismissToast(); await poHomeChannel.tabs.btnRoomInfo.click(); - await expect(page.getByRole('dialog', { name: 'Channel info' })).toContainText('hello-topic-edited'); await expect(page.getByRole('heading', { name: 'hello-topic-edited' })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'Channel info' })).toContainText('hello-topic-edited'); await expect(poHomeChannel.getSystemMessageByText('changed room topic to hello-topic-edited')).toBeVisible(); }); @@ -122,7 +122,7 @@ test.describe.serial('channel-management', () => { }); test('should truncate the room name for small screens', async ({ page }) => { - const hugeName = faker.string.alpha(100); + const hugeName = faker.string.alpha(200); await poHomeChannel.sidenav.openChat(targetChannel); await poHomeChannel.tabs.btnRoomInfo.click(); await poHomeChannel.tabs.room.btnEdit.click(); diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index 3e95eac85fc4..add24d33a8ab 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "^0.36.0", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 83b98bdecd6e..ee3dd4e42da7 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -66,7 +66,7 @@ "@rocket.chat/apps-engine": "1.43.0", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/icons": "^0.36.0", diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 315324f5a314..03cdcd09d29f 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-tokens": "^0.33.1", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/styled": "~0.31.25", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 96ad85cf1ce6..e168ec9cf7df 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6474,6 +6474,11 @@ "Next_image": "Next Image", "Previous_image": "Previous image", "Image_gallery": "Image gallery", + "Add_topic": "Add topic", + "Chat_with_leader": "Chat with leader", "You_cant_take_chats_unavailable": "You cannot take new conversations because you're unavailable", - "You_cant_take_chats_offline": "You cannot take new conversations because you're offline" + "You_cant_take_chats_offline": "You cannot take new conversations because you're offline", + "New_navigation": "Enhanced navigation experience", + "New_navigation_description": "Explore our improved navigation, designed with clear scopes for easy access to what you need. This change serves as the foundation for future advancements in navigation management.", + "Workspace_and_user_settings": "Workspace and user settings" } diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json index a433947296cb..2e45c20f1d36 100644 --- a/packages/ui-avatar/package.json +++ b/packages/ui-avatar/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "@babel/core": "~7.22.20", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/ui-contexts": "workspace:^", "@types/babel__core": "~7.20.3", "@types/react": "~17.0.69", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index 33bff50a90c8..fe35430364cf 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "^0.36.0", "@rocket.chat/mock-providers": "workspace:^", diff --git a/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx index 36a124ece5ab..ba08eeba2332 100644 --- a/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx +++ b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx @@ -1,6 +1,7 @@ import { IconButton } from '@rocket.chat/fuselage'; import { forwardRef } from 'react'; +// TODO: remove any and type correctly const HeaderToolbarAction = forwardRef(function HeaderToolbarAction( { id, icon, action, index, title, 'data-tooltip': tooltip, ...props }, ref, diff --git a/packages/ui-client/src/components/HeaderV2/Header.stories.tsx b/packages/ui-client/src/components/HeaderV2/Header.stories.tsx new file mode 100644 index 000000000000..5988cf24c33b --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/Header.stories.tsx @@ -0,0 +1,192 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Avatar } from '@rocket.chat/fuselage'; +import { SettingsContext } from '@rocket.chat/ui-contexts'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta } from '@storybook/react'; +import { ComponentProps } from 'react'; + +import { + HeaderV2 as Header, + HeaderV2Avatar as HeaderAvatar, + HeaderV2Content as HeaderContent, + HeaderV2ContentRow as HeaderContentRow, + HeaderV2Icon as HeaderIcon, + HeaderV2Toolbar as HeaderToolbar, + HeaderV2ToolbarAction as HeaderToolbarAction, + HeaderV2ToolbarActionBadge as HeaderToolbarActionBadge, + HeaderV2Title as HeaderTitle, + HeaderV2State as HeaderState, +} from '.'; +import { RoomBanner } from '../RoomBanner'; +import { RoomBannerContent } from '../RoomBanner/RoomBannerContent'; + +const avatarUrl = + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'; + +export default { + title: 'Components/HeaderV2', + component: Header, + subcomponents: { + HeaderToolbar, + HeaderToolbarAction, + HeaderAvatar, + HeaderContent, + HeaderContentRow, + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (fn) => ( + [ + () => () => undefined, + () => ({ + _id, + type: 'action', + value: '', + actionText: '', + public: true, + blocked: false, + createdAt: new Date(), + env: true, + i18nLabel: _id, + packageValue: false, + sorter: 1, + ts: new Date(), + _updatedAt: new Date(), + }), + ], + querySettings: () => [() => () => undefined, () => []], + dispatch: async () => undefined, + }} + > + {fn()} + + ), + ], +} as ComponentMeta; + +const room: IRoom = { + t: 'c', + name: 'general general general general general general general general general general general general general general general general general general general', + _id: 'GENERAL', + encrypted: true, + autoTranslate: true, + autoTranslateLanguage: 'pt-BR', + u: { + _id: 'rocket.cat', + name: 'rocket.cat', + username: 'rocket.cat', + }, + msgs: 123, + usersCount: 3, + _updatedAt: new Date(), +} as const; + +const CustomAvatar = (props: Omit, 'url'>) => ; +const icon = { name: 'hash' } as const; + +export const Default = () => ( + + + + + + + {icon && } + {room.name} + + + + + + + + + + + +); + +export const WithBurger = () => ( + + + + + + + + + + {icon && } + {room.name} + + + + + + + + + + + +); + +export const WithActionBadge = () => ( + + + + + + + {icon && } + {room.name} + + + + + + 1 + + + 2 + + + 99 + + + + +); + +export const WithTopicBanner = () => ( + <> + + + + + + + {icon && } + {room.name} + + + + + + + + + + + + + Topic {room.name} + + > +); diff --git a/packages/ui-client/src/components/HeaderV2/Header.tsx b/packages/ui-client/src/components/HeaderV2/Header.tsx new file mode 100644 index 000000000000..4ee887e93cfb --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/Header.tsx @@ -0,0 +1,31 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import type { ComponentPropsWithoutRef } from 'react'; + +import HeaderDivider from './HeaderDivider'; + +type HeaderProps = ComponentPropsWithoutRef; + +const Header = (props: HeaderProps) => { + const { isMobile } = useLayout(); + + return ( + + + + + ); +}; + +export default Header; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderAvatar.tsx b/packages/ui-client/src/components/HeaderV2/HeaderAvatar.tsx new file mode 100644 index 000000000000..06b80697446c --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderAvatar.tsx @@ -0,0 +1,8 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderAvatarProps = ComponentPropsWithoutRef; + +const HeaderAvatar = (props: HeaderAvatarProps) => ; + +export default HeaderAvatar; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderContent.tsx b/packages/ui-client/src/components/HeaderV2/HeaderContent.tsx new file mode 100644 index 000000000000..2f8d75329493 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderContent.tsx @@ -0,0 +1,10 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderContentProps = ComponentPropsWithoutRef; + +const HeaderContent = (props: HeaderContentProps) => ( + +); + +export default HeaderContent; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderContentRow.tsx b/packages/ui-client/src/components/HeaderV2/HeaderContentRow.tsx new file mode 100644 index 000000000000..5b2ca1468667 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderContentRow.tsx @@ -0,0 +1,10 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderContentRowProps = ComponentPropsWithoutRef; + +const HeaderContentRow = (props: HeaderContentRowProps) => ( + +); + +export default HeaderContentRow; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderDivider.tsx b/packages/ui-client/src/components/HeaderV2/HeaderDivider.tsx new file mode 100644 index 000000000000..2beadec2d088 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderDivider.tsx @@ -0,0 +1,5 @@ +import { Divider } from '@rocket.chat/fuselage'; + +const HeaderDivider = () => ; + +export default HeaderDivider; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderIcon.tsx b/packages/ui-client/src/components/HeaderV2/HeaderIcon.tsx new file mode 100644 index 000000000000..33d5d760537e --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderIcon.tsx @@ -0,0 +1,15 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactElement } from 'react'; +import { isValidElement } from 'react'; + +type HeaderIconProps = { icon: ReactElement | { name: IconName; color?: string } | null }; + +const HeaderIcon = ({ icon }: HeaderIconProps) => + icon && ( + + {isValidElement(icon) ? icon : } + + ); + +export default HeaderIcon; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderState.tsx b/packages/ui-client/src/components/HeaderV2/HeaderState.tsx new file mode 100644 index 000000000000..a5f29d77b28e --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderState.tsx @@ -0,0 +1,17 @@ +import { Icon, IconButton } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ComponentPropsWithoutRef, MouseEventHandler } from 'react'; + +type HeaderStateProps = + | (Omit, 'onClick'> & { + onClick: MouseEventHandler; + }) + | (Omit, 'name' | 'onClick'> & { + icon: IconName; + onClick?: undefined; + }); + +const HeaderState = (props: HeaderStateProps) => + props.onClick ? : ; + +export default HeaderState; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderSubtitle.tsx b/packages/ui-client/src/components/HeaderV2/HeaderSubtitle.tsx new file mode 100644 index 000000000000..4f3ef1749281 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderSubtitle.tsx @@ -0,0 +1,10 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderSubtitleProps = ComponentPropsWithoutRef; + +const HeaderSubtitle = (props: HeaderSubtitleProps) => ( + +); + +export default HeaderSubtitle; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTag.tsx b/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTag.tsx new file mode 100644 index 000000000000..bef9407706e4 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTag.tsx @@ -0,0 +1,14 @@ +import { Box, Tag } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; + +type HeaderTagProps = ComponentProps; + +const HeaderTag = ({ children, ...props }: HeaderTagProps) => ( + + + {children} + + +); + +export default HeaderTag; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTagIcon.tsx b/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTagIcon.tsx new file mode 100644 index 000000000000..fd124ea09a10 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTagIcon.tsx @@ -0,0 +1,23 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ComponentProps, ReactElement } from 'react'; +import { isValidElement } from 'react'; + +type HeaderIconProps = { + icon: ReactElement | Pick, 'name' | 'color'> | null; +}; + +const HeaderTagIcon = ({ icon }: HeaderIconProps) => { + if (!icon) { + return null; + } + + return isValidElement(icon) ? ( + + {icon} + + ) : ( + + ); +}; + +export default HeaderTagIcon; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTagSkeleton.tsx b/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTagSkeleton.tsx new file mode 100644 index 000000000000..14fecc6c194b --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderTag/HeaderTagSkeleton.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@rocket.chat/fuselage'; + +const HeaderTagSkeleton = () => ; + +export default HeaderTagSkeleton; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderTag/index.ts b/packages/ui-client/src/components/HeaderV2/HeaderTag/index.ts new file mode 100644 index 000000000000..da15ab34e601 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderTag/index.ts @@ -0,0 +1,3 @@ +export { default as HeaderTag } from './HeaderTag'; +export { default as HeaderTagIcon } from './HeaderTagIcon'; +export { default as HeaderTagSkeleton } from './HeaderTagSkeleton'; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderTitle.tsx b/packages/ui-client/src/components/HeaderV2/HeaderTitle.tsx new file mode 100644 index 000000000000..88ebadc2ca11 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderTitle.tsx @@ -0,0 +1,8 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderTitleProps = ComponentPropsWithoutRef; + +const HeaderTitle = (props: HeaderTitleProps) => ; + +export default HeaderTitle; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderTitleButton.tsx b/packages/ui-client/src/components/HeaderV2/HeaderTitleButton.tsx new file mode 100644 index 000000000000..57a43b44b361 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderTitleButton.tsx @@ -0,0 +1,27 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Palette } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderTitleButtonProps = Omit, 'className'> & { className?: string }; + +const HeaderTitleButton = ({ className, ...props }: HeaderTitleButtonProps) => { + const customClass = css` + border-width: 1px; + border-style: solid; + border-color: transparent; + + &:hover { + cursor: pointer; + background-color: ${Palette.surface['surface-hover']}; + } + &:focus.focus-visible { + outline: 0; + box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']}; + border-color: ${Palette.stroke['stroke-highlight']}; + } + `; + + return ; +}; + +export default HeaderTitleButton; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbar.tsx b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbar.tsx new file mode 100644 index 000000000000..2c2377a5778a --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbar.tsx @@ -0,0 +1,18 @@ +import { useToolbar } from '@react-aria/toolbar'; +import { ButtonGroup } from '@rocket.chat/fuselage'; +import { type ComponentProps, useRef } from 'react'; + +type HeaderToolbarProps = ComponentProps; + +const HeaderToolbar = (props: HeaderToolbarProps) => { + const ref = useRef(null); + const { toolbarProps } = useToolbar(props, ref); + + return ( + + {props.children} + + ); +}; + +export default HeaderToolbar; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarAction.tsx b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarAction.tsx new file mode 100644 index 000000000000..ba08eeba2332 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarAction.tsx @@ -0,0 +1,26 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import { forwardRef } from 'react'; + +// TODO: remove any and type correctly +const HeaderToolbarAction = forwardRef(function HeaderToolbarAction( + { id, icon, action, index, title, 'data-tooltip': tooltip, ...props }, + ref, +) { + return ( + action(id)} + data-toolbox={index} + key={id} + icon={icon} + small + position='relative' + overflow='visible' + {...(tooltip ? { 'data-tooltip': tooltip, 'title': '' } : { title })} + {...props} + /> + ); +}); + +export default HeaderToolbarAction; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarActionBadge.tsx b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarActionBadge.tsx new file mode 100644 index 000000000000..8f35727fe518 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarActionBadge.tsx @@ -0,0 +1,22 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Badge } from '@rocket.chat/fuselage'; +import type { ComponentPropsWithoutRef } from 'react'; + +type HeaderToolbarActionBadgeProps = ComponentPropsWithoutRef; + +const HeaderToolbarActionBadge = (props: HeaderToolbarActionBadgeProps) => ( + + + +); + +export default HeaderToolbarActionBadge; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarDivider.tsx b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarDivider.tsx new file mode 100644 index 000000000000..f3823002e88e --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/HeaderToolbarDivider.tsx @@ -0,0 +1,5 @@ +import { Divider } from '@rocket.chat/fuselage'; + +const HeaderToolbarDivider = () => ; + +export default HeaderToolbarDivider; diff --git a/packages/ui-client/src/components/HeaderV2/HeaderToolbar/index.ts b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/index.ts new file mode 100644 index 000000000000..933f03d658c0 --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/HeaderToolbar/index.ts @@ -0,0 +1,4 @@ +export { default as HeaderToolbar } from './HeaderToolbar'; +export { default as HeaderToolbarAction } from './HeaderToolbarAction'; +export { default as HeaderToolbarActionBadge } from './HeaderToolbarActionBadge'; +export { default as HeaderToolbarDivider } from './HeaderToolbarDivider'; diff --git a/packages/ui-client/src/components/HeaderV2/index.ts b/packages/ui-client/src/components/HeaderV2/index.ts new file mode 100644 index 000000000000..2b6a829edc6f --- /dev/null +++ b/packages/ui-client/src/components/HeaderV2/index.ts @@ -0,0 +1,17 @@ +export { default as HeaderV2 } from './Header'; +export { default as HeaderV2Avatar } from './HeaderAvatar'; +export { default as HeaderV2Content } from './HeaderContent'; +export { default as HeaderV2ContentRow } from './HeaderContentRow'; +export { default as HeaderV2Divider } from './HeaderDivider'; +export { default as HeaderV2Icon } from './HeaderIcon'; +export { default as HeaderV2State } from './HeaderState'; +export { default as HeaderV2Subtitle } from './HeaderSubtitle'; +export { HeaderTag as HeaderV2Tag, HeaderTagIcon as HeaderV2TagIcon, HeaderTagSkeleton as HeaderV2TagSkeleton } from './HeaderTag'; +export { default as HeaderV2Title } from './HeaderTitle'; +export { default as HeaderV2TitleButton } from './HeaderTitleButton'; +export { + HeaderToolbar as HeaderV2Toolbar, + HeaderToolbarAction as HeaderV2ToolbarAction, + HeaderToolbarActionBadge as HeaderV2ToolbarActionBadge, + HeaderToolbarDivider as HeaderV2ToolbarDivider, +} from './HeaderToolbar'; diff --git a/packages/ui-client/src/components/RoomBanner/RoomBanner.stories.tsx b/packages/ui-client/src/components/RoomBanner/RoomBanner.stories.tsx new file mode 100644 index 000000000000..90e14b18b3e2 --- /dev/null +++ b/packages/ui-client/src/components/RoomBanner/RoomBanner.stories.tsx @@ -0,0 +1,68 @@ +import { Avatar, Box, IconButton } from '@rocket.chat/fuselage'; +import { ComponentProps } from 'react'; + +import { RoomBanner } from './RoomBanner'; +import { RoomBannerContent } from './RoomBannerContent'; + +export default { + title: 'Components/RoomBanner', + component: RoomBanner, +}; +const avatarUrl = + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'; + +const CustomAvatar = (props: Omit, 'url'>) => ; + +export const Default = () => ( + + + Plain text long long long loooooooooooooong loooong loooong loooong loooong loooong loooong teeeeeeext + + + + + Will Bell + + + + +); + +export const WithoutTopic = () => ( + + + + Add topic + + + + + + Will Bell + + + + +); + +export const TopicAndAnnouncement = () => ( + + + + Topic long long long loooooooooooooong loooong loooong loooong loooong loooong loooong loooong loooong teeeeeeext + + + + + Will Bell + + + + + + + Announcement banner google.com + + + +); diff --git a/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx b/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx new file mode 100644 index 000000000000..e5ab04580314 --- /dev/null +++ b/packages/ui-client/src/components/RoomBanner/RoomBanner.tsx @@ -0,0 +1,39 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Divider, Palette } from '@rocket.chat/fuselage'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import { ComponentProps } from 'react'; + +const clickable = css` + cursor: pointer; + &:focus-visible { + outline: ${Palette.stroke['stroke-highlight']} solid 1px; + } +`; + +export const RoomBanner = ({ onClick, className, ...props }: ComponentProps) => { + const { isMobile } = useLayout(); + + return ( + <> + + + > + ); +}; diff --git a/packages/ui-client/src/components/RoomBanner/RoomBannerContent.tsx b/packages/ui-client/src/components/RoomBanner/RoomBannerContent.tsx new file mode 100644 index 000000000000..65c3cd4f38f7 --- /dev/null +++ b/packages/ui-client/src/components/RoomBanner/RoomBannerContent.tsx @@ -0,0 +1,6 @@ +import { Box } from '@rocket.chat/fuselage'; +import { HTMLAttributes } from 'react'; + +export const RoomBannerContent = (props: Omit, 'is'>) => ( + +); diff --git a/packages/ui-client/src/components/RoomBanner/index.ts b/packages/ui-client/src/components/RoomBanner/index.ts new file mode 100644 index 000000000000..9c79ab469b62 --- /dev/null +++ b/packages/ui-client/src/components/RoomBanner/index.ts @@ -0,0 +1,2 @@ +export * from './RoomBanner'; +export * from './RoomBannerContent'; diff --git a/packages/ui-client/src/components/index.ts b/packages/ui-client/src/components/index.ts index f5f37ac1c878..c98eace56f13 100644 --- a/packages/ui-client/src/components/index.ts +++ b/packages/ui-client/src/components/index.ts @@ -8,5 +8,7 @@ export { default as TextSeparator } from './TextSeparator'; export * from './TooltipComponent'; export * as UserStatus from './UserStatus'; export * from './Header'; +export * from './HeaderV2'; export * from './MultiSelectCustom/MultiSelectCustom'; export * from './FeaturePreview/FeaturePreview'; +export * from './RoomBanner'; diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index ae202a8bd88a..4e79eebbd13a 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -1,7 +1,12 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; -export type FeaturesAvailable = 'quickReactions' | 'navigationBar' | 'enable-timestamp-message-parser' | 'contextualbarResizable'; +export type FeaturesAvailable = + | 'quickReactions' + | 'navigationBar' + | 'enable-timestamp-message-parser' + | 'contextualbarResizable' + | 'newNavigation'; export type FeaturePreviewProps = { name: FeaturesAvailable; @@ -47,6 +52,14 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [ value: false, enabled: true, }, + { + name: 'newNavigation', + i18n: 'New_navigation', + description: 'New_navigation_description', + group: 'Navigation', + value: false, + enabled: true, + }, ]; export const enabledDefaultFeatures = defaultFeaturesPreview.filter((feature) => feature.enabled); diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index d2dc4b808009..fdc00ad086e1 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/icons": "^0.36.0", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index 143a312594ac..efacce7a4586 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -6,7 +6,7 @@ "@babel/core": "~7.22.20", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/icons": "^0.36.0", "@rocket.chat/styled": "~0.31.25", diff --git a/packages/uikit-playground/package.json b/packages/uikit-playground/package.json index a6e3b0c1de24..a9293ff218ce 100644 --- a/packages/uikit-playground/package.json +++ b/packages/uikit-playground/package.json @@ -15,7 +15,7 @@ "@codemirror/tooltip": "^0.19.16", "@lezer/highlight": "^1.1.6", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.54.3", + "@rocket.chat/fuselage": "^0.55.2", "@rocket.chat/fuselage-hooks": "^0.33.1", "@rocket.chat/fuselage-polyfills": "~0.31.25", "@rocket.chat/fuselage-toastbar": "^0.32.0", diff --git a/yarn.lock b/yarn.lock index 161a50f1b4e5..938f9822053b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8937,7 +8937,7 @@ __metadata: "@rocket.chat/apps-engine": 1.43.0 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/gazzodown": "workspace:^" @@ -8999,9 +8999,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.54.3": - version: 0.54.3 - resolution: "@rocket.chat/fuselage@npm:0.54.3" +"@rocket.chat/fuselage@npm:^0.55.2": + version: 0.55.2 + resolution: "@rocket.chat/fuselage@npm:0.55.2" dependencies: "@rocket.chat/css-in-js": ^0.31.25 "@rocket.chat/css-supports": ^0.31.25 @@ -9019,7 +9019,7 @@ __metadata: react: ^17.0.2 react-dom: ^17.0.2 react-virtuoso: 1.2.4 - checksum: bec4d0b92e919103cda927520040f46004266ec5e1b3964c5bec6c6be59f8f051f2940689785f4e78984a9a18230a175b9f5f8e548f2b8f951387d567570735c + checksum: 286f4ac261621a09de74e34ef52f5c473e7c2e55ca977507ab1b1fdccf7274c2ca788a42d1415ffd4f4f629377b0bb1ed9ad70ddead7b46d3b422bac1b861431 languageName: node linkType: hard @@ -9030,7 +9030,7 @@ __metadata: "@babel/core": ~7.22.20 "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-tokens": ^0.33.1 "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/styled": ~0.31.25 @@ -9391,7 +9391,7 @@ __metadata: "@rocket.chat/favicon": "workspace:^" "@rocket.chat/forked-matrix-appservice-bridge": ^4.0.2 "@rocket.chat/forked-matrix-bot-sdk": ^0.6.0-beta.3 - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/fuselage-toastbar": ^0.32.0 @@ -10287,7 +10287,7 @@ __metadata: resolution: "@rocket.chat/ui-avatar@workspace:packages/ui-avatar" dependencies: "@babel/core": ~7.22.20 - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/ui-contexts": "workspace:^" "@types/babel__core": ~7.20.3 "@types/react": ~17.0.69 @@ -10313,7 +10313,7 @@ __metadata: "@babel/core": ~7.22.20 "@react-aria/toolbar": ^3.0.0-beta.1 "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/icons": ^0.36.0 "@rocket.chat/mock-providers": "workspace:^" @@ -10366,7 +10366,7 @@ __metadata: "@babel/core": ~7.22.20 "@react-aria/toolbar": ^3.0.0-beta.1 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/icons": ^0.36.0 "@storybook/addon-actions": ~6.5.16 "@storybook/addon-docs": ~6.5.16 @@ -10458,7 +10458,7 @@ __metadata: resolution: "@rocket.chat/ui-theming@workspace:ee/packages/ui-theming" dependencies: "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/icons": ^0.36.0 "@rocket.chat/ui-contexts": "workspace:~" @@ -10501,7 +10501,7 @@ __metadata: "@rocket.chat/css-in-js": ~0.31.25 "@rocket.chat/emitter": ~0.31.25 "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/icons": ^0.36.0 "@rocket.chat/styled": ~0.31.25 @@ -10546,7 +10546,7 @@ __metadata: "@codemirror/tooltip": ^0.19.16 "@lezer/highlight": ^1.1.6 "@rocket.chat/css-in-js": ~0.31.25 - "@rocket.chat/fuselage": ^0.54.3 + "@rocket.chat/fuselage": ^0.55.2 "@rocket.chat/fuselage-hooks": ^0.33.1 "@rocket.chat/fuselage-polyfills": ~0.31.25 "@rocket.chat/fuselage-toastbar": ^0.32.0