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,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'));