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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -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;

View 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);

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
};
};

View File

@@ -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);
},
});
};

View File

@@ -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 };
};

View File

@@ -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);
},
});
};

View File

@@ -0,0 +1,3 @@
import { lazy } from 'react';
export default lazy(() => import('./QuickActions'));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './ParentDiscussionRoute';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './ParentRoom';

View 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']);
});
});
});
});

View 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;

View 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;

View File

@@ -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();
});
});

View File

@@ -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 = {};

View 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;

View 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();
});
});

View 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;

View 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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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 };
};

View File

@@ -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']);
});
});

View File

@@ -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,
};
};

View File

@@ -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);
});
});

View File

@@ -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 }));

View File

@@ -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 };
};

View File

@@ -0,0 +1,3 @@
import { lazy } from 'react';
export default lazy(() => import('./RoomToolbox'));

View 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();
});
});

View 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;

View File

@@ -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>
`;

View 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);

View 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);

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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;

View 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);

View File

@@ -0,0 +1 @@
export { default } from './Header';