snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { HeaderTag, HeaderTagIcon } from '@rocket.chat/ui-client';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { IRoomWithFederationOriginalName } from '../contexts/RoomContext';
|
||||
|
||||
export type FederatedRoomOriginServerProps = {
|
||||
room: IRoomWithFederationOriginalName;
|
||||
};
|
||||
|
||||
const FederatedRoomOriginServer = ({ room }: FederatedRoomOriginServerProps) => {
|
||||
const originServerName = useMemo(() => room.federationOriginalName?.split(':')[1], [room.federationOriginalName]);
|
||||
if (!originServerName) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<HeaderTag data-qa='federated-origin-server-name'>
|
||||
<HeaderTagIcon icon={{ name: 'globe' }} />
|
||||
{originServerName}
|
||||
</HeaderTag>
|
||||
);
|
||||
};
|
||||
|
||||
export default FederatedRoomOriginServer;
|
||||
41
apps/meteor/client/views/room/Header/Header.tsx
Normal file
41
apps/meteor/client/views/room/Header/Header.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { isInviteSubscription } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { lazy, memo } from 'react';
|
||||
|
||||
const RoomInviteHeader = lazy(() => import('./RoomInviteHeader'));
|
||||
const OmnichannelRoomHeader = lazy(() => import('./Omnichannel/OmnichannelRoomHeader'));
|
||||
const RoomHeaderE2EESetup = lazy(() => import('./RoomHeaderE2EESetup'));
|
||||
const RoomHeader = lazy(() => import('./RoomHeader'));
|
||||
|
||||
export type HeaderProps = {
|
||||
room: IRoom;
|
||||
subscription?: ISubscription;
|
||||
};
|
||||
|
||||
const Header = ({ room, subscription }: HeaderProps) => {
|
||||
const { isEmbedded, showTopNavbarEmbeddedLayout } = useLayout();
|
||||
const encrypted = Boolean(room.encrypted);
|
||||
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
|
||||
const shouldDisplayE2EESetup = encrypted && !unencryptedMessagesAllowed;
|
||||
|
||||
if (isEmbedded && !showTopNavbarEmbeddedLayout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription && isInviteSubscription(subscription)) {
|
||||
return <RoomInviteHeader room={room} />;
|
||||
}
|
||||
|
||||
if (room.t === 'l') {
|
||||
return <OmnichannelRoomHeader />;
|
||||
}
|
||||
|
||||
if (shouldDisplayE2EESetup) {
|
||||
return <RoomHeaderE2EESetup room={room} />;
|
||||
}
|
||||
|
||||
return <RoomHeader room={room} />;
|
||||
};
|
||||
|
||||
export default memo(Header);
|
||||
21
apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx
Normal file
21
apps/meteor/client/views/room/Header/HeaderIconWithRoom.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { HeaderIcon } from '@rocket.chat/ui-client';
|
||||
|
||||
import { OmnichannelRoomIcon } from '../../../components/RoomIcon/OmnichannelRoomIcon';
|
||||
import { useRoomIcon } from '../../../hooks/useRoomIcon';
|
||||
|
||||
export type HeaderIconWithRoomProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const HeaderIconWithRoom = ({ room }: HeaderIconWithRoomProps) => {
|
||||
const icon = useRoomIcon(room);
|
||||
if (isOmnichannelRoom(room)) {
|
||||
return <OmnichannelRoomIcon source={room.source} status={room.v?.status} size='x20' />;
|
||||
}
|
||||
|
||||
return <HeaderIcon icon={icon} />;
|
||||
};
|
||||
|
||||
export default HeaderIconWithRoom;
|
||||
16
apps/meteor/client/views/room/Header/HeaderSkeleton.tsx
Normal file
16
apps/meteor/client/views/room/Header/HeaderSkeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@rocket.chat/fuselage';
|
||||
import { Header, HeaderContent, HeaderContentRow } from '@rocket.chat/ui-client';
|
||||
|
||||
const HeaderSkeleton = () => {
|
||||
return (
|
||||
<Header>
|
||||
<HeaderContent>
|
||||
<HeaderContentRow>
|
||||
<Skeleton width='25%' />
|
||||
</HeaderContentRow>
|
||||
</HeaderContent>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderSkeleton;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type BackButtonProps = { routeName?: string };
|
||||
|
||||
const BackButton = ({ routeName }: BackButtonProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const back = useStableCallback(() => {
|
||||
switch (routeName) {
|
||||
case 'omnichannel-directory':
|
||||
router.navigate({
|
||||
name: 'omnichannel-directory',
|
||||
params: {
|
||||
...router.getRouteParameters(),
|
||||
tab: 'chats',
|
||||
context: 'info',
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 'omnichannel-current-chats':
|
||||
router.navigate({
|
||||
name: 'omnichannel-current-chats',
|
||||
params: {
|
||||
...router.getRouteParameters(),
|
||||
tab: 'chats',
|
||||
context: 'info',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return <HeaderToolbarAction title={t('Back')} icon='back' onClick={back} />;
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HeaderToolbar } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useOmnichannelRoom } from '../../contexts/RoomContext';
|
||||
import RoomHeader from '../RoomHeader';
|
||||
import BackButton from './BackButton';
|
||||
import OmnichannelRoomHeaderTag from './OmnichannelRoomHeaderTag';
|
||||
import QuickActions from './QuickActions';
|
||||
|
||||
const OmnichannelRoomHeader = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const previousRouteName = useSyncExternalStore(
|
||||
router.subscribeToRouteChange,
|
||||
useCallback(() => router.getPreviousRouteName(), [router]),
|
||||
);
|
||||
|
||||
const room = useOmnichannelRoom();
|
||||
|
||||
const slots = useMemo(
|
||||
() => ({
|
||||
start: (previousRouteName === 'omnichannel-directory' || previousRouteName === 'omnichannel-current-chats') && (
|
||||
<HeaderToolbar>
|
||||
<BackButton routeName={previousRouteName} />
|
||||
</HeaderToolbar>
|
||||
),
|
||||
insideContent: <OmnichannelRoomHeaderTag />,
|
||||
posContent: <QuickActions />,
|
||||
}),
|
||||
[previousRouteName],
|
||||
);
|
||||
|
||||
return <RoomHeader slots={slots} room={room} />;
|
||||
};
|
||||
|
||||
export default OmnichannelRoomHeader;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import OmnichannelVerificationTag from '../../../omnichannel/components/OmnichannelVerificationTag';
|
||||
import AdvancedContactModal from '../../../omnichannel/contactInfo/AdvancedContactModal';
|
||||
import { useOmnichannelRoom } from '../../contexts/RoomContext';
|
||||
|
||||
const OmnichannelRoomHeaderTag = () => {
|
||||
const setModal = useSetModal();
|
||||
const { verified } = useOmnichannelRoom();
|
||||
|
||||
return (
|
||||
<Box marginInlineStart={4} withTruncatedText>
|
||||
<OmnichannelVerificationTag verified={verified} onClick={() => setModal(<AdvancedContactModal onCancel={() => setModal(null)} />)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OmnichannelRoomHeaderTag;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { Box, Dropdown, Option } from '@rocket.chat/fuselage';
|
||||
import type { Keys as IconName } from '@rocket.chat/icons';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { memo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDropdownVisibility } from './hooks/useDropdownVisibility';
|
||||
import type { QuickActionsActionOptions } from '../../../lib/quickActions';
|
||||
|
||||
export type QuickActionOptionsProps = {
|
||||
options: QuickActionsActionOptions;
|
||||
action: (id: string) => void;
|
||||
room: IOmnichannelRoom;
|
||||
icon: IconName;
|
||||
};
|
||||
|
||||
const QuickActionOptions = ({ options, room, action, icon, ...props }: QuickActionOptionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const reference = useRef<HTMLButtonElement>(null);
|
||||
const target = useRef(null);
|
||||
const { isVisible, toggle } = useDropdownVisibility({ reference, target });
|
||||
|
||||
const handleClick = (id: string) => (): void => {
|
||||
toggle();
|
||||
action(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderToolbarAction ref={reference} icon={icon} onClick={() => toggle()} secondary={isVisible} {...props} />
|
||||
{isVisible && (
|
||||
<Dropdown reference={reference} ref={target}>
|
||||
{options.map(({ id, label, validate }) => {
|
||||
const { value: valid = true, tooltip } = validate?.(room) || {};
|
||||
return (
|
||||
<Option key={id} onClick={handleClick(id)} disabled={!valid} title={!valid && tooltip ? t(tooltip) : undefined}>
|
||||
<Box fontScale='p2m' minWidth='180px'>
|
||||
{t(label)}
|
||||
</Box>
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(QuickActionOptions);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { HeaderToolbar, HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import QuickActionOptions from './QuickActionOptions';
|
||||
import { useQuickActions } from './hooks/useQuickActions';
|
||||
import { useOmnichannelRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
export type QuickActionsProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const QuickActions = ({ className }: QuickActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const room = useOmnichannelRoom();
|
||||
const { quickActions, actionDefault } = useQuickActions();
|
||||
|
||||
return (
|
||||
<HeaderToolbar aria-label={t('Omnichannel_quick_actions')}>
|
||||
{quickActions.map(({ id, color, icon, title, action = actionDefault, options }, index) => {
|
||||
const props = {
|
||||
id,
|
||||
icon,
|
||||
color,
|
||||
title: t(title),
|
||||
className,
|
||||
index,
|
||||
primary: false,
|
||||
room,
|
||||
};
|
||||
|
||||
if (options) {
|
||||
return <QuickActionOptions options={options} key={id} action={action} {...props} />;
|
||||
}
|
||||
|
||||
return <HeaderToolbarAction key={id} onClick={() => action(id)} {...props} />;
|
||||
})}
|
||||
{quickActions.length > 0 && <HeaderToolbarDivider />}
|
||||
</HeaderToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(QuickActions);
|
||||
@@ -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 = <T extends HTMLElement>({
|
||||
reference,
|
||||
target,
|
||||
}: {
|
||||
reference: RefObject<T | null>;
|
||||
target: RefObject<T | null>;
|
||||
}): {
|
||||
isVisible: boolean;
|
||||
toggle: (state?: boolean) => void;
|
||||
} => {
|
||||
const [isVisible, toggle] = useToggle(false);
|
||||
|
||||
useOutsideClick(
|
||||
[target, reference],
|
||||
useCallback(() => toggle(false), [toggle]),
|
||||
);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
toggle,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
import { roomsQueryKeys, subscriptionsQueryKeys } from '../../../../../../lib/queryKeys';
|
||||
|
||||
export const usePutChatOnHoldMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, IRoom['_id']>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, IRoom['_id']> => {
|
||||
const putChatOnHold = useEndpoint('POST', '/v1/livechat/room.onHold');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (rid) => {
|
||||
await putChatOnHold({ roomId: rid });
|
||||
},
|
||||
...options,
|
||||
onSuccess: async (data, rid, context) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['current-chats'] });
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.room(rid) });
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionsQueryKeys.subscription(rid) });
|
||||
return options?.onSuccess?.(data, rid, context);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
useSetModal,
|
||||
useToastMessageDispatch,
|
||||
useUserId,
|
||||
useSetting,
|
||||
usePermission,
|
||||
useRole,
|
||||
useEndpoint,
|
||||
useTranslation,
|
||||
useRouter,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { usePutChatOnHoldMutation } from './usePutChatOnHoldMutation';
|
||||
import { useReturnChatToQueueMutation } from './useReturnChatToQueueMutation';
|
||||
import { useHasLicenseModule } from '../../../../../../hooks/useHasLicenseModule';
|
||||
import { useLivechatInquiryStore } from '../../../../../../hooks/useLivechatInquiryStore';
|
||||
import { LegacyRoomManager } from '../../../../../../lib/LegacyRoomManager';
|
||||
import { quickActionHooks } from '../../../../../../ui';
|
||||
import { useIsRoomOverMacLimit } from '../../../../../omnichannel/hooks/useIsRoomOverMacLimit';
|
||||
import { useOmnichannelRouteConfig } from '../../../../../omnichannel/hooks/useOmnichannelRouteConfig';
|
||||
import CloseChatModal from '../../../../../omnichannel/modals/CloseChatModal';
|
||||
import CloseChatModalData from '../../../../../omnichannel/modals/CloseChatModalData';
|
||||
import ForwardChatModal from '../../../../../omnichannel/modals/ForwardChatModal';
|
||||
import PlaceChatOnHoldModal from '../../../../../omnichannel/modals/PlaceChatOnHoldModal';
|
||||
import ReturnChatQueueModal from '../../../../../omnichannel/modals/ReturnChatQueueModal';
|
||||
import TranscriptModal from '../../../../../omnichannel/modals/TranscriptModal';
|
||||
import { useOmnichannelRoom } from '../../../../contexts/RoomContext';
|
||||
import type { QuickActionsActionConfig } from '../../../../lib/quickActions';
|
||||
import { QuickActionsEnum } from '../../../../lib/quickActions';
|
||||
|
||||
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 = useStableCallback(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 = useEndpoint('POST', '/v1/livechat/transcript');
|
||||
|
||||
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 closeChat = useEndpoint('POST', '/v1/livechat/room.closeByUser');
|
||||
|
||||
const discardForRoom = useLivechatInquiryStore((state) => state.discardForRoom);
|
||||
|
||||
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 } }),
|
||||
});
|
||||
discardForRoom(rid);
|
||||
closeModal();
|
||||
dispatchToastMessage({ type: 'success', message: t('Chat_closed_successfully') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[closeChat, closeModal, dispatchToastMessage, rid, t, discardForRoom],
|
||||
);
|
||||
|
||||
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 = useStableCallback(async (id: string) => {
|
||||
switch (id) {
|
||||
case QuickActionsEnum.MoveQueue:
|
||||
setModal(
|
||||
<ReturnChatQueueModal
|
||||
onMoveChat={(): void => 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(
|
||||
<TranscriptModal
|
||||
room={room}
|
||||
email={visitorEmail}
|
||||
onRequest={handleRequestTranscript}
|
||||
onSend={handleSendTranscript}
|
||||
onDiscard={handleDiscardTranscript}
|
||||
onCancel={closeModal}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
case QuickActionsEnum.ChatForward:
|
||||
setModal(<ForwardChatModal room={room} onCancel={closeModal} />);
|
||||
break;
|
||||
case QuickActionsEnum.CloseChat:
|
||||
const email = await getVisitorEmail();
|
||||
setModal(
|
||||
room.departmentId ? (
|
||||
<CloseChatModalData visitorEmail={email} departmentId={room.departmentId} onConfirm={handleClose} onCancel={closeModal} />
|
||||
) : (
|
||||
<CloseChatModal visitorEmail={email} onConfirm={handleClose} onCancel={closeModal} />
|
||||
),
|
||||
);
|
||||
break;
|
||||
case QuickActionsEnum.OnHoldChat:
|
||||
setModal(
|
||||
<PlaceChatOnHoldModal
|
||||
onOnHoldChat={(): void => 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 { data: hasLicense = false } = 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 = useStableCallback((actionId: string) => {
|
||||
handleAction(actionId);
|
||||
});
|
||||
|
||||
return { quickActions, actionDefault };
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
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';
|
||||
|
||||
import { roomsQueryKeys, subscriptionsQueryKeys } from '../../../../../../lib/queryKeys';
|
||||
|
||||
export const useReturnChatToQueueMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, IRoom['_id']>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, IRoom['_id']> => {
|
||||
const returnChatToQueue = useEndpoint('POST', '/v1/livechat/inquiries.returnAsInquiry');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (rid) => {
|
||||
await returnChatToQueue({ roomId: rid });
|
||||
},
|
||||
...options,
|
||||
onSuccess: async (data, rid, context) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['current-chats'] });
|
||||
queryClient.removeQueries({ queryKey: roomsQueryKeys.room(rid) });
|
||||
queryClient.removeQueries({ queryKey: roomsQueryKeys.info(rid) });
|
||||
queryClient.removeQueries({ queryKey: subscriptionsQueryKeys.subscription(rid) });
|
||||
return options?.onSuccess?.(data, rid, context);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default lazy(() => import('./QuickActions'));
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import { useRoomRoute, useUserDisplayName } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ParentRoomButton from '../ParentRoomButton';
|
||||
|
||||
export type ParentDiscussionProps = {
|
||||
loading?: boolean;
|
||||
room: Pick<IRoom, '_id' | 't' | 'name' | 'fname' | 'prid' | 'u' | 'federated'>;
|
||||
};
|
||||
|
||||
const getChannelRoomName = (room: ParentDiscussionProps['room'], allowSpecialChars: boolean): string => {
|
||||
if (room.prid || isRoomFederated(room)) {
|
||||
return room.fname || '';
|
||||
}
|
||||
|
||||
return (allowSpecialChars ? room.fname || room.name : room.name) || '';
|
||||
};
|
||||
|
||||
const ParentDiscussion = ({ loading = false, room }: ParentDiscussionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const goToRoom = useRoomRoute();
|
||||
const allowSpecialChars = useSetting('UI_Allow_room_names_with_special_chars', false);
|
||||
const userDisplayName = useUserDisplayName({ name: room.fname, username: room.name });
|
||||
const roomName = room.t === 'c' || room.t === 'p' ? getChannelRoomName(room, allowSpecialChars) : (userDisplayName ?? '');
|
||||
|
||||
const handleRedirect = (): void => {
|
||||
goToRoom({ rid: room._id, t: room.t, name: room.name });
|
||||
};
|
||||
|
||||
return <ParentRoomButton loading={loading} onClick={handleRedirect} title={t('Back_to__roomName__channel', { roomName })} />;
|
||||
};
|
||||
|
||||
export default ParentDiscussion;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useUserSubscription } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ParentDiscussion from './ParentDiscussion';
|
||||
import ParentDiscussionWithData from './ParentDiscussionWithData';
|
||||
|
||||
export type ParentDiscussionRouteProps = {
|
||||
room: Pick<IRoom, '_id' | 't' | 'name' | 'fname' | 'prid' | 'u'>;
|
||||
};
|
||||
|
||||
const ParentDiscussionRoute = ({ room }: ParentDiscussionRouteProps) => {
|
||||
const { prid } = room;
|
||||
|
||||
if (!prid) {
|
||||
throw new Error('Parent room ID is missing');
|
||||
}
|
||||
|
||||
const subscription = useUserSubscription(prid);
|
||||
const parentRoomProps = useMemo(
|
||||
() =>
|
||||
subscription
|
||||
? {
|
||||
_id: subscription.rid,
|
||||
t: subscription.t,
|
||||
name: subscription.name,
|
||||
fname: subscription.fname,
|
||||
u: subscription.u,
|
||||
federated: (subscription as any).federated as IRoom['federated'],
|
||||
}
|
||||
: undefined,
|
||||
[subscription],
|
||||
);
|
||||
|
||||
if (subscription && parentRoomProps) {
|
||||
return <ParentDiscussion room={parentRoomProps} />;
|
||||
}
|
||||
|
||||
return <ParentDiscussionWithData rid={prid} />;
|
||||
};
|
||||
|
||||
export default ParentDiscussionRoute;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import ParentDiscussion from './ParentDiscussion';
|
||||
import { useRoomInfoEndpoint } from '../../../../../hooks/useRoomInfoEndpoint';
|
||||
|
||||
export type ParentDiscussionWithDataProps = { rid: IRoom['_id'] };
|
||||
|
||||
const ParentDiscussionWithData = ({ rid }: ParentDiscussionWithDataProps) => {
|
||||
const { data, isPending, isError } = useRoomInfoEndpoint(rid);
|
||||
|
||||
if (isError || !data?.room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ParentDiscussion loading={isPending} room={data.room} />;
|
||||
};
|
||||
|
||||
export default ParentDiscussionWithData;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ParentDiscussionRoute';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import ParentDiscussion from './ParentDiscussion';
|
||||
import ParentTeam from './ParentTeam';
|
||||
|
||||
export type ParentRoomProps = { room: IRoom };
|
||||
|
||||
const ParentRoom = ({ room }: ParentRoomProps) => {
|
||||
if (room.prid) {
|
||||
return <ParentDiscussion room={room} />;
|
||||
}
|
||||
|
||||
if (room.teamId && !room.teamMain) {
|
||||
return <ParentTeam room={room} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ParentRoom;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IconButton, Skeleton } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type ParentRoomButtonProps = Omit<ComponentProps<typeof IconButton>, 'icon'> & { loading: boolean };
|
||||
|
||||
const ParentRoomButton = ({ loading, ...props }: ParentRoomButtonProps) => {
|
||||
if (loading) {
|
||||
return <Skeleton variant='rect' size={28} />;
|
||||
}
|
||||
|
||||
return <IconButton small icon='arrow-back-up' {...props} />;
|
||||
};
|
||||
|
||||
export default ParentRoomButton;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { TeamType } from '@rocket.chat/core-typings';
|
||||
import { useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ParentRoomButton from './ParentRoomButton';
|
||||
import { useTeamInfoQuery } from '../../../../hooks/useTeamInfoQuery';
|
||||
import { useGoToRoom } from '../../hooks/useGoToRoom';
|
||||
import { useUserTeamsQuery } from '../../hooks/useUserTeamsQuery';
|
||||
|
||||
type APIErrorResult = { success: boolean; error: string };
|
||||
|
||||
export type ParentTeamProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const ParentTeam = ({ room }: ParentTeamProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { teamId } = room;
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
if (!teamId) {
|
||||
throw new Error('invalid rid');
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('invalid uid');
|
||||
}
|
||||
|
||||
const {
|
||||
data: teamInfo,
|
||||
isLoading: teamInfoLoading,
|
||||
isError: teamInfoError,
|
||||
} = useTeamInfoQuery(teamId, { retry: (_, error) => (error as unknown as APIErrorResult)?.error !== 'unauthorized' });
|
||||
|
||||
const { data: userTeams, isLoading: userTeamsLoading } = useUserTeamsQuery(userId);
|
||||
|
||||
const userBelongsToTeam = Boolean(userTeams?.find((team) => team._id === teamId)) || false;
|
||||
const isPublicTeam = teamInfo?.type === TeamType.PUBLIC;
|
||||
const shouldDisplayTeam = isPublicTeam || userBelongsToTeam;
|
||||
|
||||
const goToRoom = useGoToRoom();
|
||||
|
||||
const redirectToMainRoom = (): void => {
|
||||
const rid = teamInfo?.roomId;
|
||||
if (!rid) {
|
||||
return;
|
||||
}
|
||||
|
||||
goToRoom(rid);
|
||||
};
|
||||
|
||||
if (teamInfoError || !shouldDisplayTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ParentRoomButton
|
||||
loading={teamInfoLoading || userTeamsLoading}
|
||||
onClick={redirectToMainRoom}
|
||||
title={t('Back_to__roomName__team', { roomName: teamInfo?.name })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentTeam;
|
||||
1
apps/meteor/client/views/room/Header/ParentRoom/index.ts
Normal file
1
apps/meteor/client/views/room/Header/ParentRoom/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ParentRoom';
|
||||
330
apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
Normal file
330
apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { LayoutContext, RoomToolboxContext } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig, LayoutContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import RoomHeader from './RoomHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ prid: undefined, name: 'general', fname: 'General' });
|
||||
const appRoot = mockAppRoot()
|
||||
.withRoom(mockedRoom)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={mockedRoom}>{children}</FakeRoomProvider>)
|
||||
.build();
|
||||
|
||||
jest.mock('../../../../client/lib/getURL', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoom', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoom</div>),
|
||||
}));
|
||||
|
||||
const mockUseRealRoomToolbox = { value: false };
|
||||
|
||||
jest.mock('./RoomToolbox', () => {
|
||||
const ActualRoomToolbox = jest.requireActual('./RoomToolbox/RoomToolbox').default;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn((props) => {
|
||||
if (mockUseRealRoomToolbox.value) {
|
||||
return <ActualRoomToolbox {...props} />;
|
||||
}
|
||||
return <div>RoomToolbox</div>;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const MockTabComponent = () => <div>tab</div>;
|
||||
|
||||
const mockActions: RoomToolboxActionConfig[] = [
|
||||
{ id: 'thread', icon: 'thread', title: 'Threads' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'members-list', icon: 'members', title: 'Members' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'discussions', icon: 'discussion', title: 'Discussions' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'files', icon: 'clip', title: 'Files' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
{ id: 'pinned-messages', icon: 'pin', title: 'Pinned Messages' as any, groups: ['channel'], tabComponent: MockTabComponent },
|
||||
];
|
||||
|
||||
const layoutSetting = (...scopes: object[]) => JSON.stringify(scopes);
|
||||
|
||||
const mockLayoutConfig = {
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: true, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 2 },
|
||||
{ id: 'discussions', featured: false, order: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('RoomHeader', () => {
|
||||
describe('Toolbox', () => {
|
||||
it('should render toolbox by default', async () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{}} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render toolbox if roomToolbox is null and no slots are provided', () => {
|
||||
render(
|
||||
<RoomHeader
|
||||
room={mockedRoom}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render toolbox if slots.toolbox is provided', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: {} }} />, { wrapper: appRoot });
|
||||
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from roomToolbox prop', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Custom Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Custom Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom toolbox content from slots.toolbox.content', () => {
|
||||
render(<RoomHeader room={mockedRoom} slots={{ toolbox: { content: <div>Slotted Toolbox</div> } }} />, { wrapper: appRoot });
|
||||
expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room Toolbox Layout Engine', () => {
|
||||
beforeAll(() => {
|
||||
mockUseRealRoomToolbox.value = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockUseRealRoomToolbox.value = false;
|
||||
});
|
||||
|
||||
const renderWithLayout = (
|
||||
room = mockedRoom,
|
||||
layoutContextValue?: Partial<LayoutContextValue>,
|
||||
extraSettings?: Record<string, string | boolean>,
|
||||
featuresPreview = [{ name: 'roomToolboxLayout', value: true }],
|
||||
) => {
|
||||
const mockLayoutContextValue: LayoutContextValue = {
|
||||
isEmbedded: false,
|
||||
showTopNavbarEmbeddedLayout: false,
|
||||
isTablet: false,
|
||||
isMobile: false,
|
||||
roomToolboxExpanded: true,
|
||||
navbar: { searchExpanded: false },
|
||||
sidebar: {
|
||||
overlayed: false,
|
||||
setOverlayed: () => undefined,
|
||||
isCollapsed: false,
|
||||
shouldToggle: false,
|
||||
toggle: () => undefined,
|
||||
collapse: () => undefined,
|
||||
expand: () => undefined,
|
||||
close: () => undefined,
|
||||
},
|
||||
sidePanel: {
|
||||
displaySidePanel: true,
|
||||
closeSidePanel: () => undefined,
|
||||
openSidePanel: () => undefined,
|
||||
},
|
||||
size: { sidebar: '240px', contextualBar: '380px' },
|
||||
contextualBarPosition: 'relative',
|
||||
contextualBarExpanded: false,
|
||||
hiddenActions: {
|
||||
roomToolbox: [],
|
||||
messageToolbox: [],
|
||||
composerToolbox: [],
|
||||
userToolbox: [],
|
||||
},
|
||||
...layoutContextValue,
|
||||
};
|
||||
|
||||
const mockToolboxValue = {
|
||||
actions: mockActions,
|
||||
openTab: () => undefined,
|
||||
closeTab: () => undefined,
|
||||
};
|
||||
|
||||
const allowFeaturePreview = extraSettings?.Accounts_AllowFeaturePreview ?? true;
|
||||
const layoutValue = extraSettings?.Room_Toolbox_Layout ?? layoutSetting({ roomType: [room.t], ...mockLayoutConfig });
|
||||
|
||||
const appRootWithSettings = mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', allowFeaturePreview as boolean)
|
||||
.withSetting('Room_Toolbox_Layout', layoutValue as string)
|
||||
.withUserPreference('featuresPreview', featuresPreview)
|
||||
.withRoom(room)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={room}>{children}</FakeRoomProvider>)
|
||||
.build();
|
||||
|
||||
return render(
|
||||
<LayoutContext.Provider value={mockLayoutContextValue}>
|
||||
<RoomToolboxContext.Provider value={mockToolboxValue}>
|
||||
<RoomHeader room={room} slots={{}} />
|
||||
</RoomToolboxContext.Provider>
|
||||
</LayoutContext.Provider>,
|
||||
{ wrapper: appRootWithSettings },
|
||||
);
|
||||
};
|
||||
|
||||
const getToolbarSequence = () => {
|
||||
const toolbar = within(screen.getByRole('toolbar', { name: 'Toolbox_room_actions' }));
|
||||
|
||||
return [...toolbar.getAllByRole('button'), ...toolbar.queryAllByRole('separator')]
|
||||
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1))
|
||||
.map((element) => element.getAttribute('title') ?? 'divider');
|
||||
};
|
||||
|
||||
const roomScenarios = [
|
||||
{ type: 'c', name: 'public-channel', title: 'Public Channel' },
|
||||
{ type: 'p', name: 'private-group', title: 'Private Group' },
|
||||
{ type: 'd', name: 'direct-message', title: 'Direct Message' },
|
||||
] as const;
|
||||
|
||||
roomScenarios.forEach(({ type, name, title }) => {
|
||||
describe(`Room Type: ${title}`, () => {
|
||||
const testRoom = createFakeRoom({
|
||||
prid: undefined,
|
||||
t: type,
|
||||
name,
|
||||
fname: name,
|
||||
});
|
||||
|
||||
it('should respect custom featured and visible actions layout and send remaining normal actions to options dropdown', () => {
|
||||
renderWithLayout(testRoom);
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Members')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Files')).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Options')).toBeInTheDocument();
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Members', 'Discussions', 'Options']);
|
||||
});
|
||||
|
||||
it('should order featured and visible actions by their configured order instead of their declaration order', () => {
|
||||
renderWithLayout(testRoom, undefined, {
|
||||
Room_Toolbox_Layout: layoutSetting({
|
||||
roomType: [type],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: true, order: 2 },
|
||||
{ id: 'pinned-messages', featured: true, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 3 },
|
||||
{ id: 'discussions', featured: false, order: 2 },
|
||||
{ id: 'files', featured: false, order: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Pinned Messages', 'Threads', 'divider', 'Files', 'Discussions', 'Options']);
|
||||
expect(screen.queryByTitle('Members')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should collapse normal actions into kebab menu when roomToolboxExpanded is false (mobile viewport)', () => {
|
||||
renderWithLayout(testRoom, { roomToolboxExpanded: false });
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Members')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Files')).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Options')).toBeInTheDocument();
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Options']);
|
||||
});
|
||||
|
||||
describe('Soft Fallbacks', () => {
|
||||
it('should fallback to legacy behavior if feature preview is disabled', () => {
|
||||
renderWithLayout(testRoom, undefined, undefined, [{ name: 'roomToolboxLayout', value: false }]);
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Members')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Files')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', '{ invalid json }'],
|
||||
['a bare scope object instead of an array', JSON.stringify({ roomType: [type], ...mockLayoutConfig })],
|
||||
['the legacy layouts wrapper', JSON.stringify({ layouts: [{ roomType: [type], ...mockLayoutConfig }] })],
|
||||
['scoped to an unsupported room type', layoutSetting({ roomType: ['l'], ...mockLayoutConfig })],
|
||||
[
|
||||
'claiming the same room type twice',
|
||||
layoutSetting({ roomType: [type], ...mockLayoutConfig }, { roomType: [type], maxVisibleNormal: 1 }),
|
||||
],
|
||||
])('should fallback to legacy behavior if layout configuration is %s', (_, layoutValue) => {
|
||||
renderWithLayout(testRoom, undefined, { Room_Toolbox_Layout: layoutValue });
|
||||
|
||||
expect(screen.getByTitle('Threads')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Members')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Files')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to legacy behavior if no scope declares this room type', () => {
|
||||
const otherType = type === 'd' ? 'c' : 'd';
|
||||
renderWithLayout(testRoom, undefined, {
|
||||
Room_Toolbox_Layout: layoutSetting({ roomType: [otherType], ...mockLayoutConfig }),
|
||||
});
|
||||
|
||||
expect(screen.getByTitle('Files')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility (a11y)', () => {
|
||||
it('should not have any accessibility violations', async () => {
|
||||
const { container } = renderWithLayout(testRoom);
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a single setting serving every room type', () => {
|
||||
const sharedSetting = layoutSetting(
|
||||
{
|
||||
roomType: ['c', 'p'],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: true, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 2 },
|
||||
{ id: 'discussions', featured: false, order: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
roomType: ['d'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'files', featured: true, order: 1 }],
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['c', 'p'] as const)('should apply the channel scope to room type %s', (type) => {
|
||||
const room = createFakeRoom({ prid: undefined, t: type, name: type, fname: type });
|
||||
renderWithLayout(room, undefined, { Room_Toolbox_Layout: sharedSetting });
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Members', 'Discussions', 'Options']);
|
||||
});
|
||||
|
||||
it('should apply the direct message scope from the same setting', () => {
|
||||
const room = createFakeRoom({ prid: undefined, t: 'd', name: 'dm', fname: 'dm' });
|
||||
renderWithLayout(room, undefined, { Room_Toolbox_Layout: sharedSetting });
|
||||
|
||||
expect(getToolbarSequence()).toEqual(['Files', 'divider', 'Threads', 'Options']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/meteor/client/views/room/Header/RoomHeader.tsx
Normal file
70
apps/meteor/client/views/room/Header/RoomHeader.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated } from '@rocket.chat/core-typings';
|
||||
import { Header, HeaderContent, HeaderContentRow, HeaderToolbar } from '@rocket.chat/ui-client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FederatedRoomOriginServer from './FederatedRoomOriginServer';
|
||||
import ParentRoom from './ParentRoom';
|
||||
import RoomMemberStatus from './RoomMemberStatus';
|
||||
import RoomTitle from './RoomTitle';
|
||||
import RoomToolbox from './RoomToolbox';
|
||||
import RoomTopic from './RoomTopic';
|
||||
import Encrypted from './icons/Encrypted';
|
||||
import RoomGroupingMenu from './icons/RoomGroupingMenu';
|
||||
import Translate from './icons/Translate';
|
||||
|
||||
export type RoomHeaderProps = {
|
||||
room: IRoom;
|
||||
slots?: {
|
||||
start?: ReactNode;
|
||||
preContent?: ReactNode;
|
||||
insideContent?: ReactNode;
|
||||
posContent?: ReactNode;
|
||||
end?: ReactNode;
|
||||
toolbox?: {
|
||||
pre?: ReactNode;
|
||||
content?: ReactNode;
|
||||
pos?: ReactNode;
|
||||
hidden?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const RoomHeader = ({ room, slots = {} }: RoomHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
{slots?.start}
|
||||
<ParentRoom room={room} />
|
||||
{slots?.preContent}
|
||||
<HeaderContent>
|
||||
<HeaderContentRow>
|
||||
<RoomGroupingMenu room={room} />
|
||||
<RoomTitle room={room} />
|
||||
{isRoomFederated(room) && <FederatedRoomOriginServer room={room} />}
|
||||
<Encrypted room={room} />
|
||||
<Translate room={room} />
|
||||
<RoomTopic room={room} />
|
||||
<RoomMemberStatus room={room} />
|
||||
{slots?.insideContent}
|
||||
</HeaderContentRow>
|
||||
</HeaderContent>
|
||||
{slots?.posContent}
|
||||
{slots.toolbox?.hidden !== true && (
|
||||
<Suspense fallback={null}>
|
||||
<HeaderToolbar aria-label={t('Toolbox_room_actions')}>
|
||||
{slots?.toolbox?.pre}
|
||||
{slots?.toolbox?.content || <RoomToolbox />}
|
||||
{slots?.toolbox?.pos}
|
||||
</HeaderToolbar>
|
||||
</Suspense>
|
||||
)}
|
||||
{slots?.end}
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomHeader;
|
||||
30
apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx
Normal file
30
apps/meteor/client/views/room/Header/RoomHeaderE2EESetup.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
import RoomHeader from './RoomHeader';
|
||||
import type { RoomHeaderProps } from './RoomHeader';
|
||||
import { useE2EERoomState } from '../hooks/useE2EERoomState';
|
||||
import { useE2EEState } from '../hooks/useE2EEState';
|
||||
|
||||
const RoomToolboxE2EESetup = lazy(() => import('./RoomToolbox/RoomToolboxE2EESetup'));
|
||||
|
||||
const RoomHeaderE2EESetup = ({ room }: RoomHeaderProps) => {
|
||||
const e2eeState = useE2EEState();
|
||||
const e2eRoomState = useE2EERoomState(room._id);
|
||||
|
||||
if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === 'WAITING_KEYS') {
|
||||
return (
|
||||
<RoomHeader
|
||||
room={room}
|
||||
slots={{
|
||||
toolbox: {
|
||||
content: <RoomToolboxE2EESetup />,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <RoomHeader room={room} />;
|
||||
};
|
||||
|
||||
export default RoomHeaderE2EESetup;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './RoomInviteHeader.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot().build();
|
||||
|
||||
jest.mock('../../../../client/lib/getURL', () => ({
|
||||
getURL: (url: string) => url,
|
||||
}));
|
||||
|
||||
jest.mock('./ParentRoom', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>ParentRoom</div>),
|
||||
}));
|
||||
|
||||
jest.mock('./RoomToolbox', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => <div>RoomToolbox</div>),
|
||||
}));
|
||||
|
||||
describe('RoomInviteHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />, { wrapper: appRoot });
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import RoomInviteHeader from './RoomInviteHeader';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom } from '../../../../tests/mocks/data';
|
||||
|
||||
const mockedRoom = createFakeRoom({ name: 'rocket.cat', federated: true });
|
||||
|
||||
const meta = {
|
||||
component: RoomInviteHeader,
|
||||
args: {
|
||||
room: mockedRoom,
|
||||
},
|
||||
decorators: [(story) => <FakeRoomProvider roomOverrides={mockedRoom}>{story()}</FakeRoomProvider>],
|
||||
} satisfies Meta<typeof RoomInviteHeader>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
17
apps/meteor/client/views/room/Header/RoomInviteHeader.tsx
Normal file
17
apps/meteor/client/views/room/Header/RoomInviteHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import RoomHeader from './RoomHeader';
|
||||
import type { RoomHeaderProps } from './RoomHeader';
|
||||
|
||||
const RoomInviteHeader = ({ room }: Pick<RoomHeaderProps, 'room'>) => {
|
||||
return (
|
||||
<RoomHeader
|
||||
room={room}
|
||||
slots={{
|
||||
toolbox: {
|
||||
hidden: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomInviteHeader;
|
||||
122
apps/meteor/client/views/room/Header/RoomMemberStatus.spec.tsx
Normal file
122
apps/meteor/client/views/room/Header/RoomMemberStatus.spec.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RoomMemberStatus from './RoomMemberStatus';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../tests/mocks/data';
|
||||
|
||||
const user = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user2 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
statusText: 'Sample Status',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user3 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const userWithExpiration = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
statusText: 'In a meeting',
|
||||
statusExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
status: UserStatus.BUSY,
|
||||
});
|
||||
|
||||
describe('RoomMemberStatus', () => {
|
||||
it('should render the statusText of the DM partner', () => {
|
||||
const room = createFakeRoom({ t: 'd', uids: [user._id, user2._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([user2])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sample Status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should expose the expiration text on the title attribute when the partner has one', () => {
|
||||
const room = createFakeRoom({ t: 'd', uids: [user._id, userWithExpiration._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([userWithExpiration])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
const wrapper = screen.getByText('In a meeting').closest('[title]');
|
||||
expect(wrapper).toHaveAttribute('title');
|
||||
expect(wrapper?.getAttribute('title')).not.toBe('');
|
||||
});
|
||||
|
||||
it('should not render statusText for multi-user DMs (3+ participants)', () => {
|
||||
const room = createFakeRoom({ t: 'd', uids: [user._id, user2._id, user3._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([user2, user3])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Sample Status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render statusText for non-DM rooms', () => {
|
||||
const room = createFakeRoom({ t: 'c' });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomMemberStatus room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Sample Status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
25
apps/meteor/client/views/room/Header/RoomMemberStatus.tsx
Normal file
25
apps/meteor/client/views/room/Header/RoomMemberStatus.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useUserId, useUserPresence } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import MarkdownText from '../../../components/MarkdownText';
|
||||
import { useExpirationText } from '../../../hooks/useExpirationText';
|
||||
import { getUidDirectMessage } from '../../../lib/utils/getUidDirectMessage';
|
||||
|
||||
export type RoomMemberStatusProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const RoomMemberStatus = ({ room }: RoomMemberStatusProps): ReactElement | null => {
|
||||
const directUserId = getUidDirectMessage(room, useUserId());
|
||||
const presence = useUserPresence(directUserId);
|
||||
const expirationText = useExpirationText(presence?.statusExpiresAt);
|
||||
|
||||
if (!presence?.statusText && !presence?.statusExpiresAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownText title={expirationText} content={presence?.statusText} parseEmoji={true} variant='inline' withTruncatedText />;
|
||||
};
|
||||
|
||||
export default RoomMemberStatus;
|
||||
47
apps/meteor/client/views/room/Header/RoomTitle.tsx
Normal file
47
apps/meteor/client/views/room/Header/RoomTitle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { isTeamRoom, type IRoom } from '@rocket.chat/core-typings';
|
||||
import { useButtonPattern, useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useDocumentTitle, HeaderTitle, HeaderTitleButton } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import HeaderIconWithRoom from './HeaderIconWithRoom';
|
||||
|
||||
export type RoomTitleProps = { room: IRoom };
|
||||
|
||||
const RoomTitle = ({ room }: RoomTitleProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useDocumentTitle(room.name, false);
|
||||
const { openTab } = useRoomToolbox();
|
||||
|
||||
const handleOpenRoomInfo = useStableCallback(() => {
|
||||
if (isTeamRoom(room)) {
|
||||
return openTab('team-info');
|
||||
}
|
||||
|
||||
switch (room.t) {
|
||||
case 'l':
|
||||
openTab('room-info');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
(room.uids?.length ?? 0) > 2 ? openTab('user-info-group') : openTab('user-info');
|
||||
break;
|
||||
|
||||
default:
|
||||
openTab('channel-settings');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const buttonProps = useButtonPattern(handleOpenRoomInfo);
|
||||
|
||||
return (
|
||||
<HeaderTitleButton aria-label={`${room.name}${room.encrypted ? ` - ${t('encrypted')}` : ''}`} {...buttonProps} marginInlineEnd={4}>
|
||||
<HeaderIconWithRoom room={room} />
|
||||
<HeaderTitle>{room.name}</HeaderTitle>
|
||||
</HeaderTitleButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomTitle;
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericMenu, HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox, type RenderToolboxItemParams, type RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useRoomToolboxActions } from './hooks/useRoomToolboxActions';
|
||||
|
||||
export type RoomToolboxProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const RoomToolbox = ({ className }: RoomToolboxProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toolbox = useRoomToolbox();
|
||||
const { featuredActions, hiddenActions, visibleActions } = useRoomToolboxActions(toolbox);
|
||||
|
||||
const showKebabMenu = hiddenActions.length > 0;
|
||||
|
||||
const renderDefaultToolboxItem = useStableCallback(
|
||||
({ id, className, icon, title, toolbox: { tab }, action, disabled, tooltip }: RenderToolboxItemParams) => {
|
||||
return (
|
||||
<HeaderToolbarAction
|
||||
key={id}
|
||||
className={className}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
pressed={id === tab?.id}
|
||||
onClick={action}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const mapToToolboxItem = (action: RoomToolboxActionConfig) => {
|
||||
return (action.renderToolboxItem ?? renderDefaultToolboxItem)?.({
|
||||
...action,
|
||||
action: action.action ?? (() => toolbox.openTab(action.id)),
|
||||
className,
|
||||
toolbox,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{featuredActions.map(mapToToolboxItem)}
|
||||
{featuredActions.length > 0 && <HeaderToolbarDivider />}
|
||||
{visibleActions.map(mapToToolboxItem)}
|
||||
{showKebabMenu && <GenericMenu className={className} title={t('Options')} sections={hiddenActions} placement='bottom-end' />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RoomToolbox);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableArray } from '@rocket.chat/fuselage-hooks';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox, type RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { roomActionHooksForE2EESetup } from '../../../../ui';
|
||||
|
||||
export type RoomToolboxE2EESetupProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const RoomToolboxE2EESetup = ({ className }: RoomToolboxE2EESetupProps) => {
|
||||
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 }) => (
|
||||
<HeaderToolbarAction
|
||||
key={id}
|
||||
className={className}
|
||||
id={id}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
pressed={id === tab?.id}
|
||||
onClick={action ?? (() => toolbox.openTab(id))}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomToolboxE2EESetup;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
|
||||
const setting = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
describe('resolveLayoutForRoomType', () => {
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['invalid JSON', '{ invalid json }'],
|
||||
['a bare entry object', setting({ roomType: ['c'] })],
|
||||
['the legacy layouts wrapper', setting({ layouts: [{ roomType: ['c'] }] })],
|
||||
['a JSON primitive', '42'],
|
||||
['null', 'null'],
|
||||
])('should return null when the setting is %s', (_, raw) => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
const raw = setting([
|
||||
{ roomType: ['c', 'p'], maxVisibleNormal: 4, items: [{ id: 'thread', featured: true, order: 1 }] },
|
||||
{ roomType: ['d'], maxVisibleNormal: 1, items: [{ id: 'discussions', order: 1 }] },
|
||||
]);
|
||||
|
||||
it.each([
|
||||
['c', 4],
|
||||
['p', 4],
|
||||
['d', 1],
|
||||
] as const)('should resolve the entry declaring room type %s', (roomType, maxVisibleNormal) => {
|
||||
expect(resolveLayoutForRoomType(raw, roomType)).toEqual(
|
||||
expect.objectContaining({
|
||||
maxVisibleNormal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip roomType from the resolved engine config', () => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toEqual({
|
||||
maxVisibleNormal: 4,
|
||||
items: [{ id: 'thread', featured: true, order: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for a room type no entry declares', () => {
|
||||
expect(resolveLayoutForRoomType(setting([{ roomType: ['c'] }]), 'd')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for the omnichannel room type', () => {
|
||||
expect(resolveLayoutForRoomType(raw, 'l')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the setting itself is not parseable', () => {
|
||||
expect(resolveLayoutForRoomType('{ invalid json }', 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when there are no entries', () => {
|
||||
expect(resolveLayoutForRoomType(setting([]), 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when an entry declares no roomType, as the schema requires one', () => {
|
||||
expect(resolveLayoutForRoomType(setting([{ maxVisibleNormal: 2 }, { roomType: ['c'], maxVisibleNormal: 4 }]), 'c')).toBeNull();
|
||||
});
|
||||
|
||||
describe('when the value did not go through schema validation', () => {
|
||||
it.each([
|
||||
['items is not an array', setting([{ roomType: ['c'], items: {} }])],
|
||||
['items holds a null', setting([{ roomType: ['c'], items: [null] }])],
|
||||
['items holds a primitive', setting([{ roomType: ['c'], items: ['thread'] }])],
|
||||
['an item has no id', setting([{ roomType: ['c'], items: [{ order: 1 }] }])],
|
||||
['an item id is not a string', setting([{ roomType: ['c'], items: [{ id: 1 }] }])],
|
||||
['maxVisibleNormal is a string', setting([{ roomType: ['c'], maxVisibleNormal: '4' }])],
|
||||
['maxVisibleNormal is null', setting([{ roomType: ['c'], maxVisibleNormal: null }])],
|
||||
['the same room type is claimed by two entries', setting([{ roomType: ['c', 'p'] }, { roomType: ['c'] }])],
|
||||
['the same room type is repeated inside one entry', setting([{ roomType: ['c', 'c'] }])],
|
||||
['a roomType is not an array', setting([{ roomType: 'c' }])],
|
||||
['a roomType is empty', setting([{ roomType: [] }])],
|
||||
['a roomType mixes a supported and an unsupported type', setting([{ roomType: ['c', 'l'], maxVisibleNormal: 4 }])],
|
||||
['a roomType holds only an unsupported type', setting([{ roomType: ['l'] }, { roomType: ['c'] }])],
|
||||
['a roomType holds an uppercase type', setting([{ roomType: ['C', 'c'] }])],
|
||||
['a roomType holds a non-string', setting([{ roomType: [1, 'c'] }])],
|
||||
['an entry is null', setting([{ roomType: ['c'] }, null])],
|
||||
['the matching entry itself is null', setting([null, { roomType: ['d'] }])],
|
||||
['an entry is a primitive', setting([{ roomType: ['c'] }, 'nope'])],
|
||||
['an entry is an array', setting([{ roomType: ['c'] }, []])],
|
||||
['an item featured is not a boolean', setting([{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }])],
|
||||
['an item order is not a number', setting([{ roomType: ['c'], items: [{ id: 'thread', order: 'first' }] }])],
|
||||
['an item order is an object', setting([{ roomType: ['c'], items: [{ id: 'thread', order: {} }] }])],
|
||||
['an item order is null', setting([{ roomType: ['c'], items: [{ id: 'thread', order: null }] }])],
|
||||
['an item order parses to Infinity', '[{"roomType":["c"],"items":[{"id":"thread","order":1e999}]}]'],
|
||||
['the setting is an empty list', setting([])],
|
||||
['an entry carries an unknown key', setting([{ roomType: ['c'], somethingElse: true }])],
|
||||
['an item carries an unknown key', setting([{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }])],
|
||||
['an item id is empty', setting([{ roomType: ['c'], items: [{ id: '' }] }])],
|
||||
['an item order is fractional', setting([{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }])],
|
||||
['maxVisibleNormal is negative', setting([{ roomType: ['c'], maxVisibleNormal: -1 }])],
|
||||
['maxVisibleNormal is fractional', setting([{ roomType: ['c'], maxVisibleNormal: 1.5 }])],
|
||||
])('should return null so the toolbox keeps its default layout when %s', (_, raw) => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should discard the whole setting when a later entry overlaps, even if the matching one is fine', () => {
|
||||
const raw = setting([{ roomType: ['c'], maxVisibleNormal: 4 }, { roomType: ['d'] }, { roomType: ['d'] }]);
|
||||
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { RoomType } from '@rocket.chat/core-typings';
|
||||
import { isRecord } from '@rocket.chat/tools';
|
||||
|
||||
import type { RoomToolboxLayoutConfig } from './processRoomActions';
|
||||
|
||||
const ROOM_TOOLBOX_LAYOUT_ROOM_TYPES = ['c', 'p', 'd'] as const;
|
||||
|
||||
type RoomToolboxLayoutRoomType = (typeof ROOM_TOOLBOX_LAYOUT_ROOM_TYPES)[number];
|
||||
|
||||
type RoomToolboxLayoutScope = RoomToolboxLayoutConfig & {
|
||||
roomType: RoomToolboxLayoutRoomType[];
|
||||
};
|
||||
|
||||
export type RoomToolboxLayoutSetting = RoomToolboxLayoutScope[];
|
||||
|
||||
const SUPPORTED_ROOM_TYPES: ReadonlySet<unknown> = new Set(ROOM_TOOLBOX_LAYOUT_ROOM_TYPES);
|
||||
|
||||
const ENTRY_KEYS = ['roomType', 'maxVisibleNormal', 'items'];
|
||||
|
||||
const ITEM_KEYS = ['id', 'featured', 'order'];
|
||||
|
||||
// mirrors apps/meteor/lib/roomToolboxLayout/room-toolbox-layout.schema.json, which rejects the same
|
||||
// values when the setting is saved; both have to change together
|
||||
const hasOnlyKeys = (value: Record<string, unknown>, allowed: string[]): boolean =>
|
||||
Object.keys(value).every((key) => allowed.includes(key));
|
||||
|
||||
const isCount = (value: unknown): boolean => Number.isInteger(value) && (value as number) >= 0;
|
||||
|
||||
const isLayoutItem = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ITEM_KEYS) &&
|
||||
typeof value.id === 'string' &&
|
||||
value.id.length > 0 &&
|
||||
(value.featured === undefined || typeof value.featured === 'boolean') &&
|
||||
(value.order === undefined || Number.isInteger(value.order));
|
||||
|
||||
const isRoomTypeList = (value: unknown): boolean =>
|
||||
Array.isArray(value) && value.length > 0 && new Set(value).size === value.length && value.every((type) => SUPPORTED_ROOM_TYPES.has(type));
|
||||
|
||||
const isLayoutScope = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ENTRY_KEYS) &&
|
||||
isRoomTypeList(value.roomType) &&
|
||||
(value.maxVisibleNormal === undefined || isCount(value.maxVisibleNormal)) &&
|
||||
(value.items === undefined || (Array.isArray(value.items) && value.items.every(isLayoutItem)));
|
||||
|
||||
const hasOverlappingRoomTypes = (scopes: RoomToolboxLayoutSetting): boolean => {
|
||||
const claimed = new Set<RoomToolboxLayoutRoomType>();
|
||||
|
||||
for (const { roomType: roomTypes } of scopes) {
|
||||
for (const claimedType of roomTypes) {
|
||||
if (claimed.has(claimedType)) {
|
||||
return true;
|
||||
}
|
||||
claimed.add(claimedType);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseLayoutSetting = (raw: string): RoomToolboxLayoutSetting | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isLayoutScope)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as RoomToolboxLayoutSetting;
|
||||
};
|
||||
|
||||
export const resolveLayoutForRoomType = (raw: string, roomType: RoomType): RoomToolboxLayoutConfig | null => {
|
||||
const scopes = parseLayoutSetting(raw);
|
||||
if (!scopes || hasOverlappingRoomTypes(scopes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = scopes.find(({ roomType: roomTypes }) => roomTypes.includes(roomType as RoomToolboxLayoutRoomType));
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { maxVisibleNormal: scope.maxVisibleNormal, items: scope.items };
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { processRoomActions } from './processRoomActions';
|
||||
import type { RoomToolboxLayoutConfig } from './processRoomActions';
|
||||
|
||||
describe('RoomToolbox Layout Engine (processRoomActions)', () => {
|
||||
it('should isolate featured items into featuredActions', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 10 },
|
||||
{ id: 'start-call', featured: true, order: 5 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
});
|
||||
it('should respect maxVisibleNormal limit and sort items by order', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'clean-history' }, { id: 'team-info' }, { id: 'members-list' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 10 },
|
||||
{ id: 'clean-history', featured: false, order: 250 },
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 7 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['team-info', 'members-list']);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('thread');
|
||||
expect(hiddenIds).toContain('clean-history');
|
||||
});
|
||||
it('should force app actions into hiddenActions grouped by section, ignoring config placement', () => {
|
||||
const actionsBase = [{ id: 'app-star-actions', type: 'apps' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'app-star-actions', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(0);
|
||||
expect(result.hiddenActions).toMatchObject([
|
||||
{
|
||||
id: 'apps',
|
||||
items: [{ id: 'app-star-actions' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should safely handle new/unknown actions not present in the layout config without dropping them', () => {
|
||||
const actionsBase = [{ id: 'legacy-action' }, { id: 'brand-new-action' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'legacy-action', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
const allRenderedIds = [
|
||||
...result.featuredActions.map((a) => a.id),
|
||||
...result.visibleActions.map((a) => a.id),
|
||||
...result.hiddenActions.flatMap((section) => section.items.map((i) => i.id)),
|
||||
];
|
||||
expect(allRenderedIds).toContain('brand-new-action');
|
||||
});
|
||||
it('should return base actions unchanged when the config has no items', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config = {
|
||||
items: [],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result).toEqual({
|
||||
featuredActions: [],
|
||||
visibleActions: actionsBase,
|
||||
hiddenActions: [],
|
||||
});
|
||||
});
|
||||
it('should return base actions unchanged when items is missing from the config object', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config: RoomToolboxLayoutConfig = {};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result).toEqual({
|
||||
featuredActions: [],
|
||||
visibleActions: actionsBase,
|
||||
hiddenActions: [],
|
||||
});
|
||||
});
|
||||
it('should fall back to default maxVisibleNormal (6) when not specified in config', () => {
|
||||
const actionsBase = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }, { id: '7' }];
|
||||
const config = {
|
||||
items: [
|
||||
{ id: '1', featured: false, order: 1 },
|
||||
{ id: '2', featured: false, order: 2 },
|
||||
{ id: '3', featured: false, order: 3 },
|
||||
{ id: '4', featured: false, order: 4 },
|
||||
{ id: '5', featured: false, order: 5 },
|
||||
{ id: '6', featured: false, order: 6 },
|
||||
{ id: '7', featured: false, order: 7 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(6);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toEqual(['7']);
|
||||
});
|
||||
it('should force app actions into hiddenActions', () => {
|
||||
const actionsBase = [{ id: 'app-x', type: 'apps' }, { id: 'thread' }];
|
||||
const result = processRoomActions(actionsBase, { maxVisibleNormal: 6 });
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread']);
|
||||
expect(result.hiddenActions).toMatchObject([{ id: 'apps', items: [{ id: 'app-x' }] }]);
|
||||
});
|
||||
it('should keep actions flagged as featured by themselves when the config does not mention them', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-video-call', featured: true }, { id: 'ai-actions', featured: true }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'thread', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['start-video-call', 'ai-actions']);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread']);
|
||||
expect(result.hiddenActions).toHaveLength(0);
|
||||
});
|
||||
it('should let the config demote an action flagged as featured by itself', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-video-call', featured: true }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'start-video-call', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions).toHaveLength(0);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['start-video-call', 'thread']);
|
||||
});
|
||||
it('should keep configured actions ahead of unconfigured ones even when the configured order is very large', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'discussions' }, { id: 'clean-history' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'clean-history', featured: false, order: 10000 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['clean-history', 'thread', 'discussions']);
|
||||
});
|
||||
it('should keep unconfigured actions in their incoming order', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'discussions' }, { id: 'clean-history' }, { id: 'mentions' }];
|
||||
const result = processRoomActions(actionsBase, { maxVisibleNormal: 6, items: [] });
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread', 'discussions', 'clean-history', 'mentions']);
|
||||
});
|
||||
it('should sort featured items with an omitted order after the ones that declare it', () => {
|
||||
const actionsBase = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'a', featured: true },
|
||||
{ id: 'b', featured: true, order: 3 },
|
||||
{ id: 'c', featured: true, order: 1 },
|
||||
{ id: 'd', featured: true, order: 2 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((x) => x.id)).toEqual(['c', 'd', 'b', 'a']);
|
||||
});
|
||||
it('should sort featuredActions by order when multiple featured items exist', () => {
|
||||
const actionsBase = [{ id: 'start-call' }, { id: 'thread' }, { id: 'search' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'start-call', featured: true, order: 20 },
|
||||
{ id: 'thread', featured: true, order: 5 },
|
||||
{ id: 'search', featured: false, order: 10 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
});
|
||||
it('should hide all normal actions when maxVisibleNormal is 0', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'search' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 0,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 1 },
|
||||
{ id: 'search', featured: false, order: 2 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(0);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toEqual(['thread', 'search']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export type RoomToolboxLayoutItem = {
|
||||
id: string;
|
||||
featured?: boolean;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type RoomToolboxLayoutConfig = {
|
||||
maxVisibleNormal?: number;
|
||||
items?: RoomToolboxLayoutItem[];
|
||||
};
|
||||
|
||||
export type RoomToolboxBaseAction = {
|
||||
id: string;
|
||||
type?: string;
|
||||
featured?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RoomToolboxHiddenSection = {
|
||||
id: string;
|
||||
items: RoomToolboxBaseAction[];
|
||||
};
|
||||
|
||||
const compareByOrder = (a: { order: number }, b: { order: number }): number => (a.order === b.order ? 0 : a.order - b.order);
|
||||
|
||||
const groupActionsByType = (actions: RoomToolboxBaseAction[]): RoomToolboxHiddenSection[] => {
|
||||
const sectionsMap = new Map<string, RoomToolboxHiddenSection>();
|
||||
for (const action of actions) {
|
||||
const group = action.type ?? '';
|
||||
const existing = sectionsMap.get(group);
|
||||
if (existing) {
|
||||
existing.items.push(action);
|
||||
} else {
|
||||
sectionsMap.set(group, { id: group, items: [action] });
|
||||
}
|
||||
}
|
||||
return Array.from(sectionsMap.values());
|
||||
};
|
||||
|
||||
export const processRoomActions = (actionsBase: RoomToolboxBaseAction[], config: RoomToolboxLayoutConfig) => {
|
||||
const appActions = actionsBase.filter((a) => a.type === 'apps');
|
||||
const nonAppActions = actionsBase.filter((a) => a.type !== 'apps');
|
||||
|
||||
const itemMap = new Map((config.items || []).map((item) => [item.id, item]));
|
||||
|
||||
const [featuredWithOrder, normalWithOrder] = nonAppActions.reduce<
|
||||
[{ action: RoomToolboxBaseAction; order: number }[], { action: RoomToolboxBaseAction; order: number }[]]
|
||||
>(
|
||||
(acc, action) => {
|
||||
const configItem = itemMap.get(action.id);
|
||||
const isFeatured = configItem?.featured ?? action.featured ?? false;
|
||||
const order = configItem?.order ?? Number.POSITIVE_INFINITY;
|
||||
acc[isFeatured ? 0 : 1].push({ action, order });
|
||||
return acc;
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
|
||||
featuredWithOrder.sort(compareByOrder);
|
||||
normalWithOrder.sort(compareByOrder);
|
||||
|
||||
const maxVisible = config.maxVisibleNormal ?? 6;
|
||||
const visibleActions = normalWithOrder.slice(0, maxVisible).map((n) => n.action);
|
||||
const overflowNormalActions = normalWithOrder.slice(maxVisible).map((n) => n.action);
|
||||
|
||||
const hiddenActions: RoomToolboxHiddenSection[] = [];
|
||||
|
||||
if (appActions.length > 0) {
|
||||
hiddenActions.push({ id: 'apps', items: appActions });
|
||||
}
|
||||
|
||||
hiddenActions.push(...groupActionsByType(overflowNormalActions));
|
||||
|
||||
return {
|
||||
featuredActions: featuredWithOrder.map((n) => n.action),
|
||||
visibleActions,
|
||||
hiddenActions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
import type { RoomToolboxLayoutSetting } from './parseLayoutSetting';
|
||||
import schema from '../../../../../../lib/roomToolboxLayout/room-toolbox-layout.schema.json';
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const validSetting: RoomToolboxLayoutSetting = [
|
||||
{ roomType: ['c', 'p'], maxVisibleNormal: 3, items: [{ id: 'members-list', featured: true, order: 1 }] },
|
||||
{ roomType: ['d'], maxVisibleNormal: 1 },
|
||||
];
|
||||
|
||||
describe('room toolbox layout JSON schema', () => {
|
||||
it('accepts a document typed as RoomToolboxLayoutSetting', () => {
|
||||
expect(validate(validSetting)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an entry with only roomType', [{ roomType: ['c'] }]],
|
||||
['an entry with an empty items list', [{ roomType: ['c'], items: [] }]],
|
||||
['an item with only an id', [{ roomType: ['c'], items: [{ id: 'thread' }] }]],
|
||||
['maxVisibleNormal of zero', [{ roomType: ['c'], maxVisibleNormal: 0 }]],
|
||||
['a negative order', [{ roomType: ['c'], items: [{ id: 'thread', order: -1 }] }]],
|
||||
['entries that partition the room types', [{ roomType: ['c', 'p'] }, { roomType: ['d'] }]],
|
||||
])('accepts %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a bare entry object', { roomType: ['c'] }],
|
||||
['the legacy layouts wrapper', { layouts: [{ roomType: ['c'] }] }],
|
||||
['a JSON primitive', 42],
|
||||
['null', null],
|
||||
['an empty list', []],
|
||||
['a null entry', [null]],
|
||||
['an entry that is an array', [[]]],
|
||||
['an unknown entry key', [{ roomType: ['c'], somethingElse: true }]],
|
||||
])('rejects %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
|
||||
describe('roomType', () => {
|
||||
it.each([
|
||||
['roomType is missing', [{ items: [] }]],
|
||||
['roomType is an empty list', [{ roomType: [] }]],
|
||||
['roomType is a bare string', [{ roomType: 'c' }]],
|
||||
['roomType holds the omnichannel type', [{ roomType: ['l'] }]],
|
||||
['roomType holds an unknown type', [{ roomType: ['x'] }]],
|
||||
['roomType mixes a valid and an invalid type', [{ roomType: ['c', 'l'] }]],
|
||||
['roomType holds a non-string', [{ roomType: [1] }]],
|
||||
['roomType holds an uppercase type', [{ roomType: ['C'] }]],
|
||||
['the same room type is repeated inside one entry', [{ roomType: ['c', 'c'] }]],
|
||||
['the same room type is claimed by two entries', [{ roomType: ['c', 'p'] }, { roomType: ['c'] }]],
|
||||
['every room type is claimed twice', [{ roomType: ['c', 'p', 'd'] }, { roomType: ['c', 'p', 'd'] }]],
|
||||
])('rejects when %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('items and maxVisibleNormal', () => {
|
||||
it.each([
|
||||
['items is not an array', [{ roomType: ['c'], items: {} }]],
|
||||
['an item is null', [{ roomType: ['c'], items: [null] }]],
|
||||
['an item has no id', [{ roomType: ['c'], items: [{ order: 1 }] }]],
|
||||
['an item id is empty', [{ roomType: ['c'], items: [{ id: '' }] }]],
|
||||
['an item id is not a string', [{ roomType: ['c'], items: [{ id: {} }] }]],
|
||||
['an item featured is not a boolean', [{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }]],
|
||||
['an item order is fractional', [{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }]],
|
||||
['an item has an unknown key', [{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }]],
|
||||
['maxVisibleNormal is negative', [{ roomType: ['c'], maxVisibleNormal: -1 }]],
|
||||
['maxVisibleNormal is fractional', [{ roomType: ['c'], maxVisibleNormal: 1.5 }]],
|
||||
[
|
||||
'a later entry is invalid even if the first one is fine',
|
||||
[
|
||||
{ roomType: ['c'], items: [{ id: 'thread' }] },
|
||||
{ roomType: ['d'], items: [null] },
|
||||
],
|
||||
],
|
||||
])('rejects when %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('client validation parity with the JSON schema', () => {
|
||||
const documents: [string, unknown][] = [
|
||||
['a complete document', validSetting],
|
||||
['an entry with only roomType', [{ roomType: ['c'] }]],
|
||||
['maxVisibleNormal of zero', [{ roomType: ['c'], maxVisibleNormal: 0 }]],
|
||||
['an item with only an id', [{ roomType: ['c'], items: [{ id: 'thread' }] }]],
|
||||
['an empty list', []],
|
||||
['an entry with no roomType', [{ roomType: ['c'] }, { maxVisibleNormal: 1 }]],
|
||||
['an unsupported room type', [{ roomType: ['c', 'l'] }]],
|
||||
['a duplicated room type', [{ roomType: ['c', 'c'] }]],
|
||||
['overlapping entries', [{ roomType: ['c', 'p'] }, { roomType: ['c'] }]],
|
||||
['an unknown entry key', [{ roomType: ['c'], somethingElse: true }]],
|
||||
['an unknown item key', [{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }]],
|
||||
['an empty item id', [{ roomType: ['c'], items: [{ id: '' }] }]],
|
||||
['a fractional order', [{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }]],
|
||||
['a non-boolean featured', [{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }]],
|
||||
['a negative maxVisibleNormal', [{ roomType: ['c'], maxVisibleNormal: -1 }]],
|
||||
['a fractional maxVisibleNormal', [{ roomType: ['c'], maxVisibleNormal: 1.5 }]],
|
||||
['a null entry', [{ roomType: ['c'] }, null]],
|
||||
];
|
||||
|
||||
it.each(documents)('agrees on %s', (_name, document) => {
|
||||
const raw = JSON.stringify(document);
|
||||
// ajv coerces in place, so it gets its own copy
|
||||
const acceptedBySchema = validate(JSON.parse(raw));
|
||||
const claimsPublicChannel = (document as { roomType?: string[] }[]).some((entry) => entry?.roomType?.includes('c'));
|
||||
|
||||
expect(resolveLayoutForRoomType(raw, 'c') !== null).toBe(acceptedBySchema && claimsPublicChannel);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { LayoutContext } from '@rocket.chat/ui-contexts';
|
||||
import type { LayoutContextValue, RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useRoomToolboxActions } from './useRoomToolboxActions';
|
||||
import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider';
|
||||
|
||||
const MockTabComponent = () => <div>tab</div>;
|
||||
|
||||
describe('useRoomToolboxActions', () => {
|
||||
it('should return an empty array if there are no actions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions: [], openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.featuredActions).toEqual([]);
|
||||
expect(result.current.hiddenActions).toEqual([]);
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return apps actions only inside hiddenActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions: appsActions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
const appsSection = result.current.hiddenActions[0];
|
||||
const appsItems = appsSection.items;
|
||||
|
||||
expect(appsSection).toBeDefined();
|
||||
expect(appsSection).toHaveProperty('id', 'apps');
|
||||
expect(appsItems).toMatchObject(appsActions);
|
||||
});
|
||||
|
||||
it('should return max of 6 items on visibleActions and the rest items inside hiddenActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.hiddenActions.length).toBeGreaterThan(0);
|
||||
expect(result.current.visibleActions.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should return featured items inside featuredActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.featuredActions).toMatchObject(actions.filter((action) => action.featured));
|
||||
});
|
||||
|
||||
describe('with roomToolboxLayout feature preview enabled', () => {
|
||||
const layoutSetting = (...scopes: object[]) => JSON.stringify(scopes);
|
||||
|
||||
const mockLayoutConfig = layoutSetting({
|
||||
roomType: ['c'],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'thread', featured: true, order: 10 },
|
||||
{ id: 'discussions', featured: false, order: 2 },
|
||||
{ id: 'rocket-search', featured: false, order: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
it('should respect custom featured and visible ordering for public channels', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(['team-info', 'discussions']);
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('rocket-search');
|
||||
});
|
||||
|
||||
describe('with a single setting scoped by room type', () => {
|
||||
const scopedSetting = layoutSetting(
|
||||
{
|
||||
roomType: ['c', 'p'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'thread', featured: true, order: 1 }],
|
||||
},
|
||||
{
|
||||
roomType: ['d'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'discussions', featured: false, order: 1 }],
|
||||
},
|
||||
);
|
||||
|
||||
const renderForRoomType = (roomType: 'c' | 'p' | 'd' | 'l') =>
|
||||
renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', scopedSetting)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: roomType }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
it.each(['c', 'p'] as const)('should apply the scope declaring both channel types to room type %s', (roomType) => {
|
||||
const { result } = renderForRoomType(roomType);
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
});
|
||||
|
||||
it('should apply the direct message scope from the same setting', () => {
|
||||
const { result } = renderForRoomType('d');
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(['discussions']);
|
||||
});
|
||||
|
||||
it('should fall back to legacy behavior for a room type no scope declares', () => {
|
||||
const { result } = renderForRoomType('l');
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep actions flagged as featured by themselves out of the visible row and the kebab menu', () => {
|
||||
const configWithoutStartCall = layoutSetting({
|
||||
roomType: ['c'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'team-info', featured: false, order: 1 }],
|
||||
});
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', configWithoutStartCall)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).not.toContain('start-call');
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).not.toContain('start-call');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid JSON', '{ invalid json }'],
|
||||
['an empty array', '[]'],
|
||||
['a bare scope object instead of an array', JSON.stringify({ roomType: ['c'], maxVisibleNormal: 1, items: [{ id: 'team-info' }] })],
|
||||
['the legacy layouts wrapper', JSON.stringify({ layouts: [{ roomType: ['c'], maxVisibleNormal: 1 }] })],
|
||||
['non-array items', layoutSetting({ roomType: ['c'], items: {} })],
|
||||
['items with invalid item types', layoutSetting({ roomType: ['c'], items: [null] })],
|
||||
['a scope without roomType', layoutSetting({ maxVisibleNormal: 1, items: [] })],
|
||||
['an empty roomType', layoutSetting({ roomType: [], items: [] })],
|
||||
['an unsupported roomType', layoutSetting({ roomType: ['l'], items: [] })],
|
||||
['room types overlapping across scopes', layoutSetting({ roomType: ['c', 'p'] }, { roomType: ['c'] })],
|
||||
['a room type repeated inside one scope', layoutSetting({ roomType: ['c', 'c'] })],
|
||||
])('should fall back to legacy behavior if config is %s', (_, layoutConfigValue) => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', layoutConfigValue)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
|
||||
it('should fall back to legacy behavior if feature preview is disabled', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: false }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
|
||||
const collapsedLayoutContextValue: LayoutContextValue = {
|
||||
isEmbedded: false,
|
||||
showTopNavbarEmbeddedLayout: false,
|
||||
isTablet: false,
|
||||
isMobile: false,
|
||||
roomToolboxExpanded: false,
|
||||
navbar: {
|
||||
searchExpanded: false,
|
||||
},
|
||||
sidebar: {
|
||||
overlayed: false,
|
||||
setOverlayed: () => undefined,
|
||||
isCollapsed: false,
|
||||
shouldToggle: false,
|
||||
toggle: () => undefined,
|
||||
collapse: () => undefined,
|
||||
expand: () => undefined,
|
||||
close: () => undefined,
|
||||
},
|
||||
sidePanel: {
|
||||
displaySidePanel: true,
|
||||
closeSidePanel: () => undefined,
|
||||
openSidePanel: () => undefined,
|
||||
},
|
||||
size: {
|
||||
sidebar: '240px',
|
||||
contextualBar: '380px',
|
||||
},
|
||||
contextualBarPosition: 'relative',
|
||||
contextualBarExpanded: false,
|
||||
hiddenActions: {
|
||||
roomToolbox: [],
|
||||
messageToolbox: [],
|
||||
composerToolbox: [],
|
||||
userToolbox: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe('when a demoted action can not render as a menu item', () => {
|
||||
// mirrors useAppsRoomStarActions: renders itself, has no `action` and no `tabComponent`
|
||||
const renderOnlyAction = {
|
||||
id: 'ai-actions',
|
||||
title: 'AI_Actions',
|
||||
icon: 'stars',
|
||||
groups: ['channel'],
|
||||
featured: true,
|
||||
order: 3,
|
||||
renderToolboxItem: () => null,
|
||||
} as unknown as RoomToolboxActionConfig;
|
||||
|
||||
const renderWithConfig = (config: object, layoutContextValue?: LayoutContextValue) => {
|
||||
const withLayout = (children: ReactNode) =>
|
||||
layoutContextValue ? <LayoutContext.Provider value={layoutContextValue}>{children}</LayoutContext.Provider> : children;
|
||||
|
||||
return renderHook(() => useRoomToolboxActions({ actions: [...actions, renderOnlyAction], openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', layoutSetting({ roomType: ['c'], ...config }))
|
||||
.wrap((children) => withLayout(<FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>))
|
||||
.build(),
|
||||
});
|
||||
};
|
||||
|
||||
const hiddenIdsOf = (result: { hiddenActions: { items: { id?: string }[] }[] }) =>
|
||||
result.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
|
||||
it('should keep it in the visible row when it fits, honouring the configured order', () => {
|
||||
const { result } = renderWithConfig({
|
||||
maxVisibleNormal: 3,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 2 },
|
||||
{ id: 'ai-actions', featured: false, order: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id).slice(0, 2)).toEqual(['ai-actions', 'team-info']);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).not.toContain('ai-actions');
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
|
||||
it('should pull it back into the visible row when it would overflow into the kebab', () => {
|
||||
const { result } = renderWithConfig({
|
||||
maxVisibleNormal: 1,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'ai-actions', featured: false, order: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toContain('ai-actions');
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
|
||||
it('should pin it to the featured row when the toolbox is collapsed and the visible row is gone', () => {
|
||||
const { result } = renderWithConfig(
|
||||
{
|
||||
maxVisibleNormal: 1,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'ai-actions', featured: false, order: 2 },
|
||||
],
|
||||
},
|
||||
collapsedLayoutContextValue,
|
||||
);
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toContain('ai-actions');
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unexpanded layout properly (roomToolboxExpanded = false)', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => (
|
||||
<LayoutContext.Provider value={collapsedLayoutContextValue}>
|
||||
<FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>
|
||||
</LayoutContext.Provider>
|
||||
))
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('team-info');
|
||||
expect(hiddenIds).toContain('discussions');
|
||||
expect(hiddenIds).toContain('rocket-search');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const appsActions: RoomToolboxActionConfig[] = [
|
||||
{
|
||||
id: 'app1',
|
||||
title: 'app-42212581-0966-44aa-8366-b3e92aa00df4.action_button_label_files',
|
||||
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
|
||||
type: 'apps',
|
||||
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
|
||||
},
|
||||
{
|
||||
id: 'app2',
|
||||
title: 'app-42212581-0966-44aa-8366-b3e92aa00df4.action_button_label_files',
|
||||
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
|
||||
type: 'apps',
|
||||
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
|
||||
},
|
||||
];
|
||||
|
||||
const baseActions: RoomToolboxActionConfig[] = [
|
||||
{
|
||||
id: 'team-info',
|
||||
groups: ['team'],
|
||||
anonymous: true,
|
||||
full: true,
|
||||
title: 'Teams_Info',
|
||||
icon: 'info-circled',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'thread',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
full: true,
|
||||
title: 'Threads',
|
||||
icon: 'thread',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'team-channels',
|
||||
groups: ['team'],
|
||||
anonymous: true,
|
||||
full: true,
|
||||
title: 'Team_Channels',
|
||||
icon: 'hash',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'discussions',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Discussions',
|
||||
icon: 'discussion',
|
||||
full: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'start-call',
|
||||
title: 'Call',
|
||||
icon: 'phone',
|
||||
groups: ['direct', 'direct_multiple', 'group', 'team', 'channel', 'direct'],
|
||||
disabled: false,
|
||||
full: true,
|
||||
order: 4,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'rocket-search',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
|
||||
title: 'Search_Messages',
|
||||
icon: 'magnifier',
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: 'mentions',
|
||||
groups: ['channel', 'group', 'team'],
|
||||
title: 'Mentions',
|
||||
icon: 'at',
|
||||
order: 6,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'members-list',
|
||||
groups: ['channel', 'group', 'team'],
|
||||
title: 'Teams_members',
|
||||
icon: 'members',
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
id: 'uploaded-files-list',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
|
||||
title: 'Files',
|
||||
icon: 'clip',
|
||||
order: 8,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'pinned-messages',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Pinned_Messages',
|
||||
icon: 'pin',
|
||||
order: 9,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'starred-messages',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Starred_Messages',
|
||||
icon: 'star',
|
||||
order: 10,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'keyboard-shortcut-list',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Keyboard_Shortcuts_Title',
|
||||
icon: 'keyboard',
|
||||
order: 99,
|
||||
type: 'customization',
|
||||
},
|
||||
{
|
||||
id: 'clean-history',
|
||||
groups: ['channel', 'group', 'team', 'direct_multiple', 'direct'],
|
||||
full: true,
|
||||
title: 'Prune_Messages',
|
||||
icon: 'eraser',
|
||||
order: 250,
|
||||
type: 'customization',
|
||||
},
|
||||
];
|
||||
|
||||
// real toolbox actions open a contextual bar or run an action; keep fixtures faithful to that
|
||||
const actions: RoomToolboxActionConfig[] = baseActions.map((action) => ({ tabComponent: MockTabComponent, ...action }));
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { useFeaturePreview } from '@rocket.chat/ui-client';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig, RoomToolboxContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
import { processRoomActions } from './processRoomActions';
|
||||
import type { RoomToolboxBaseAction } from './processRoomActions';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
type MenuSection = {
|
||||
id: string;
|
||||
title?: string;
|
||||
items: GenericMenuItemProps[];
|
||||
};
|
||||
|
||||
const canRenderAsMenuItem = (item: RoomToolboxActionConfig): boolean => Boolean(item.action ?? item.tabComponent);
|
||||
|
||||
const isSelfRendering = (item: RoomToolboxActionConfig): boolean => Boolean(item.renderToolboxItem) && !canRenderAsMenuItem(item);
|
||||
|
||||
const actionToMenuItem = (
|
||||
item: RoomToolboxBaseAction,
|
||||
openTab: RoomToolboxContextValue['openTab'],
|
||||
t: (key: string) => string,
|
||||
): GenericMenuItemProps => ({
|
||||
...(item as Record<string, unknown>),
|
||||
id: item.id,
|
||||
content: t(item.title as string),
|
||||
onClick:
|
||||
(item.action as (() => void) | undefined) ??
|
||||
((): void => {
|
||||
openTab(item.id);
|
||||
}),
|
||||
});
|
||||
|
||||
export const useRoomToolboxActions = ({ actions, openTab }: Pick<RoomToolboxContextValue, 'actions' | 'openTab'>) => {
|
||||
const { t } = useTranslation();
|
||||
const { roomToolboxExpanded } = useLayout();
|
||||
const room = useRoom();
|
||||
const isLayoutPreviewEnabled = useFeaturePreview('roomToolboxLayout');
|
||||
|
||||
const layoutSettingJSON = useSetting('Room_Toolbox_Layout', '');
|
||||
|
||||
const layoutConfig = useMemo(() => {
|
||||
if (!isLayoutPreviewEnabled) {
|
||||
return null;
|
||||
}
|
||||
return resolveLayoutForRoomType(layoutSettingJSON, room.t);
|
||||
}, [isLayoutPreviewEnabled, layoutSettingJSON, room.t]);
|
||||
|
||||
if (isLayoutPreviewEnabled && layoutConfig) {
|
||||
const { featuredActions, visibleActions: engineVisible, hiddenActions: engineSections } = processRoomActions(actions, layoutConfig);
|
||||
|
||||
const typedFeatured = featuredActions as RoomToolboxActionConfig[];
|
||||
const typedVisible = engineVisible as RoomToolboxActionConfig[];
|
||||
const overflowActions = engineSections.flatMap((section) => section.items as RoomToolboxActionConfig[]);
|
||||
|
||||
if (!roomToolboxExpanded) {
|
||||
const pinnedSelfRendering = [...typedVisible, ...overflowActions].filter(isSelfRendering);
|
||||
|
||||
const orderedOverflowActions = [...typedVisible, ...overflowActions].filter((item) => !item.disabled && canRenderAsMenuItem(item));
|
||||
|
||||
const sectionsMap = new Map<string, MenuSection>();
|
||||
for (const item of orderedOverflowActions) {
|
||||
const group = item.type ?? '';
|
||||
const menuItem = actionToMenuItem(item, openTab, t);
|
||||
const existing = sectionsMap.get(group);
|
||||
if (existing) {
|
||||
existing.items.push(menuItem);
|
||||
} else {
|
||||
sectionsMap.set(group, {
|
||||
id: group,
|
||||
title: group === 'apps' ? t('Apps') : '',
|
||||
items: [menuItem],
|
||||
});
|
||||
}
|
||||
}
|
||||
const hiddenActions = Array.from(sectionsMap.values());
|
||||
|
||||
return { featuredActions: [...typedFeatured, ...pinnedSelfRendering], visibleActions: [], hiddenActions };
|
||||
}
|
||||
|
||||
const hiddenActions = engineSections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.id === 'apps' ? t('Apps') : '',
|
||||
items: (section.items as RoomToolboxActionConfig[])
|
||||
.filter((item) => !item.disabled && canRenderAsMenuItem(item))
|
||||
.map((item) => actionToMenuItem(item, openTab, t)),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
|
||||
const rescuedFromOverflow = overflowActions.filter(isSelfRendering);
|
||||
|
||||
return { featuredActions: typedFeatured, visibleActions: [...typedVisible, ...rescuedFromOverflow], hiddenActions };
|
||||
}
|
||||
|
||||
const normalActions = actions.filter((action) => !action.featured && action.type !== 'apps');
|
||||
const featuredActions = actions.filter((action) => action.featured);
|
||||
const appsActions = actions.filter((action) => action.type === 'apps');
|
||||
const visibleActions = !roomToolboxExpanded ? [] : normalActions.slice(0, 6);
|
||||
|
||||
const hiddenActions = (!roomToolboxExpanded ? actions : [...appsActions, ...normalActions.slice(6)])
|
||||
.filter((item) => !item.disabled && !item.featured)
|
||||
.map((item) => ({
|
||||
content: t(item.title),
|
||||
onClick:
|
||||
item.action ??
|
||||
((): void => {
|
||||
openTab(item.id);
|
||||
}),
|
||||
...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, title: group === 'apps' ? t('Apps') : '', items: [item] };
|
||||
acc.push(newSection);
|
||||
|
||||
return acc;
|
||||
}, [] as MenuSection[]);
|
||||
|
||||
return { hiddenActions, featuredActions, visibleActions };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default lazy(() => import('./RoomToolbox'));
|
||||
153
apps/meteor/client/views/room/Header/RoomTopic.spec.tsx
Normal file
153
apps/meteor/client/views/room/Header/RoomTopic.spec.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import RoomTopic from './RoomTopic';
|
||||
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
|
||||
import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../tests/mocks/data';
|
||||
|
||||
const user = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user2 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
statusText: 'Sample Status',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
const user3 = createFakeUser({
|
||||
active: true,
|
||||
roles: ['admin'],
|
||||
type: 'user',
|
||||
status: UserStatus.ONLINE,
|
||||
});
|
||||
|
||||
describe('RoomTopic', () => {
|
||||
it('should render Add Topic when no topic is present and user can edit room', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'c', u: { _id: user._id, username: user.username, name: user.name } });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id, u: room.u, roles: ['owner'] });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withPermission('edit-room')
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add_topic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Add Topic when no topic is present and user cannot edit room', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'c' });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Add Topic when no statusText is present, user can edit room and room is a direct message room', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'd', uids: [user._id, user3._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withUsers([user3])
|
||||
.withPermission('edit-room')
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render topic when topic is present for rooms', () => {
|
||||
const room = createFakeRoom({ topic: 'Sample Topic', t: 'c' });
|
||||
const subscription = createFakeSubscription({ t: 'c', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sample Topic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the DM partner statusText (handled by RoomMemberStatus)', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'd', uids: [user._id, user2._id] });
|
||||
const subscription = createFakeSubscription({ t: 'd', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withPermission('edit-room')
|
||||
.withUser(user)
|
||||
.withUsers([user2])
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Sample Status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render Add Topic for livechat rooms', () => {
|
||||
const room = createFakeRoom({ topic: '', t: 'l' });
|
||||
const subscription = createFakeSubscription({ t: 'l', rid: room._id });
|
||||
|
||||
render(
|
||||
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
|
||||
<RoomTopic room={room} />
|
||||
</FakeRoomProvider>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withSubscriptions([{ ...subscription, ...room }] as unknown as SubscriptionWithRoom[])
|
||||
.withUser(user)
|
||||
.withPermission('edit-room')
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Add_topic')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
39
apps/meteor/client/views/room/Header/RoomTopic.tsx
Normal file
39
apps/meteor/client/views/room/Header/RoomTopic.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isPrivateRoom, isPublicRoom, isTeamRoom } from '@rocket.chat/core-typings';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useTranslation, useRouter } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import MarkdownText from '../../../components/MarkdownText';
|
||||
import { useCanEditRoom } from '../contextualBar/Info/hooks/useCanEditRoom';
|
||||
|
||||
export type RoomTopicProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const RoomTopic = ({ room }: RoomTopicProps) => {
|
||||
const t = useTranslation();
|
||||
const canEdit = useCanEditRoom(room);
|
||||
const router = useRouter();
|
||||
|
||||
const currentRoute = router.getLocationPathname();
|
||||
const href = isTeamRoom(room) ? `${currentRoute}/team-info` : `${currentRoute}/channel-settings`;
|
||||
|
||||
const { topic } = room;
|
||||
const canEditTopic = canEdit && (isPublicRoom(room) || isPrivateRoom(room));
|
||||
|
||||
if (!topic && !canEditTopic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!topic && canEditTopic) {
|
||||
return (
|
||||
<Box is='a' href={href}>
|
||||
{t('Add_topic')}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <MarkdownText color='default' parseEmoji={true} variant='inlineWithoutBreaks' withTruncatedText content={topic} />;
|
||||
};
|
||||
|
||||
export default RoomTopic;
|
||||
@@ -0,0 +1,64 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`RoomInviteHeader renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<header
|
||||
class="rcx-box rcx-box--full rcx-room-header rcx-css-12iwr9i"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-e8r6o4"
|
||||
>
|
||||
<div>
|
||||
ParentRoom
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1axz7ym"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1c6vtok"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-sdt442"
|
||||
title="Favorite rocket.cat"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-star rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
<div
|
||||
aria-label="rocket.cat"
|
||||
class="rcx-box rcx-box--full rcx-css-1lb6izd rcx-css-f2vsf1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1vxtvqh"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-globe rcx-icon rcx-css-rm4moq"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<h1
|
||||
class="rcx-box rcx-box--full rcx-css-13pxsnj"
|
||||
>
|
||||
rocket.cat
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
class="rcx-box rcx-box--full rcx-divider rcx-css-emj6cu"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
15
apps/meteor/client/views/room/Header/icons/Encrypted.tsx
Normal file
15
apps/meteor/client/views/room/Header/icons/Encrypted.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { HeaderState } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type EncryptedProps = { room: IRoom };
|
||||
|
||||
const Encrypted = ({ room }: EncryptedProps) => {
|
||||
const { t } = useTranslation();
|
||||
const e2eEnabled = useSetting('E2E_Enable');
|
||||
return e2eEnabled && room?.encrypted ? <HeaderState title={t('Encrypted')} icon='key' color='status-font-on-success' /> : null;
|
||||
};
|
||||
|
||||
export default memo(Encrypted);
|
||||
43
apps/meteor/client/views/room/Header/icons/Favorite.tsx
Normal file
43
apps/meteor/client/views/room/Header/icons/Favorite.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation';
|
||||
|
||||
export type FavoriteProps = { room: IRoom & { f?: ISubscription['f'] } };
|
||||
|
||||
const Favorite = ({ room: { _id, f: favorite = false, t: type, name } }: FavoriteProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const isFavoritesEnabled = useSetting('Favorite_Rooms', true) && ['c', 'p', 'd', 't'].includes(type);
|
||||
const { mutate: toggleFavorite } = useToggleFavoriteMutation();
|
||||
|
||||
const handleFavoriteClick = useStableCallback(() => {
|
||||
if (!isFavoritesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleFavorite({ roomId: _id, favorite: !favorite, roomName: name || '' });
|
||||
});
|
||||
|
||||
const favoriteLabel = favorite ? `${t('Unfavorite')} ${name}` : `${t('Favorite')} ${name}`;
|
||||
|
||||
if (!isFavoritesEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
small
|
||||
marginInlineEnd={4}
|
||||
icon={favorite ? 'star-filled' : 'star'}
|
||||
title={favoriteLabel}
|
||||
color={favorite ? 'status-font-on-warning' : undefined}
|
||||
onClick={handleFavoriteClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Favorite);
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import RoomGroupingMenu from './RoomGroupingMenu';
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
|
||||
import { useCategoryModals } from '../../../../sidebar/categories/hooks/useCategoryModals';
|
||||
import { useMoveRoomCategory } from '../../../../sidebar/categories/hooks/useMoveRoomCategory';
|
||||
import { useUserIsSubscribed } from '../../contexts/RoomContext';
|
||||
import { useToggleFavoriteMutation } from '../../hooks/useToggleFavoriteMutation';
|
||||
|
||||
jest.mock('../../../../hooks/useHasLicenseModule', () => ({ useHasLicenseModule: jest.fn() }));
|
||||
jest.mock('../../../../sidebar/categories/hooks/useMoveRoomCategory', () => ({ useMoveRoomCategory: jest.fn() }));
|
||||
jest.mock('../../../../sidebar/categories/hooks/useCategoryModals', () => ({ useCategoryModals: jest.fn() }));
|
||||
jest.mock('../../contexts/RoomContext', () => ({ useUserIsSubscribed: jest.fn() }));
|
||||
jest.mock('../../hooks/useToggleFavoriteMutation', () => ({ useToggleFavoriteMutation: jest.fn() }));
|
||||
|
||||
const mockedUseCategoryModals = jest.mocked(useCategoryModals);
|
||||
const mockedUseMoveRoomCategory = jest.mocked(useMoveRoomCategory);
|
||||
const mockedUseHasLicenseModule = jest.mocked(useHasLicenseModule);
|
||||
const mockedUseUserIsSubscribed = jest.mocked(useUserIsSubscribed);
|
||||
const mockedUseToggleFavoriteMutation = jest.mocked(useToggleFavoriteMutation);
|
||||
|
||||
const ROOM_ID = 'test-room-id';
|
||||
|
||||
const catDesign = { _id: 'cat-design', name: 'Design', showUnreads: false };
|
||||
const catOther = { _id: 'cat-other', name: 'Engineering', showUnreads: false };
|
||||
|
||||
const makeRoom = (overrides: Partial<IRoom & { f?: boolean; category?: string }> = {}): IRoom & { f?: boolean; category?: string } =>
|
||||
({ _id: ROOM_ID, name: 'General', t: 'c', ...overrides }) as any;
|
||||
|
||||
const wrapper = mockAppRoot().withSetting('Favorite_Rooms', true).withUserPreference('sidebarCategories', [catDesign, catOther]).build();
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseUserIsSubscribed.mockReturnValue(true);
|
||||
mockedUseToggleFavoriteMutation.mockReturnValue({ mutate: jest.fn() } as any);
|
||||
mockedUseCategoryModals.mockReturnValue({ openCreate: jest.fn(), openManage: jest.fn(), openDelete: jest.fn() });
|
||||
mockedUseMoveRoomCategory.mockReturnValue({ mutate: jest.fn(), mutateAsync: jest.fn() } as any);
|
||||
mockedUseHasLicenseModule.mockReturnValue({ data: true } as any);
|
||||
});
|
||||
|
||||
it('shows Favorites in the grouping menu when the room is favorited', async () => {
|
||||
render(<RoomGroupingMenu room={makeRoom({ f: true })} />, { wrapper });
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'Favorites' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the matching category in the grouping menu when the room has a category subscription field', async () => {
|
||||
render(<RoomGroupingMenu room={makeRoom({ f: false, category: 'cat-design' })} />, { wrapper });
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'Design' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isOmnichannelRoom, type IRoom, type ISubscription } from '@rocket.chat/core-typings';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Favorite from './Favorite';
|
||||
import RoomHeaderCategoryMenu from './RoomHeaderCategoryMenu';
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
|
||||
import { useUserIsSubscribed } from '../../contexts/RoomContext';
|
||||
|
||||
const RoomGroupingMenu = ({ room }: { room: IRoom & { f?: ISubscription['f']; category?: ISubscription['category'] } }) => {
|
||||
const subscribed = useUserIsSubscribed();
|
||||
const { data: hasLicenseModule = false } = useHasLicenseModule('experimental-enterprise-features');
|
||||
|
||||
if (!subscribed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasLicenseModule && !isOmnichannelRoom(room)) {
|
||||
return <RoomHeaderCategoryMenu room={room} />;
|
||||
}
|
||||
|
||||
return <Favorite room={room} />;
|
||||
};
|
||||
|
||||
export default memo(RoomGroupingMenu);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import { GenericMenu } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCategoryMenuItems } from '../../../../sidebar/categories/hooks/useCategoryMenuItems';
|
||||
|
||||
const getGroupingIcon = (favorite: boolean, category: boolean, isFavoritesEnabled: boolean): 'star-filled' | 'folder' | 'star' => {
|
||||
if (favorite) {
|
||||
return 'star-filled';
|
||||
}
|
||||
|
||||
if (category) {
|
||||
return 'folder';
|
||||
}
|
||||
|
||||
return isFavoritesEnabled ? 'star' : 'folder';
|
||||
};
|
||||
|
||||
type RoomHeaderCategoryMenuProps = {
|
||||
room: IRoom & { f?: ISubscription['f']; category?: ISubscription['category'] };
|
||||
};
|
||||
|
||||
const RoomHeaderCategoryMenu = ({ room }: RoomHeaderCategoryMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isFavoritesEnabled = useSetting('Favorite_Rooms', true);
|
||||
const favorite = Boolean(room.f);
|
||||
const category = Boolean(room.category);
|
||||
const groupingIcon = getGroupingIcon(favorite, category, isFavoritesEnabled);
|
||||
|
||||
const { targetItems, utilItems } = useCategoryMenuItems({
|
||||
rid: room._id,
|
||||
name: room.name,
|
||||
isFavorite: favorite,
|
||||
categoryId: room.category,
|
||||
});
|
||||
|
||||
const sections = [{ items: targetItems }, ...(utilItems.length ? [{ items: utilItems }] : [])];
|
||||
|
||||
return (
|
||||
<GenericMenu
|
||||
button={<IconButton small color={favorite ? 'status-font-on-warning' : undefined} icon={groupingIcon} marginInlineEnd={4} />}
|
||||
title={t('Move_to')}
|
||||
icon={groupingIcon}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomHeaderCategoryMenu;
|
||||
20
apps/meteor/client/views/room/Header/icons/Translate.tsx
Normal file
20
apps/meteor/client/views/room/Header/icons/Translate.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { HeaderState } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type TranslateProps = {
|
||||
room: IRoom;
|
||||
};
|
||||
|
||||
const Translate = ({ room: { autoTranslateLanguage, autoTranslate } }: TranslateProps) => {
|
||||
const { t } = useTranslation();
|
||||
const autoTranslateEnabled = useSetting('AutoTranslate_Enabled');
|
||||
const encryptedLabel = t('Translated');
|
||||
return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? (
|
||||
<HeaderState title={encryptedLabel} icon='language' color='info' />
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default memo(Translate);
|
||||
1
apps/meteor/client/views/room/Header/index.ts
Normal file
1
apps/meteor/client/views/room/Header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Header';
|
||||
Reference in New Issue
Block a user