diff --git a/.changeset/deprecate-community-saml-ldap.md b/.changeset/deprecate-community-saml-ldap.md
new file mode 100644
index 0000000000000..860c464dc56f0
--- /dev/null
+++ b/.changeset/deprecate-community-saml-ldap.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/i18n': minor
+'@rocket.chat/meteor': minor
+---
+
+Deprecates LDAP and SAML authentication on workspaces without a Premium plan. Both keep working as they are today, but the admin settings now warn that version 9.0.0 will require a license including the `ldap-enterprise` or `saml-enterprise` module, and a warning is logged when an unlicensed workspace authenticates a user through either of them.
diff --git a/.changeset/gold-jobs-help.md b/.changeset/gold-jobs-help.md
new file mode 100644
index 0000000000000..f526c8471d142
--- /dev/null
+++ b/.changeset/gold-jobs-help.md
@@ -0,0 +1,6 @@
+---
+"@rocket.chat/core-typings": minor
+"@rocket.chat/media-calls": minor
+---
+
+Adds support for call diversion handling in voice calls
diff --git a/.changeset/pink-dolls-flash.md b/.changeset/pink-dolls-flash.md
new file mode 100644
index 0000000000000..ddef59a5a327e
--- /dev/null
+++ b/.changeset/pink-dolls-flash.md
@@ -0,0 +1,5 @@
+---
+'@rocket.chat/meteor': patch
+---
+
+Replace http with serverFetch in downloadPublicImportFile to add SSRF protection
diff --git a/apps/meteor/client/components/Wizard/WizardActions.tsx b/apps/meteor/client/components/Wizard/WizardActions.tsx
deleted file mode 100644
index 13a525ace8b3f..0000000000000
--- a/apps/meteor/client/components/Wizard/WizardActions.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Box, ButtonGroup } from '@rocket.chat/fuselage';
-import type { ReactNode } from 'react';
-
-export type WizardActionsProps = {
- children: ReactNode;
-};
-
-const WizardActions = ({ children }: WizardActionsProps) => {
- return (
-
- {children}
-
- );
-};
-
-export default WizardActions;
diff --git a/apps/meteor/client/components/message/notification/AllMentionNotification.tsx b/apps/meteor/client/components/message/notification/AllMentionNotification.tsx
deleted file mode 100644
index 7f4e1562ea077..0000000000000
--- a/apps/meteor/client/components/message/notification/AllMentionNotification.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import MessageNotification from './MessageNotification';
-
-const AllMentionNotification = function All() {
- return ;
-};
-
-export default AllMentionNotification;
diff --git a/apps/meteor/client/components/message/notification/MeMentionNotification.tsx b/apps/meteor/client/components/message/notification/MeMentionNotification.tsx
deleted file mode 100644
index 27611d87c9b3b..0000000000000
--- a/apps/meteor/client/components/message/notification/MeMentionNotification.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import MessageNotification from './MessageNotification';
-
-const MeMentionNotification = () => {
- return ;
-};
-
-export default MeMentionNotification;
diff --git a/apps/meteor/client/components/message/notification/MessageNotification.tsx b/apps/meteor/client/components/message/notification/MessageNotification.tsx
deleted file mode 100644
index 249dd15c98a67..0000000000000
--- a/apps/meteor/client/components/message/notification/MessageNotification.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { Palette } from '@rocket.chat/fuselage';
-import { Box } from '@rocket.chat/fuselage';
-import type { TranslationKey } from '@rocket.chat/ui-contexts';
-import { useTranslation } from 'react-i18next';
-
-export type MessageNotificationProps = {
- label: TranslationKey;
- backgroundColor: keyof (typeof Palette)['badge'];
-};
-
-const MessageNotification = ({ label, backgroundColor }: MessageNotificationProps) => {
- const { t } = useTranslation();
- return ;
-};
-
-export default MessageNotification;
diff --git a/apps/meteor/client/components/message/notification/UnreadMessagesNotification.tsx b/apps/meteor/client/components/message/notification/UnreadMessagesNotification.tsx
deleted file mode 100644
index b7a61af1134b7..0000000000000
--- a/apps/meteor/client/components/message/notification/UnreadMessagesNotification.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import MessageNotification from './MessageNotification';
-
-const UnreadMessagesNotification = () => {
- return ;
-};
-
-export default UnreadMessagesNotification;
diff --git a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/index.ts b/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/index.ts
deleted file mode 100644
index a497f65b3bf6c..0000000000000
--- a/apps/meteor/client/components/message/toolbar/items/actions/Timestamp/TimestampPicker/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './TimestampPickerModal';
diff --git a/apps/meteor/client/lib/errors/VisitorDoesNotExistError.ts b/apps/meteor/client/lib/errors/VisitorDoesNotExistError.ts
deleted file mode 100644
index 646e17b17ff9f..0000000000000
--- a/apps/meteor/client/lib/errors/VisitorDoesNotExistError.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { RocketChatError } from './RocketChatError';
-
-export class VisitorDoesNotExistError extends RocketChatError<'visitor-does-not-exist'> {
- constructor(message = 'Visitor does not exist', details?: string) {
- super('visitor-does-not-exist', message, details);
- }
-}
diff --git a/apps/meteor/client/lib/utils/waitForElement.spec.ts b/apps/meteor/client/lib/utils/waitForElement.spec.ts
deleted file mode 100644
index ca1ebb67e4d97..0000000000000
--- a/apps/meteor/client/lib/utils/waitForElement.spec.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { waitForElement } from './waitForElement';
-
-beforeEach(() => {
- document.body.innerHTML = ``;
-});
-
-it('should return the element when it is already in the dom', async () => {
- expect(await waitForElement('.ready')).toBe(document.querySelector('.ready'));
-});
-
-it('should await until the element be in the dom and return it', async () => {
- setTimeout(() => {
- const element = document.createElement('div');
- element.setAttribute('class', 'not-ready');
- document.body.appendChild(element);
- }, 5);
- expect(await waitForElement('.not-ready')).toBe(document.querySelector('.not-ready'));
-});
diff --git a/apps/meteor/client/lib/utils/waitForElement.ts b/apps/meteor/client/lib/utils/waitForElement.ts
deleted file mode 100644
index 4f4aa1438881d..0000000000000
--- a/apps/meteor/client/lib/utils/waitForElement.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-export const waitForElement = async (
- selector: string,
- { parent = document.documentElement, signal }: { parent?: Element; signal?: AbortSignal } = {},
-): Promise => {
- const element = parent.querySelector(selector);
- return new Promise((resolve, reject) => {
- if (element) {
- return resolve(element);
- }
- const observer = new MutationObserver((_, obs) => {
- const element = parent.querySelector(selector);
- if (element) {
- obs.disconnect(); // stop observing
- return resolve(element);
- }
- });
- observer.observe(parent, {
- childList: true,
- subtree: true,
- });
-
- signal?.addEventListener('abort', () => {
- observer.disconnect();
- reject(new DOMException('Aborted', 'AbortError'));
- });
- });
-};
diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useMatrixFederationItems.ts b/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useMatrixFederationItems.ts
deleted file mode 100644
index f0851180ef6fd..0000000000000
--- a/apps/meteor/client/navbar/NavBarPagesGroup/hooks/useMatrixFederationItems.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
-import { useTranslation } from 'react-i18next';
-
-import { useCreateRoomModal } from './useCreateRoomModal';
-import MatrixFederationSearch from '../../../sidebar/header/MatrixFederationSearch';
-
-export const useMatrixFederationItems = ({
- isMatrixEnabled,
-}: {
- isMatrixEnabled: string | number | boolean | null | undefined;
-}): GenericMenuItemProps[] => {
- const { t } = useTranslation();
-
- const searchFederatedRooms = useCreateRoomModal(MatrixFederationSearch);
-
- const matrixFederationSearchItem: GenericMenuItemProps = {
- id: 'matrix-federation-search',
- content: t('Federation_Search_federated_rooms'),
- icon: 'magnifier',
- onClick: () => {
- searchFederatedRooms();
- },
- };
-
- return [...(isMatrixEnabled ? [matrixFederationSearchItem] : [])];
-};
diff --git a/apps/meteor/client/sidebar/SidebarPortal.tsx b/apps/meteor/client/sidebar/SidebarPortal.tsx
deleted file mode 100644
index aa6da64c97fc5..0000000000000
--- a/apps/meteor/client/sidebar/SidebarPortal.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Box } from '@rocket.chat/fuselage';
-import type { ReactNode } from 'react';
-import { memo } from 'react';
-import { createPortal } from 'react-dom';
-
-export type SidebarPortalProps = { children?: ReactNode };
-
-const SidebarPortal = ({ children }: SidebarPortalProps) => {
- const sidebarRoot = document.getElementById('sidebar-region');
-
- if (!sidebarRoot) {
- return null;
- }
-
- return <>{createPortal({children}, sidebarRoot)}>;
-};
-
-export default memo(SidebarPortal);
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx
deleted file mode 100644
index e20822d6589bf..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomList.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Throbber, Box } from '@rocket.chat/fuselage';
-import type { IFederationPublicRooms } from '@rocket.chat/rest-typings';
-import { VirtualizedScrollbars } from '@rocket.chat/ui-client';
-import { useSetModal, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
-import { useMutation } from '@tanstack/react-query';
-import { useTranslation } from 'react-i18next';
-import { Virtuoso } from 'react-virtuoso';
-
-import FederatedRoomListEmptyPlaceholder from './FederatedRoomListEmptyPlaceholder';
-import FederatedRoomListItem from './FederatedRoomListItem';
-import { useInfiniteFederationSearchPublicRooms } from './useInfiniteFederationSearchPublicRooms';
-import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
-
-export type FederatedRoomListProps = {
- serverName: string;
- roomName?: string;
- pageToken?: string;
- count?: number;
-};
-
-const FederatedRoomList = ({ serverName, roomName, count }: FederatedRoomListProps) => {
- const joinExternalPublicRoom = useEndpoint('POST', '/v1/federation/joinExternalPublicRoom');
-
- const setModal = useSetModal();
- const { t } = useTranslation();
- const dispatchToastMessage = useToastMessageDispatch();
- const { data, isPending, isFetchingNextPage, fetchNextPage } = useInfiniteFederationSearchPublicRooms(serverName, roomName, count);
-
- const { mutate: onClickJoin, isPending: isLoadingMutation } = useMutation({
- mutationKey: ['federation/joinExternalPublicRoom'],
-
- mutationFn: async ({ id, pageToken }: IFederationPublicRooms) => {
- return joinExternalPublicRoom({ externalRoomId: id as `!${string}:${string}`, roomName, pageToken });
- },
-
- onSuccess: (_, data) => {
- dispatchToastMessage({
- type: 'success',
- message: t('Your_request_to_join__roomName__has_been_made_it_could_take_up_to_15_minutes_to_be_processed', {
- roomName: data.name,
- }),
- });
- setModal(null);
- },
-
- onError: (error, { id }) => {
- if (error instanceof Error && error.message === 'already-joined') {
- setModal(null);
- roomCoordinator.openRouteLink('c', { rid: id });
- return;
- }
-
- dispatchToastMessage({ type: 'error', message: error });
- },
- });
-
- if (isPending) {
- return ;
- }
-
- const flattenedData = data?.pages.flatMap((page) => page.rooms);
- return (
-
-
- room?.id || index}
- overscan={4}
- components={{
- // eslint-disable-next-line react/no-multi-comp
- Footer: () => (isFetchingNextPage ? : null),
- EmptyPlaceholder: FederatedRoomListEmptyPlaceholder,
- }}
- endReached={isPending || isFetchingNextPage ? () => undefined : () => fetchNextPage()}
- itemContent={(_, room) => (
- onClickJoin(room)} {...room} disabled={isLoadingMutation} key={room.id} />
- )}
- />
-
-
- );
-};
-
-export default FederatedRoomList;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx
deleted file mode 100644
index aa3b5c9409345..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListEmptyPlaceholder.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Box } from '@rocket.chat/fuselage';
-import { useTranslation } from 'react-i18next';
-
-import GenericNoResults from '../../../components/GenericNoResults';
-
-const FederatedRoomListEmptyPlaceholder = () => {
- const { t } = useTranslation();
-
- return (
-
-
-
- );
-};
-
-export default FederatedRoomListEmptyPlaceholder;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx
deleted file mode 100644
index 19890e00a466b..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListErrorBoundary.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, Icon } from '@rocket.chat/fuselage';
-import { QueryErrorResetBoundary } from '@tanstack/react-query';
-import type { ReactNode } from 'react';
-import { ErrorBoundary } from 'react-error-boundary';
-import { useTranslation } from 'react-i18next';
-
-export type FederatedRoomListErrorBoundaryProps = {
- children?: ReactNode;
- resetKeys?: unknown[];
-};
-
-const FederatedRoomListErrorBoundary = ({ children, resetKeys }: FederatedRoomListErrorBoundaryProps) => {
- const { t } = useTranslation();
-
- return (
-
- {({ reset }) => (
- (
-
-
- {t('Error')}
- {t('Error_something_went_wrong')}
-
- {
- reset();
- resetErrorBoundary();
- }}
- >
- {t('Reload')}
-
-
-
- )}
- >
- {children}
-
- )}
-
- );
-};
-
-export default FederatedRoomListErrorBoundary;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx
deleted file mode 100644
index 4f11d9b3fc88d..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/FederatedRoomListItem.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { css } from '@rocket.chat/css-in-js';
-import { Box, Button, Icon } from '@rocket.chat/fuselage';
-import type { IFederationPublicRooms } from '@rocket.chat/rest-typings';
-import { useTranslation } from 'react-i18next';
-
-export type FederatedRoomListItemProps = IFederationPublicRooms & {
- disabled: boolean;
- onClickJoin: () => void;
-};
-
-const clampLine = css`
- line-clamp: 6;
-`;
-
-const FederatedRoomListItem = ({
- name,
- topic,
- canonicalAlias,
- joinedMembers,
- onClickJoin,
- canJoin,
- disabled,
-}: FederatedRoomListItemProps) => {
- const { t } = useTranslation();
-
- return (
-
-
-
- {name}
-
- {canJoin && (
-
- )}
- {/* Currently canJoin is only false when the ammount of members is too big. This property will be used in the future
- in case the matrix room is knock only. When that happens, the check for this should be based on the limit setting. */}
- {!canJoin && (
-
- {t('Cant_join')}
-
- )}
-
-
- {topic && (
-
- {topic}
-
- )}
-
-
- {canonicalAlias}{' '}
-
-
- {joinedMembers}
-
-
-
- );
-};
-
-export default FederatedRoomListItem;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx
deleted file mode 100644
index 2a0c567a4e608..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import {
- Divider,
- Modal,
- ButtonGroup,
- Button,
- Field,
- TextInput,
- FieldLabel,
- FieldRow,
- FieldError,
- FieldHint,
- ModalHeader,
- ModalTitle,
- ModalClose,
- ModalContent,
- ModalFooter,
-} from '@rocket.chat/fuselage';
-import type { TranslationKey } from '@rocket.chat/ui-contexts';
-import { useSetModal, useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import type { ChangeEvent } from 'react';
-import { useState } from 'react';
-
-import MatrixFederationRemoveServerList from './MatrixFederationRemoveServerList';
-import MatrixFederationSearch from './MatrixFederationSearch';
-import { useMatrixServerList } from './useMatrixServerList';
-
-export type MatrixFederationAddServerModalProps = {
- onClickClose: () => void;
-};
-
-const getErrorKey = (error: any): TranslationKey | undefined => {
- if (!error) {
- return;
- }
- if (error.error === 'invalid-server-name') {
- return 'Server_doesnt_exist';
- }
- if (error.error === 'invalid-server-name') {
- return 'Server_already_added';
- }
-};
-
-const MatrixFederationAddServerModal = ({ onClickClose }: MatrixFederationAddServerModalProps) => {
- const t = useTranslation();
- const addMatrixServer = useEndpoint('POST', '/v1/federation/addServerByUser');
- const [serverName, setServerName] = useState('');
- const [errorKey, setErrorKey] = useState();
- const setModal = useSetModal();
- const queryClient = useQueryClient();
- const dispatchToastMessage = useToastMessageDispatch();
-
- const {
- mutate: addServer,
- isPending,
- isError,
- } = useMutation({
- mutationKey: ['v1/federation/addServerByUser', serverName],
- mutationFn: () => addMatrixServer({ serverName }),
-
- onSuccess: async () => {
- await queryClient.invalidateQueries({
- queryKey: ['federation/listServersByUsers'],
- });
- setModal();
- },
-
- onError: (error) => {
- const errorKey = getErrorKey(error);
- if (!errorKey) {
- dispatchToastMessage({ type: 'error', message: error });
- return;
- }
- setErrorKey(errorKey);
- },
- });
-
- const { data, isPending: isLoadingServerList } = useMatrixServerList();
-
- return (
-
-
- {t('Manage_servers')}
-
-
-
-
- {t('Server_name')}
-
- ) => {
- setServerName(e.currentTarget.value);
- if (errorKey) {
- setErrorKey(undefined);
- }
- }}
- marginInlineEnd={4}
- />
-
-
- {isError && errorKey && {t(errorKey)}}
- {t('Federation_Example_matrix_server')}
-
-
- {!isLoadingServerList && data?.servers && }
-
-
-
-
-
-
-
- );
-};
-
-export default MatrixFederationAddServerModal;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx
deleted file mode 100644
index 3b23aac42973d..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationRemoveServerList.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { css } from '@rocket.chat/css-in-js';
-import { Box, Option, Icon } from '@rocket.chat/fuselage';
-import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-
-export type MatrixFederationRemoveServerListProps = {
- servers: Array<{ name: string; default: boolean; local: boolean }>;
-};
-
-const style = css`
- i {
- visibility: hidden;
- }
- li {
- cursor: default;
- }
- li:hover {
- i {
- cursor: pointer;
- visibility: visible;
- }
- }
-`;
-
-const MatrixFederationRemoveServerList = ({ servers }: MatrixFederationRemoveServerListProps) => {
- const removeMatrixServer = useEndpoint('POST', '/v1/federation/removeServerByUser');
-
- const queryClient = useQueryClient();
-
- const { mutate: removeServer, isPending: isRemovingServer } = useMutation({
- mutationKey: ['federation/removeServerByUser'],
- mutationFn: (serverName: string) => removeMatrixServer({ serverName }),
-
- onSuccess: () =>
- queryClient.invalidateQueries({
- queryKey: ['federation/listServersByUsers'],
- }),
- });
-
- const t = useTranslation();
-
- return (
-
-
- {t('Servers')}
-
- {servers.map(({ name, default: isDefault }) => (
-
- ))}
-
- );
-};
-
-export default MatrixFederationRemoveServerList;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx
deleted file mode 100644
index d1b72a3e07f5a..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearch.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Modal, ModalClose, ModalContent, ModalFooter, ModalHeader, ModalTitle, Skeleton } from '@rocket.chat/fuselage';
-import { useTranslation } from 'react-i18next';
-
-import MatrixFederationSearchModalContent from './MatrixFederationSearchModalContent';
-import { useMatrixServerList } from './useMatrixServerList';
-
-export type MatrixFederationSearchProps = {
- onClose: () => void;
- defaultSelectedServer?: string;
-};
-
-const MatrixFederationSearch = ({ onClose, defaultSelectedServer }: MatrixFederationSearchProps) => {
- const { t } = useTranslation();
- const { data, isLoading } = useMatrixServerList();
-
- return (
-
-
- {t('Federation_Federated_room_search')}
-
-
-
- {isLoading && (
- <>
-
-
-
-
- >
- )}
- {!isLoading && data?.servers && (
-
- )}
-
-
-
- );
-};
-
-export default MatrixFederationSearch;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx
deleted file mode 100644
index 98c9563e2301d..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import type { SelectOption } from '@rocket.chat/fuselage';
-import { Box, Select, TextInput } from '@rocket.chat/fuselage';
-import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
-import { useSetModal } from '@rocket.chat/ui-contexts';
-import type { ChangeEvent } from 'react';
-import { useCallback, useState, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import FederatedRoomList from './FederatedRoomList';
-import FederatedRoomListErrorBoundary from './FederatedRoomListErrorBoundary';
-import MatrixFederationManageServersModal from './MatrixFederationManageServerModal';
-import MatrixFederationSearch from './MatrixFederationSearch';
-
-export type MatrixFederationSearchModalContentProps = {
- servers: Array<{
- name: string;
- default: boolean;
- local: boolean;
- }>;
- defaultSelectedServer?: string;
-};
-
-const MatrixFederationSearchModalContent = ({ defaultSelectedServer, servers }: MatrixFederationSearchModalContentProps) => {
- const [serverName, setServerName] = useState(() => {
- const defaultServer = servers.find((server) => server.name === defaultSelectedServer);
- return defaultServer?.name ?? servers[0].name;
- });
-
- const [roomName, setRoomName] = useState('');
-
- const setModal = useSetModal();
-
- const debouncedRoomName = useDebouncedValue(roomName, 400);
-
- const { t } = useTranslation();
-
- const serverOptions = useMemo>(() => servers.map((server): SelectOption => [server.name, server.name]), [servers]);
-
- const manageServers = useCallback(() => {
- setModal(
- setModal( setModal(null)} />)} />,
- );
- }, [setModal]);
-
- return (
- <>
-
-
-
- ) => setRoomName(e.currentTarget.value)}
- />
-
-
- {t('Manage_server_list')}
-
-
-
-
- >
- );
-};
-
-export default MatrixFederationSearchModalContent;
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/index.ts b/apps/meteor/client/sidebar/header/MatrixFederationSearch/index.ts
deleted file mode 100644
index 8447180665a03..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './MatrixFederationSearch';
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/useInfiniteFederationSearchPublicRooms.ts b/apps/meteor/client/sidebar/header/MatrixFederationSearch/useInfiniteFederationSearchPublicRooms.ts
deleted file mode 100644
index 66336fe40058a..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/useInfiniteFederationSearchPublicRooms.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useEndpoint } from '@rocket.chat/ui-contexts';
-import { useInfiniteQuery } from '@tanstack/react-query';
-
-const tenMinutes = 10 * 60 * 1000;
-
-export const useInfiniteFederationSearchPublicRooms = (serverName: string, roomName?: string, count?: number) => {
- const fetchRoomList = useEndpoint('GET', '/v1/federation/searchPublicRooms');
- return useInfiniteQuery({
- queryKey: ['federation/searchPublicRooms', serverName, roomName, count],
- queryFn: async ({ pageParam }) => fetchRoomList({ serverName, roomName, count, pageToken: pageParam }),
- getNextPageParam: (lastPage) => lastPage.nextPageToken,
- initialPageParam: '',
- throwOnError: true,
- staleTime: tenMinutes,
- gcTime: tenMinutes,
- });
-};
diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/useMatrixServerList.ts b/apps/meteor/client/sidebar/header/MatrixFederationSearch/useMatrixServerList.ts
deleted file mode 100644
index 7ffa3d937bba4..0000000000000
--- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/useMatrixServerList.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useEndpoint } from '@rocket.chat/ui-contexts';
-import { useQuery } from '@tanstack/react-query';
-
-export const useMatrixServerList = () => {
- const fetchServerList = useEndpoint('GET', '/v1/federation/listServersByUser');
- return useQuery({
- queryKey: ['federation/listServersByUsers'],
- queryFn: async () => fetchServerList(),
- throwOnError: true,
- staleTime: Infinity,
- });
-};
diff --git a/apps/meteor/client/stories/contexts/QueryClientProviderMock.tsx b/apps/meteor/client/stories/contexts/QueryClientProviderMock.tsx
deleted file mode 100644
index 6b532ad0e387c..0000000000000
--- a/apps/meteor/client/stories/contexts/QueryClientProviderMock.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { DefaultError, Query } from '@tanstack/react-query';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import type { ReactNode } from 'react';
-
-import { dispatchToastMessage } from '../../lib/toast';
-
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- gcTime: Infinity,
- refetchOnWindowFocus: false,
- },
- },
-});
-
-export type QueryClientProviderMockProps = {
- children?: ReactNode;
-};
-
-const QueryClientProviderMock = ({ children }: QueryClientProviderMockProps) => {
- const queryCacheInstance = queryClient.getQueryCache();
-
- queryCacheInstance.config.onError = (error: DefaultError, query: Query) => {
- const { errorToastMessage, apiErrorToastMessage } = query?.meta as { errorToastMessage?: string; apiErrorToastMessage?: boolean };
- if (apiErrorToastMessage) {
- dispatchToastMessage({ type: 'error', message: error });
- } else if (errorToastMessage) {
- dispatchToastMessage({ type: 'error', message: errorToastMessage });
- }
- };
-
- queryCacheInstance.config.onSuccess = (_, query: Query) => {
- const { successToastMessage } = query?.meta as { successToastMessage?: string };
- if (successToastMessage) {
- dispatchToastMessage({ type: 'success', message: successToastMessage });
- }
- };
-
- return {children};
-};
-
-export default QueryClientProviderMock;
diff --git a/apps/meteor/client/views/admin/viewLogs/ansispan.ts b/apps/meteor/client/views/admin/viewLogs/ansispan.ts
deleted file mode 100644
index 8aa5bccb11954..0000000000000
--- a/apps/meteor/client/views/admin/viewLogs/ansispan.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/* eslint-disable no-control-regex */
-const foregroundColors = {
- 30: 'var(--rcx-color-font-secondary-info, #6C727A)',
- 31: 'var(--rcx-color-font-danger, #D40C26)',
- 32: 'var(--rcx-color-status-font-on-success, #148660)',
- 33: 'var(--rcx-color-status-font-on-warning, #B88D00)',
- 34: 'var(--rcx-color-status-font-on-info, #095AD2)',
- 35: 'var(--rcx-color-status-font-on-service-2, #7F1B9F)',
- 36: 'teal',
- 37: 'var(--rcx-color-font-white, #FFFFFF)',
-};
-
-export const ansispan = (str: string): string => {
- str = str
- .replace(/\s/g, ' ')
- .replace(/(\\n|\n)/g, '
')
- .replace(/>/g, '>')
- .replace(/$1')
- .replace(/\x1b\[1m/g, '')
- .replace(/\x1b\[22m/g, '')
- .replace(/\x1b\[3m/g, '')
- .replace(/\x1b\[23m/g, '')
- .replace(/\x1b\[m/g, '')
- .replace(/\x1b\[0m/g, '')
- .replace(/\x1b\[39m/g, '');
- return Object.entries(foregroundColors).reduce((str, [ansiCode, color]) => {
- const span = ``;
- return str.replace(new RegExp(`\\033\\[${ansiCode}m`, 'g'), span).replace(new RegExp(`\\033\\[0;${ansiCode}m`, 'g'), span);
- }, str);
-};
diff --git a/apps/meteor/client/views/home/HomepageGridItem.tsx b/apps/meteor/client/views/home/HomepageGridItem.tsx
deleted file mode 100644
index 310bd73775a5b..0000000000000
--- a/apps/meteor/client/views/home/HomepageGridItem.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { GridItem } from '@rocket.chat/fuselage';
-import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
-import type { ReactNode } from 'react';
-
-export type HomepageGridItemProps = { children: ReactNode };
-
-const HomepageGridItem = ({ children }: HomepageGridItemProps) => {
- const breakpoints = useBreakpoints();
-
- const isMedium = !breakpoints.includes('lg');
-
- return (
-
- {children}
-
- );
-};
-
-export default HomepageGridItem;
diff --git a/apps/meteor/client/views/navigation/sidebar/hooks/useCollapsedGroups.ts b/apps/meteor/client/views/navigation/sidebar/hooks/useCollapsedGroups.ts
deleted file mode 100644
index b178f5c5121c2..0000000000000
--- a/apps/meteor/client/views/navigation/sidebar/hooks/useCollapsedGroups.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
-import type { KeyboardEvent } from 'react';
-import { useCallback } from 'react';
-
-export const useCollapsedGroups = () => {
- const [collapsedGroups, setCollapsedGroups] = useLocalStorage('sidebarGroups', []);
-
- const handleClick = useCallback(
- (group: string) => {
- if (collapsedGroups.includes(group)) {
- setCollapsedGroups(collapsedGroups.filter((item) => item !== group));
- } else {
- setCollapsedGroups([...collapsedGroups, group]);
- }
- },
- [collapsedGroups, setCollapsedGroups],
- );
-
- const handleKeyDown = useCallback(
- (event: KeyboardEvent, group: string) => {
- if (['Enter', 'Space'].includes(event.code)) {
- event.preventDefault();
- handleClick(group);
- }
- },
- [handleClick],
- );
-
- return { collapsedGroups, handleClick, handleKeyDown };
-};
diff --git a/apps/meteor/client/views/navigation/sidepanel/tabs/SidePanelMentions.tsx b/apps/meteor/client/views/navigation/sidepanel/tabs/SidePanelMentions.tsx
deleted file mode 100644
index cce5c82aa6008..0000000000000
--- a/apps/meteor/client/views/navigation/sidepanel/tabs/SidePanelMentions.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useTranslation } from 'react-i18next';
-
-import { sidePanelFiltersConfig, useSidePanelRoomsListTab, useUnreadOnlyToggle } from '../../contexts/RoomsNavigationContext';
-import SidePanel from '../SidePanel';
-
-const SidePanelMentions = () => {
- const { t } = useTranslation();
- const rooms = useSidePanelRoomsListTab('mentions');
- const [unreadOnly, toggleUnreadOnly] = useUnreadOnlyToggle();
-
- return (
-
- );
-};
-
-export default SidePanelMentions;
diff --git a/apps/meteor/client/views/omnichannel/directory/chats/Chat.tsx b/apps/meteor/client/views/omnichannel/directory/chats/Chat.tsx
deleted file mode 100644
index 9e3f3be943b8e..0000000000000
--- a/apps/meteor/client/views/omnichannel/directory/chats/Chat.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { IRoom } from '@rocket.chat/core-typings';
-import { Box } from '@rocket.chat/fuselage';
-
-import RoomOpener from '../../../room/RoomOpener';
-
-export type ChatProps = { rid: IRoom['_id'] };
-
-const Chat = ({ rid }: ChatProps) => {
- return (
-
-
-
- );
-};
-export default Chat;
diff --git a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts b/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts
deleted file mode 100644
index c699059d9f6d9..0000000000000
--- a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts
+++ /dev/null
@@ -1,351 +0,0 @@
-import { mockAppRoot } from '@rocket.chat/mock-providers';
-import { renderHook, waitFor } from '@testing-library/react';
-
-import { useLoadSurroundingMessages } from './useLoadSurroundingMessages';
-import { RoomHistoryManager } from '../../../../../app/ui-utils/client';
-import { RoomManager } from '../../../../lib/RoomManager';
-import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
-import { Subscriptions } from '../../../../stores';
-
-jest.mock('../../../../../app/ui-utils/client', () => ({
- RoomHistoryManager: {
- getSurroundingChannelMessages: jest.fn().mockResolvedValue(undefined),
- getSurroundingMessages: jest.fn().mockResolvedValue(undefined),
- isLoaded: jest.fn().mockReturnValue(false),
- getMore: jest.fn().mockResolvedValue(undefined),
- },
-}));
-
-jest.mock('../../../../lib/RoomManager', () => ({
- RoomManager: {
- opened: undefined as string | undefined,
- },
-}));
-
-jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
- roomCoordinator: {
- openRouteLink: jest.fn(),
- },
-}));
-
-jest.mock('../../../../providers/RouterProvider', () => ({
- router: {
- getSearchParameters: jest.fn().mockReturnValue({}),
- getRouteParameters: jest.fn().mockReturnValue({}),
- },
-}));
-
-jest.mock('../../../../stores', () => ({
- Subscriptions: {
- state: {
- find: jest.fn().mockReturnValue(undefined),
- },
- },
-}));
-
-const mockUseSearchParameter = jest.fn().mockReturnValue(undefined);
-const mockUseRouteParameter = jest.fn().mockReturnValue(undefined);
-
-jest.mock('@rocket.chat/ui-contexts', () => ({
- ...jest.requireActual('@rocket.chat/ui-contexts'),
- useSearchParameter: (..._args: unknown[]) => mockUseSearchParameter(),
- useRouteParameter: (param: string) => mockUseRouteParameter(param),
-}));
-
-const mockedRoomHistoryManager = jest.mocked(RoomHistoryManager);
-const mockedRoomCoordinator = jest.mocked(roomCoordinator);
-const mockedSubscriptions = jest.mocked(Subscriptions);
-
-afterEach(() => {
- jest.clearAllMocks();
- mockUseSearchParameter.mockReturnValue(undefined);
- mockUseRouteParameter.mockReturnValue(undefined);
- (RoomManager as { opened: string | undefined }).opened = undefined;
-});
-
-describe('useLoadSurroundingMessages', () => {
- it('should not fetch a message when msg search parameter is absent', () => {
- const endpointSpy = jest.fn();
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot().withEndpoint('GET', '/v1/chat.getMessage', endpointSpy).build(),
- });
-
- expect(endpointSpy).not.toHaveBeenCalled();
- });
-
- it('should fetch the message and jump to it when msg search parameter is present', async () => {
- mockUseSearchParameter.mockReturnValue('msg-1');
-
- const message = {
- _id: 'msg-1',
- rid: 'room-1',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Hello',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- (RoomManager as { opened: string | undefined }).opened = 'room-1';
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalled();
- });
- });
-
- it('should navigate to a different room when the message belongs to a room that is not currently opened', async () => {
- mockUseSearchParameter.mockReturnValue('msg-2');
-
- const message = {
- _id: 'msg-2',
- rid: 'room-2',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Hello',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- (RoomManager as { opened: string | undefined }).opened = 'room-1';
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .withEndpoint('GET', '/v1/rooms.info', () => ({ room: { _id: 'room-2', t: 'c', name: 'general' } }) as any)
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomCoordinator.openRouteLink).toHaveBeenCalled();
- });
-
- expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalled();
- });
-
- it('should use the subscription when navigating to a room the user is subscribed to', async () => {
- mockUseSearchParameter.mockReturnValue('msg-3');
-
- const message = {
- _id: 'msg-3',
- rid: 'room-3',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Hello',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- (RoomManager as { opened: string | undefined }).opened = 'room-1';
-
- const subscription = { rid: 'room-3', t: 'c', name: 'general' };
- mockedSubscriptions.state.find.mockReturnValue(subscription as any);
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomCoordinator.openRouteLink).toHaveBeenCalledWith('c', subscription, {}, undefined);
- });
- });
-
- it('should open a thread when the message is a thread message', async () => {
- mockUseSearchParameter.mockReturnValue('msg-4');
-
- const message = {
- _id: 'msg-4',
- rid: 'room-4',
- tmid: 'parent-msg',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Thread reply',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomCoordinator.openRouteLink).toHaveBeenCalled();
- });
-
- const callArgs = mockedRoomCoordinator.openRouteLink.mock.calls[0];
- expect(callArgs[3]).toEqual(
- expect.objectContaining({
- routeParamsOverrides: { tab: 'thread', context: 'parent-msg' },
- }),
- );
- });
-
- it('should skip navigation when already viewing the thread', async () => {
- mockUseSearchParameter.mockReturnValue('msg-5');
- mockUseRouteParameter.mockImplementation((param) => {
- if (param === 'tab') return 'thread';
- if (param === 'context') return 'parent-msg';
- });
-
- const message = {
- _id: 'msg-5',
- rid: 'room-5',
- tmid: 'parent-msg',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Thread reply',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .withEndpoint('GET', '/v1/rooms.info', () => ({ room: { _id: 'room-5', t: 'c', name: 'general' } }) as any)
- .build(),
- });
-
- expect(mockedRoomCoordinator.openRouteLink).not.toHaveBeenCalled();
- expect(mockedRoomHistoryManager.getSurroundingChannelMessages).not.toHaveBeenCalled();
- });
-
- it('should load surrounding messages for a message with tcount (thread root)', async () => {
- mockUseSearchParameter.mockReturnValue('msg-6');
-
- const message = {
- _id: 'msg-6',
- rid: 'room-6',
- tcount: 5,
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Thread root',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- tlm: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomHistoryManager.getSurroundingChannelMessages).toHaveBeenCalled();
- });
- });
-
- it('should call getMore when room history is not loaded for a thread reply', async () => {
- mockUseSearchParameter.mockReturnValue('msg-7');
-
- const message = {
- _id: 'msg-7',
- rid: 'room-7',
- tmid: 'parent-msg-7',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Thread reply',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- mockedRoomHistoryManager.isLoaded.mockReturnValue(false);
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomHistoryManager.getMore).toHaveBeenCalledWith('room-7');
- });
- });
-
- it('should not call getMore when room history is already loaded for a thread reply', async () => {
- mockUseSearchParameter.mockReturnValue('msg-8');
-
- const message = {
- _id: 'msg-8',
- rid: 'room-8',
- tmid: 'parent-msg-8',
- ts: new Date('2024-01-01T00:00:00Z').toISOString(),
- u: { _id: 'user-1', username: 'john' },
- msg: 'Thread reply',
- _updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
- };
-
- mockedRoomHistoryManager.isLoaded.mockReturnValue(true);
-
- renderHook(() => useLoadSurroundingMessages(), {
- wrapper: mockAppRoot()
- .withEndpoint(
- 'GET',
- '/v1/chat.getMessage',
- () =>
- ({
- message,
- }) as any,
- )
- .build(),
- });
-
- await waitFor(() => {
- expect(mockedRoomCoordinator.openRouteLink).toHaveBeenCalled();
- });
-
- expect(mockedRoomHistoryManager.getMore).not.toHaveBeenCalled();
- });
-});
diff --git a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts b/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts
deleted file mode 100644
index 087341a30e384..0000000000000
--- a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { isThreadMainMessage, isThreadMessage } from '@rocket.chat/core-typings';
-import type { IMessage } from '@rocket.chat/core-typings';
-import { useStableCallback } from '@rocket.chat/fuselage-hooks';
-import { useEndpoint, useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts';
-import { useQuery } from '@tanstack/react-query';
-import { useEffect } from 'react';
-
-import { RoomHistoryManager } from '../../../../../app/ui-utils/client';
-import { RoomManager } from '../../../../lib/RoomManager';
-import { messagesQueryKeys } from '../../../../lib/queryKeys';
-import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
-import { useGoToRoom } from '../../hooks/useGoToRoom';
-
-export const useLoadSurroundingMessages = () => {
- const msgId = useSearchParameter('msg');
-
- const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
-
- const { data: message } = useQuery({
- queryKey: msgId ? messagesQueryKeys.message(msgId) : [],
- queryFn: async () => {
- if (!msgId) return null;
- const { message } = await getMessage({ msgId });
- return mapMessageFromApi(message);
- },
- enabled: !!msgId,
- });
-
- const tab = useRouteParameter('tab');
- const context = useRouteParameter('context');
- const goToRoom = useGoToRoom();
-
- const handleThreadMessage = useStableCallback(async (message: IMessage) => {
- await goToRoom(message.rid, {
- routeParamsOverrides: { tab: 'thread', context: message.tmid || message._id },
- replace: RoomManager.opened === message.rid,
- });
-
- if (message.tcount) {
- await RoomHistoryManager.getSurroundingChannelMessages(message);
- } else if (!RoomHistoryManager.isLoaded(message.rid)) {
- await RoomHistoryManager.getMore(message.rid);
- }
- });
-
- const handleRegularMessage = useStableCallback(async (message: IMessage) => {
- if (RoomManager.opened === message.rid) {
- await RoomHistoryManager.getSurroundingChannelMessages(message);
- return;
- }
-
- await goToRoom(message.rid);
-
- await RoomHistoryManager.getSurroundingChannelMessages(message);
- });
-
- useEffect(() => {
- if (!message) return;
-
- if (isThreadMessage(message) || isThreadMainMessage(message)) {
- if (tab === 'thread' && (context === message.tmid || context === message._id)) return;
-
- handleThreadMessage(message);
- return;
- }
-
- handleRegularMessage(message);
- }, [msgId, getMessage, message, tab, context, handleRegularMessage, handleThreadMessage]);
-};
diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/types.ts b/apps/meteor/client/views/room/contextualBar/RoomMembers/types.ts
deleted file mode 100644
index d4dfa2906c7b3..0000000000000
--- a/apps/meteor/client/views/room/contextualBar/RoomMembers/types.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { IUser, IRole, SubscriptionStatus, UserStatus, Serialized } from '@rocket.chat/core-typings';
-
-export type RoomMemberUser = Pick, 'username' | '_id' | 'name' | 'freeSwitchExtension' | 'federated' | 'createdAt'> & {
- roles?: IRole['_id'][];
- status?: UserStatus | SubscriptionStatus;
-};
diff --git a/apps/meteor/client/views/root/LoginRoute.tsx b/apps/meteor/client/views/root/LoginRoute.tsx
deleted file mode 100644
index 443e51d5cff06..0000000000000
--- a/apps/meteor/client/views/root/LoginRoute.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useRouter } from '@rocket.chat/ui-contexts';
-import { useEffect } from 'react';
-
-const LoginRoute = () => {
- const router = useRouter();
-
- useEffect(() => {
- router.navigate('/home');
- }, [router]);
-
- return null;
-};
-
-export default LoginRoute;
diff --git a/apps/meteor/package.json b/apps/meteor/package.json
index 86fa4efff2e4a..4d36af88ea3c2 100644
--- a/apps/meteor/package.json
+++ b/apps/meteor/package.json
@@ -127,7 +127,7 @@
"@rocket.chat/logo": "^0.33.2",
"@rocket.chat/media-calls": "workspace:^",
"@rocket.chat/media-signaling": "workspace:^",
- "@rocket.chat/memo": "~0.31.25",
+ "@rocket.chat/memo": "workspace:~",
"@rocket.chat/message-parser": "workspace:^",
"@rocket.chat/message-types": "workspace:~",
"@rocket.chat/model-typings": "workspace:^",
diff --git a/apps/meteor/server/configuration/ldap.ts b/apps/meteor/server/configuration/ldap.ts
index 3e15a5a3ceda5..d6f47e2394335 100644
--- a/apps/meteor/server/configuration/ldap.ts
+++ b/apps/meteor/server/configuration/ldap.ts
@@ -2,6 +2,7 @@ import { LDAP } from '@rocket.chat/core-services';
import { Accounts } from 'meteor/accounts-base';
import { callbacks } from '../lib/callbacks';
+import { warnUnlicensedAuthService } from '../lib/premiumAuthDeprecation';
import type { ICachedSettings } from '../settings/CachedSettings';
export async function configureLDAP(settings: ICachedSettings): Promise {
@@ -11,6 +12,8 @@ export async function configureLDAP(settings: ICachedSettings): Promise {
return undefined;
}
+ warnUnlicensedAuthService('LDAP', 'ldap-enterprise');
+
return LDAP.loginRequest(loginRequest.username, loginRequest.ldapPass);
});
diff --git a/apps/meteor/server/lib/premiumAuthDeprecation.ts b/apps/meteor/server/lib/premiumAuthDeprecation.ts
new file mode 100644
index 0000000000000..265fd8785b3e2
--- /dev/null
+++ b/apps/meteor/server/lib/premiumAuthDeprecation.ts
@@ -0,0 +1,21 @@
+import type { LicenseModule } from '@rocket.chat/core-typings';
+import { License } from '@rocket.chat/license';
+
+import { SystemLogger } from './logger/system';
+
+const warned = new Set();
+
+// Checked on login instead of on startup so the license is already loaded and
+// workspaces that do have the module never see the warning.
+export function warnUnlicensedAuthService(service: 'LDAP' | 'SAML', module: LicenseModule): void {
+ if (warned.has(module) || License.hasModule(module)) {
+ return;
+ }
+
+ warned.add(module);
+
+ SystemLogger.warn({
+ msg: `${service} authentication will require a Premium plan starting on version 9.0.0. This workspace has no license covering it, so ${service} logins will stop working after the upgrade.`,
+ module,
+ });
+}
diff --git a/apps/meteor/server/lib/saml/lib/settings.ts b/apps/meteor/server/lib/saml/lib/settings.ts
index 5819add8bc295..47dec515e7645 100644
--- a/apps/meteor/server/lib/saml/lib/settings.ts
+++ b/apps/meteor/server/lib/saml/lib/settings.ts
@@ -180,6 +180,7 @@ export const addSettings = async function (name: string): Promise {
type: 'boolean',
i18nLabel: 'Accounts_OAuth_Custom_Enable',
public: true,
+ alert: 'Premium_required_from_9_0_0_alert',
});
await this.add(`SAML_Custom_${name}_provider`, 'provider-name', {
type: 'string',
diff --git a/apps/meteor/server/lib/saml/loginHandler.ts b/apps/meteor/server/lib/saml/loginHandler.ts
index 0a7c377fdf047..63b9593308cea 100644
--- a/apps/meteor/server/lib/saml/loginHandler.ts
+++ b/apps/meteor/server/lib/saml/loginHandler.ts
@@ -6,6 +6,7 @@ import { i18n } from '../i18n';
import { SAML } from './lib/SAML';
import { SAMLUtils } from './lib/Utils';
import { SystemLogger } from '../logger/system';
+import { warnUnlicensedAuthService } from '../premiumAuthDeprecation';
const makeError = (message: string): Record => ({
type: 'saml',
@@ -22,6 +23,8 @@ Accounts.registerLoginHandler('saml', async (loginRequest) => {
return undefined;
}
+ warnUnlicensedAuthService('SAML', 'saml-enterprise');
+
const loginResult = await SAML.retrieveCredential(loginRequest.credentialToken);
await CredentialTokens.removeById(loginRequest.credentialToken);
diff --git a/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts b/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts
index ac2086b49b506..2a1b68b7277fb 100644
--- a/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts
+++ b/apps/meteor/server/meteor-methods/import/downloadPublicImportFile.ts
@@ -1,10 +1,11 @@
import fs from 'node:fs';
-import http from 'node:http';
-import https from 'node:https';
+import type { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
import { Import } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
+import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';
import { ProgressStep } from '../../../app/importer/lib/ImporterProgressStep';
@@ -12,12 +13,22 @@ import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { Importers } from '../../lib/import';
import { RocketChatImportFileInstance } from '../../lib/import/startup/store';
+import { SystemLogger } from '../../lib/logger/system';
+import { settings } from '../../settings';
-function downloadHttpFile(fileUrl: string, writeStream: fs.WriteStream): void {
- const protocol = fileUrl.startsWith('https') ? https : http;
- protocol.get(fileUrl, (response) => {
- response.pipe(writeStream);
+async function getHttpFileStream(fileUrl: string): Promise {
+ const response = await fetch(fileUrl, {
+ ignoreSsrfValidation: false,
+ allowList: settings.get('SSRF_Allowlist'),
});
+
+ const body = response.body as Readable;
+ if (!response.ok) {
+ body.resume();
+ throw new Error(`Unexpected response status ${response.status}`);
+ }
+
+ return body;
}
function copyLocalFile(filePath: fs.PathLike, writeStream: fs.WriteStream): void {
@@ -53,27 +64,46 @@ export const executeDownloadPublicImportFile = async (userId: IUser['_id'], file
await instance.updateProgress(ProgressStep.DOWNLOADING_FILE);
const writeStream = RocketChatImportFileInstance.createWriteStream(newFileName);
+ let errorProgressUpdate: Promise | undefined;
+ const markImportAsFailed = (): Promise => {
+ errorProgressUpdate ??= instance.updateProgress(ProgressStep.ERROR).catch((error) => {
+ SystemLogger.error({ msg: 'Failed to update import progress to ERROR', err: error });
+ });
+ return errorProgressUpdate;
+ };
writeStream.on('error', () => {
- void instance.updateProgress(ProgressStep.ERROR);
+ void markImportAsFailed();
});
- writeStream.on('end', () => {
+ let readStream: Readable | undefined;
+ if (isUrl) {
+ try {
+ readStream = await getHttpFileStream(fileUrl);
+ } catch (error) {
+ writeStream.destroy();
+ await markImportAsFailed();
+ throw error;
+ }
+ }
+
+ writeStream.on('finish', () => {
void instance.updateProgress(ProgressStep.FILE_LOADED);
});
- if (isUrl) {
- downloadHttpFile(fileUrl, writeStream);
- } else {
- // If the url is actually a folder path on the current machine, skip moving it to the file store
- if (fs.statSync(fileUrl).isDirectory()) {
- await instance.updateRecord({ file: fileUrl });
- await instance.updateProgress(ProgressStep.FILE_LOADED);
- return;
- }
+ if (readStream) {
+ void pipeline(readStream, writeStream).catch(() => markImportAsFailed());
+ return;
+ }
- copyLocalFile(fileUrl, writeStream);
+ // If the url is actually a folder path on the current machine, skip moving it to the file store
+ if (fs.statSync(fileUrl).isDirectory()) {
+ await instance.updateRecord({ file: fileUrl });
+ await instance.updateProgress(ProgressStep.FILE_LOADED);
+ return;
}
+
+ copyLocalFile(fileUrl, writeStream);
};
declare module '@rocket.chat/ddp-client' {
diff --git a/apps/meteor/server/settings/ldap.ts b/apps/meteor/server/settings/ldap.ts
index 4df631fb2b5f1..812ef1e18465d 100644
--- a/apps/meteor/server/settings/ldap.ts
+++ b/apps/meteor/server/settings/ldap.ts
@@ -7,7 +7,7 @@ export const createLdapSettings = () =>
const ldapOnly = { _id: 'LDAP_Server_Type', value: '' };
await this.with({ tab: 'LDAP_Connection' }, async function () {
- await this.add('LDAP_Enable', false, { type: 'boolean', public: true });
+ await this.add('LDAP_Enable', false, { type: 'boolean', public: true, alert: 'Premium_required_from_9_0_0_alert' });
await this.add('LDAP_Server_Type', 'ad', {
type: 'select',
diff --git a/apps/meteor/tests/end-to-end/api/import.spec.ts b/apps/meteor/tests/end-to-end/api/import.spec.ts
index 90019ffdba524..055a86297f520 100644
--- a/apps/meteor/tests/end-to-end/api/import.spec.ts
+++ b/apps/meteor/tests/end-to-end/api/import.spec.ts
@@ -2,9 +2,15 @@ import { expect } from 'chai';
import { after, before, describe, it } from 'mocha';
import type { Response } from 'supertest';
+import { sleep } from '../../../lib/utils/sleep';
import { getCredentials, api, request, credentials } from '../../data/api-data';
+import { mockServerHealthy, mockServerReset, mockServerSet } from '../../data/mock-server.helper';
+import { getSettingValueById, updateSetting } from '../../data/permissions.helper';
import { password } from '../../data/user';
import { createUser, login, deleteUser } from '../../data/users.helper';
+import { withTimeout } from '../../data/utils';
+
+const IMPORT_MOCK_SERVER_URL = process.env.IMPORT_MOCK_SERVER_URL ?? 'http://mock-server.dev:8080';
describe('Imports', () => {
before((done) => getCredentials(done));
@@ -112,6 +118,70 @@ describe('Imports', () => {
});
});
+ describe('[/downloadPublicImportFile]', () => {
+ let previousSsrfAllowlist: Awaited>;
+
+ const waitForImportStep = (expectedStep: string): Promise =>
+ withTimeout(async (signal) => {
+ let step = '';
+ while (!signal.aborted) {
+ const res = await request.get(api('getImportProgress')).set(credentials).expect(200);
+ step = res.body.step;
+ if (step === expectedStep || step === 'importer_import_failed') {
+ return step;
+ }
+ await sleep(100);
+ }
+ return step;
+ }, 10_000);
+
+ before(async () => {
+ expect(await mockServerHealthy(), 'mock-server is not reachable — ensure it is running').to.be.true;
+ previousSsrfAllowlist = await getSettingValueById('SSRF_Allowlist');
+ await Promise.all([mockServerReset(), updateSetting('SSRF_Allowlist', '')]);
+ });
+
+ after(async () => {
+ await Promise.all([
+ mockServerReset(),
+ previousSsrfAllowlist !== undefined ? updateSetting('SSRF_Allowlist', previousSsrfAllowlist) : Promise.resolve(),
+ ]);
+ });
+
+ it('should reject a private target that is not on the SSRF allowlist and mark the operation as failed', async () => {
+ await request
+ .post(api('downloadPublicImportFile'))
+ .set(credentials)
+ .send({ fileUrl: 'http://127.0.0.1:3000/api/v1/info', importerKey: 'slack-users' })
+ .expect(400)
+ .expect((res: Response) => {
+ expect(res.body.success).to.be.false;
+ expect(res.body.error).to.equal('error-ssrf-validation-failed');
+ });
+
+ const progress = await request.get(api('getImportProgress')).set(credentials).expect(200);
+ expect(progress.body.step).to.equal('importer_import_failed');
+ });
+
+ it('should download a file from an allowlisted private target', async () => {
+ await Promise.all([
+ mockServerSet('GET', '/import-test.zip', { marker: 'downloaded-by-server-fetch' }),
+ updateSetting('SSRF_Allowlist', new URL(IMPORT_MOCK_SERVER_URL).host),
+ ]);
+
+ await request
+ .post(api('downloadPublicImportFile'))
+ .set(credentials)
+ .send({ fileUrl: `${IMPORT_MOCK_SERVER_URL}/import-test.zip`, importerKey: 'csv' })
+ .expect(200)
+ .expect((res: Response) => {
+ expect(res.body.success).to.be.true;
+ });
+
+ expect(await waitForImportStep('importer_file_loaded')).to.equal('importer_file_loaded');
+ });
+ });
+
describe('[/uploadImportFile]', () => {
let testUser: any = {};
before(async () => {
diff --git a/apps/meteor/tests/unit/server/meteor-methods/import/downloadPublicImportFile.spec.ts b/apps/meteor/tests/unit/server/meteor-methods/import/downloadPublicImportFile.spec.ts
new file mode 100644
index 0000000000000..8816b5594d2ad
--- /dev/null
+++ b/apps/meteor/tests/unit/server/meteor-methods/import/downloadPublicImportFile.spec.ts
@@ -0,0 +1,144 @@
+import { PassThrough } from 'node:stream';
+
+import { expect } from 'chai';
+import { beforeEach, describe, it } from 'mocha';
+import proxyquire from 'proxyquire';
+import sinon from 'sinon';
+
+const progressStep = {
+ DOWNLOADING_FILE: 'importer_downloading_file',
+ FILE_LOADED: 'importer_file_loaded',
+ ERROR: 'importer_import_failed',
+};
+
+const stubs = {
+ newOperation: sinon.stub(),
+ serverFetch: sinon.stub(),
+ importersGet: sinon.stub(),
+ createWriteStream: sinon.stub(),
+ startFileUpload: sinon.stub(),
+ updateProgress: sinon.stub(),
+ updateRecord: sinon.stub(),
+ systemLoggerError: sinon.stub(),
+};
+
+class MockImporter {
+ startFileUpload = stubs.startFileUpload;
+
+ updateProgress = stubs.updateProgress;
+
+ updateRecord = stubs.updateRecord;
+}
+
+const { executeDownloadPublicImportFile } = proxyquire
+ .noCallThru()
+ .load('../../../../../server/meteor-methods/import/downloadPublicImportFile.ts', {
+ '@rocket.chat/core-services': { Import: { newOperation: stubs.newOperation } },
+ '@rocket.chat/server-fetch': { serverFetch: stubs.serverFetch },
+ 'meteor/meteor': { Meteor: { methods: sinon.stub() } },
+ '../../lib/authorization/hasPermission': { hasPermissionAsync: sinon.stub() },
+ '../../lib/deprecationWarningLogger': { methodDeprecationLogger: { method: sinon.stub() } },
+ '../../lib/import': { Importers: { get: stubs.importersGet } },
+ '../../lib/import/startup/store': { RocketChatImportFileInstance: { createWriteStream: stubs.createWriteStream } },
+ '../../lib/logger/system': { SystemLogger: { error: stubs.systemLoggerError } },
+ '../../settings': { settings: { get: sinon.stub().returns('') } },
+ '../../../app/importer/lib/ImporterProgressStep': { ProgressStep: progressStep },
+ });
+
+describe('executeDownloadPublicImportFile', () => {
+ let writeStream: PassThrough;
+
+ beforeEach(() => {
+ Object.values(stubs).forEach((stub) => stub.reset());
+
+ writeStream = new PassThrough();
+ stubs.newOperation.resolves({ _id: 'operation-id' });
+ stubs.importersGet.returns({ key: 'csv', name: 'CSV', importer: MockImporter });
+ stubs.createWriteStream.returns(writeStream);
+ stubs.startFileUpload.resolves();
+ stubs.updateProgress.resolves();
+ stubs.updateRecord.resolves();
+ });
+
+ it('rejects an unsuccessful HTTP response and drains its body', async () => {
+ const responseBody = new PassThrough();
+ const resume = sinon.spy(responseBody, 'resume');
+ stubs.serverFetch.resolves({ ok: false, status: 404, body: responseBody });
+
+ await expect(executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv')).to.be.rejectedWith(
+ 'Unexpected response status 404',
+ );
+
+ expect(resume.calledOnce).to.be.true;
+ expect(writeStream.destroyed).to.be.true;
+ expect(stubs.updateProgress.calledWith(progressStep.ERROR)).to.be.true;
+ });
+
+ it('marks the import as failed and destroys the writable when the HTTP stream fails', async () => {
+ const responseBody = new PassThrough();
+ stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody });
+
+ await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv');
+ const writeStreamClosed = new Promise((resolve) => writeStream.once('close', resolve));
+ responseBody.destroy(new Error('stream failed'));
+ await writeStreamClosed;
+
+ expect(writeStream.destroyed).to.be.true;
+ expect(stubs.updateProgress.withArgs(progressStep.ERROR).calledOnce).to.be.true;
+ expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.false;
+ });
+
+ it('marks the import as failed and destroys the HTTP stream when the writable fails', async () => {
+ const responseBody = new PassThrough();
+ stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody });
+
+ await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv');
+ const responseBodyClosed = new Promise((resolve) => responseBody.once('close', resolve));
+ writeStream.destroy(new Error('stream failed'));
+ await responseBodyClosed;
+
+ expect(responseBody.destroyed).to.be.true;
+ expect(stubs.updateProgress.withArgs(progressStep.ERROR).calledOnce).to.be.true;
+ expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.false;
+ });
+
+ it('handles a rejected error progress update when the writable fails', async () => {
+ const responseBody = new PassThrough();
+ const unhandledRejection = sinon.spy();
+ const progressUpdateError = new Error('progress update failed');
+ stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody });
+ stubs.updateProgress.withArgs(progressStep.ERROR).rejects(progressUpdateError);
+ process.on('unhandledRejection', unhandledRejection);
+
+ try {
+ await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv');
+ const responseBodyClosed = new Promise((resolve) => responseBody.once('close', resolve));
+ writeStream.destroy(new Error('stream failed'));
+ await responseBodyClosed;
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(unhandledRejection.called).to.be.false;
+ expect(stubs.updateProgress.withArgs(progressStep.ERROR).calledOnce).to.be.true;
+ sinon.assert.calledOnceWithExactly(stubs.systemLoggerError, {
+ msg: 'Failed to update import progress to ERROR',
+ err: progressUpdateError,
+ });
+ } finally {
+ process.off('unhandledRejection', unhandledRejection);
+ }
+ });
+
+ it('marks the file as loaded when the writable finishes', async () => {
+ const responseBody = new PassThrough();
+ stubs.serverFetch.resolves({ ok: true, status: 200, body: responseBody });
+
+ await executeDownloadPublicImportFile('user-id', 'https://example.com/import.zip', 'csv');
+ expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.false;
+
+ const finished = new Promise((resolve) => writeStream.once('finish', resolve));
+ responseBody.end('file contents');
+ await finished;
+
+ expect(stubs.updateProgress.calledWith(progressStep.FILE_LOADED)).to.be.true;
+ });
+});
diff --git a/ee/packages/media-calls/jest.config.ts b/ee/packages/media-calls/jest.config.ts
new file mode 100644
index 0000000000000..c18c8ae02465c
--- /dev/null
+++ b/ee/packages/media-calls/jest.config.ts
@@ -0,0 +1,6 @@
+import server from '@rocket.chat/jest-presets/server';
+import type { Config } from 'jest';
+
+export default {
+ preset: server.preset,
+} satisfies Config;
diff --git a/ee/packages/media-calls/package.json b/ee/packages/media-calls/package.json
index 084ba0ec69994..fe99e1020d21a 100644
--- a/ee/packages/media-calls/package.json
+++ b/ee/packages/media-calls/package.json
@@ -13,6 +13,8 @@
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
+ "test": "jest",
+ "testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -24,9 +26,11 @@
"drachtio-srf": "patch:drachtio-srf@npm%3A5.0.12#~/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch"
},
"devDependencies": {
+ "@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"eslint": "~9.39.5",
+ "jest": "~30.2.0",
"typescript": "~5.9.3"
}
}
diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts
index 07dbbb0677969..0520fe390aa69 100644
--- a/ee/packages/media-calls/src/definition/common.ts
+++ b/ee/packages/media-calls/src/definition/common.ts
@@ -16,6 +16,7 @@ export type InternalCallParams = {
parentCallId?: string;
requestedBy?: MediaCallSignedContact;
features: CallFeature[];
+ divertedBy?: MediaCallContact;
};
export type MediaCallHeader = AtLeast;
diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts
index 921b90b01f9f7..73858a2598f7c 100644
--- a/ee/packages/media-calls/src/server/CallDirector.ts
+++ b/ee/packages/media-calls/src/server/CallDirector.ts
@@ -183,7 +183,8 @@ class MediaCallDirector {
}
public async createCall(params: CreateCallParams): Promise {
- const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, parentCallId, requestedBy, features } = params;
+ const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, parentCallId, requestedBy, features, divertedBy } =
+ params;
// The caller must always have a contract to create the call
if (!caller.contractId) {
@@ -232,6 +233,7 @@ class MediaCallDirector {
...(requestedCallId && { callerRequestedId: requestedCallId }),
...(parentCallId && { parentCallId }),
+ ...(divertedBy && { divertedBy }),
features: allowedFeatures,
};
diff --git a/ee/packages/media-calls/src/server/signals/getNewCallTransferredBy.ts b/ee/packages/media-calls/src/server/signals/getNewCallTransferredBy.ts
index d994c2ba916fd..77ca1b6654068 100644
--- a/ee/packages/media-calls/src/server/signals/getNewCallTransferredBy.ts
+++ b/ee/packages/media-calls/src/server/signals/getNewCallTransferredBy.ts
@@ -2,7 +2,11 @@ import type { IMediaCall } from '@rocket.chat/core-typings';
import type { CallContact } from '@rocket.chat/media-signaling';
export function getNewCallTransferredBy(call: IMediaCall): CallContact | null {
- const { createdBy, parentCallId, caller, callee } = call;
+ const { createdBy, parentCallId, caller, callee, divertedBy } = call;
+
+ if (divertedBy) {
+ return divertedBy;
+ }
if (!createdBy || !parentCallId) {
return null;
diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
index a9b15ac65ec3a..8bff9a4b688ee 100644
--- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
+++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
@@ -12,6 +12,7 @@ import { mediaCallDirector } from '../../server/CallDirector';
import { getMediaCallServer } from '../../server/injection';
import type { SipServerSession } from '../Session';
import { SipError, SipErrorCodes } from '../errorCodes';
+import { parseDiversionHeader } from '../utils/parseDiversionHeader';
type IncomingSipCallNegotiation = {
id: string;
@@ -76,6 +77,12 @@ export class IncomingSipCall extends BaseSipCall {
const caller = await this.getCallerContactFromInvite(session.sessionId, req);
logger.debug({ msg: 'incoming call from', callerContact: caller });
+
+ const divertedBy = await this.getDiversionContactFromInvite(req);
+ if (divertedBy) {
+ logger.debug({ msg: 'incoming call diverted by', divertedBy });
+ }
+
const webrtcOffer = { type: 'offer', sdp: req.body } as const;
const callerAgent = await mediaCallDirector.cast.getAgentForActorAndRole(caller, 'caller');
@@ -98,6 +105,7 @@ export class IncomingSipCall extends BaseSipCall {
callerAgent,
calleeAgent,
features: SIP_CALL_FEATURES,
+ ...(divertedBy && { divertedBy }),
});
const negotiationId = await mediaCallDirector.startNewNegotiation(call, 'caller', webrtcOffer);
@@ -447,6 +455,36 @@ export class IncomingSipCall extends BaseSipCall {
return null;
}
+ private static async getDiversionContactFromInvite(req: SrfRequest): Promise {
+ if (!req.has('diversion')) {
+ return null;
+ }
+
+ logger.debug({ msg: 'IncomingSipCall.getDiversionContactFromInvite' });
+
+ const parsed = parseDiversionHeader(req.get('diversion'));
+ if (!parsed) {
+ logger.warn({ msg: 'IncomingSipCall.getDiversionContactFromInvite: failed to parse Diversion header', raw: req.get('diversion') });
+ return null;
+ }
+
+ const { extension, displayName } = parsed;
+
+ const defaultContactInfo: MediaCallContactInformation = {
+ sipExtension: extension,
+ displayName: displayName || extension,
+ };
+
+ const contact = await mediaCallDirector.cast.getContactForExtensionNumber(extension, { requiredType: 'sip' }, defaultContactInfo);
+
+ return {
+ ...defaultContactInfo,
+ ...contact,
+ type: 'sip',
+ id: extension,
+ };
+ }
+
private static async getCallerContactFromInvite(sessionId: string, req: SrfRequest): Promise> {
logger.debug({ msg: 'IncomingSipCall.getCallerContactFromInvite' });
const callerBase = await this.getRocketChatCallerFromInvite(req);
diff --git a/ee/packages/media-calls/src/sip/utils/parseDiversionHeader.spec.ts b/ee/packages/media-calls/src/sip/utils/parseDiversionHeader.spec.ts
new file mode 100644
index 0000000000000..313cd602e4a64
--- /dev/null
+++ b/ee/packages/media-calls/src/sip/utils/parseDiversionHeader.spec.ts
@@ -0,0 +1,109 @@
+import { parseDiversionHeader } from './parseDiversionHeader';
+
+describe('parseDiversionHeader', () => {
+ describe('unquoted display name', () => {
+ it('parses a single-word unquoted display name', () => {
+ expect(parseDiversionHeader('Alice ;reason=unconditional')).toEqual({
+ extension: '1234',
+ displayName: 'Alice',
+ });
+ });
+
+ it('parses a multi-word unquoted display name', () => {
+ expect(parseDiversionHeader('Alice Smith ')).toEqual({
+ extension: '1234',
+ displayName: 'Alice Smith',
+ });
+ });
+ });
+
+ describe('quoted display name', () => {
+ it('parses a single-word quoted display name', () => {
+ expect(parseDiversionHeader('"Alice" ;reason=unconditional')).toEqual({
+ extension: '1234',
+ displayName: 'Alice',
+ });
+ });
+
+ it('parses a multi-word quoted display name', () => {
+ expect(parseDiversionHeader('"Alice Smith" ')).toEqual({
+ extension: '1234',
+ displayName: 'Alice Smith',
+ });
+ });
+
+ it('returns undefined displayName for an empty quoted string', () => {
+ expect(parseDiversionHeader('"" ')).toEqual({
+ extension: '1234',
+ displayName: undefined,
+ });
+ });
+ });
+
+ describe('no display name', () => {
+ it('parses the bare angle-bracket form', () => {
+ expect(parseDiversionHeader(';reason=unconditional')).toEqual({
+ extension: '1234',
+ displayName: undefined,
+ });
+ });
+ });
+
+ describe('URI variants', () => {
+ it('parses a sips: URI', () => {
+ expect(parseDiversionHeader('')).toEqual({
+ extension: '5678',
+ displayName: undefined,
+ });
+ });
+
+ it('handles URI parameters after the host', () => {
+ expect(parseDiversionHeader(';reason=unconditional')).toEqual({
+ extension: '1234',
+ displayName: undefined,
+ });
+ });
+ });
+
+ describe('whitespace handling', () => {
+ it('trims leading and trailing whitespace from the raw value', () => {
+ expect(parseDiversionHeader(' ')).toEqual({
+ extension: '9000',
+ displayName: undefined,
+ });
+ });
+
+ it('trims extra whitespace between an unquoted display name and the angle bracket', () => {
+ expect(parseDiversionHeader('Alice ')).toEqual({
+ extension: '1234',
+ displayName: 'Alice',
+ });
+ });
+ });
+
+ describe('invalid inputs', () => {
+ it('returns null for a bare addr-spec without angle brackets (not valid per RFC 5806)', () => {
+ expect(parseDiversionHeader('sip:1234@pbx.example.com')).toBeNull();
+ });
+
+ it('returns null for a URI with no user part', () => {
+ expect(parseDiversionHeader('')).toBeNull();
+ });
+
+ it('returns null when there is a display name but no number in the URI', () => {
+ expect(parseDiversionHeader('Alice ')).toBeNull();
+ });
+
+ it('returns null for a non-SIP URI inside brackets', () => {
+ expect(parseDiversionHeader('')).toBeNull();
+ });
+
+ it('returns null for an empty string', () => {
+ expect(parseDiversionHeader('')).toBeNull();
+ });
+
+ it('returns null for a non-string value', () => {
+ expect(parseDiversionHeader(null as unknown as string)).toBeNull();
+ });
+ });
+});
diff --git a/ee/packages/media-calls/src/sip/utils/parseDiversionHeader.ts b/ee/packages/media-calls/src/sip/utils/parseDiversionHeader.ts
new file mode 100644
index 0000000000000..676bb4d21910e
--- /dev/null
+++ b/ee/packages/media-calls/src/sip/utils/parseDiversionHeader.ts
@@ -0,0 +1,42 @@
+export type ParsedDiversion = {
+ extension: string;
+ displayName?: string;
+};
+
+/**
+ * Parses the value of a single SIP Diversion header field (RFC 5806).
+ *
+ * RFC 5806 mandates the name-addr form (angle brackets are always required):
+ * Alice ;reason=unconditional
+ * "Display Name" ;reason=unconditional
+ * ;reason=unconditional
+ *
+ * Returns the SIP URI user part as `extension` and the display name when present.
+ * Returns null if the value cannot be parsed into a usable extension.
+ */
+export function parseDiversionHeader(raw: string): ParsedDiversion | null {
+ if (!raw || typeof raw !== 'string') {
+ return null;
+ }
+
+ const value = raw.trim();
+
+ // name-addr: optional display name (quoted or unquoted) followed by angle-bracket URI
+ // Group 1: quoted display name, Group 2: unquoted display name, Group 3: SIP URI
+ const nameAddrMatch = value.match(/^(?:"([^"]*)"|([^<"]+?))?\s*<([^>]+)>/);
+ if (!nameAddrMatch) {
+ return null;
+ }
+
+ const rawDisplayName = nameAddrMatch[1] ?? nameAddrMatch[2];
+ const displayName = rawDisplayName?.trim() || undefined;
+ const sipUri = nameAddrMatch[3];
+
+ // Extract the user part from sip:user@host or sips:user@host
+ const uriMatch = sipUri?.match(/^sips?:([^@;>\s]+)@/i);
+ if (!uriMatch?.[1]) {
+ return null;
+ }
+
+ return { extension: uriMatch[1], displayName };
+}
diff --git a/fuselage.sh b/fuselage.sh
index 4a65b5c8374a9..56995d20c92cf 100755
--- a/fuselage.sh
+++ b/fuselage.sh
@@ -117,11 +117,10 @@ if [[ $action == "next-all" || $action == "latest-all" ]]; then
📦 @rocket.chat/fuselage-hooks [UPDATING to $targetVersion version...]
📦 @rocket.chat/icons [UPDATING to $targetVersion version...]
📦 @rocket.chat/logo [UPDATING to $targetVersion version...]
-📦 @rocket.chat/memo [UPDATING to $targetVersion version...]
📦 @rocket.chat/onboarding-ui [UPDATING to $targetVersion version...]
📦 @rocket.chat/layout [UPDATING to $targetVersion version...]"
- eval "yarn up @rocket.chat/fuselage-toastbar@$targetVersion @rocket.chat/fuselage-tokens@$targetVersion @rocket.chat/css-in-js@$targetVersion @rocket.chat/styled@$targetVersion @rocket.chat/fuselage@$targetVersion @rocket.chat/fuselage-hooks@$targetVersion @rocket.chat/icons@$targetVersion @rocket.chat/logo@$targetVersion @rocket.chat/memo@$targetVersion @rocket.chat/onboarding-ui@$targetVersion @rocket.chat/layout@$targetVersion"
+ eval "yarn up @rocket.chat/fuselage-toastbar@$targetVersion @rocket.chat/fuselage-tokens@$targetVersion @rocket.chat/css-in-js@$targetVersion @rocket.chat/styled@$targetVersion @rocket.chat/fuselage@$targetVersion @rocket.chat/fuselage-hooks@$targetVersion @rocket.chat/icons@$targetVersion @rocket.chat/logo@$targetVersion @rocket.chat/onboarding-ui@$targetVersion @rocket.chat/layout@$targetVersion"
exit 1
fi
diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts
index b726495500543..27c91b4adadc3 100644
--- a/packages/core-typings/src/mediaCalls/IMediaCall.ts
+++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts
@@ -64,6 +64,9 @@ export interface IMediaCall extends IRocketChatRecord {
transferredTo?: MediaCallContact;
transferredAt?: Date;
+ /** The party whose line was diverted at the SIP level (from the Diversion header) */
+ divertedBy?: MediaCallContact;
+
uids: IUser['_id'][];
/** The list of features that may be used in this call. Values are final once the call is accepted. */
diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json
index ee82a36c95b84..c97709099c01a 100644
--- a/packages/i18n/src/locales/en.i18n.json
+++ b/packages/i18n/src/locales/en.i18n.json
@@ -4369,6 +4369,7 @@
"Premium_capability": "Premium capability",
"Premium_omnichannel_capabilities": "Premium omnichannel capabilities",
"Premium_only": "Premium only",
+ "Premium_required_from_9_0_0_alert": "From version 9.0.0 on, this authentication method requires a Premium plan. Workspaces without a license covering it will not be able to authenticate users through it after the upgrade.",
"Please_provide_a_name_for_your_token": "Please provide a name for your token",
"Preparing_data_for_import_process": "Preparing data for import process",
"Preparing_list_of_channels": "Preparing list of channels",
diff --git a/packages/memo/jest.config.ts b/packages/memo/jest.config.ts
new file mode 100644
index 0000000000000..c18c8ae02465c
--- /dev/null
+++ b/packages/memo/jest.config.ts
@@ -0,0 +1,6 @@
+import server from '@rocket.chat/jest-presets/server';
+import type { Config } from 'jest';
+
+export default {
+ preset: server.preset,
+} satisfies Config;
diff --git a/packages/memo/package.json b/packages/memo/package.json
new file mode 100644
index 0000000000000..5fef6aa09b862
--- /dev/null
+++ b/packages/memo/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@rocket.chat/memo",
+ "version": "0.31.25",
+ "description": "Memoization utilities",
+ "keywords": [
+ "memoize"
+ ],
+ "bugs": {
+ "url": "https://github.com/RocketChat/Rocket.Chat/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/RocketChat/Rocket.Chat.git",
+ "directory": "packages/memo"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "Rocket.Chat",
+ "url": "https://rocket.chat/"
+ },
+ "main": "dist/cjs/index.js",
+ "module": "dist/esm/index.js",
+ "types": "dist/esm/index.d.ts",
+ "files": [
+ "/dist"
+ ],
+ "scripts": {
+ "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json",
+ "lint": "eslint .",
+ "lint:fix": "eslint --fix .",
+ "test": "jest",
+ "testunit": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@rocket.chat/jest-presets": "workspace:~",
+ "@rocket.chat/tsconfig": "workspace:~",
+ "eslint": "~9.39.5",
+ "jest": "~30.2.0",
+ "prettier": "~3.3.3",
+ "ts-jest": "~29.4.11",
+ "typescript": "~5.9.3"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/memo/src/index.ts b/packages/memo/src/index.ts
new file mode 100644
index 0000000000000..f684b36ced39d
--- /dev/null
+++ b/packages/memo/src/index.ts
@@ -0,0 +1 @@
+export { memoize, clear, type MemoizableFunction, type MemoizedFunction, type Options } from './memoize';
diff --git a/packages/memo/src/memoize.spec.ts b/packages/memo/src/memoize.spec.ts
new file mode 100644
index 0000000000000..3a4b99e40d4ad
--- /dev/null
+++ b/packages/memo/src/memoize.spec.ts
@@ -0,0 +1,105 @@
+import { memoize, clear } from './memoize';
+
+it('should memoize a function that takes no parameter', () => {
+ const fn = jest.fn(() => 'foo');
+ const memoized = jest.fn(memoize(fn));
+
+ memoized(undefined);
+ memoized(undefined);
+
+ expect(memoized).toHaveBeenCalledTimes(2);
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ expect(fn).toHaveReturnedWith('foo');
+ expect(memoized).toHaveReturnedWith('foo');
+});
+
+it('should memoize a function that takes one parameter', () => {
+ const fn = jest.fn((i: number) => i + 1);
+ const memoized = jest.fn(memoize(fn));
+
+ memoized(5);
+ memoized(5);
+ memoized(2);
+
+ expect(memoized).toHaveBeenCalledTimes(3);
+ expect(fn).toHaveBeenCalledTimes(2);
+
+ expect(fn).toHaveNthReturnedWith(1, 6);
+ expect(fn).toHaveNthReturnedWith(2, 3);
+
+ expect(memoized).toHaveNthReturnedWith(1, 6);
+ expect(memoized).toHaveNthReturnedWith(2, 6);
+ expect(memoized).toHaveNthReturnedWith(3, 3);
+});
+
+describe('clear', () => {
+ it('should discard cached values of a memoized function', () => {
+ const fn = jest.fn(() => 'foo');
+ const memoized = memoize(fn);
+ const spiedMemoized = jest.fn(memoized);
+
+ spiedMemoized(undefined);
+ spiedMemoized(undefined);
+ clear(memoized);
+ spiedMemoized(undefined);
+
+ expect(spiedMemoized).toHaveBeenCalledTimes(3);
+ expect(fn).toHaveBeenCalledTimes(2);
+
+ expect(fn).toHaveReturnedWith('foo');
+ expect(spiedMemoized).toHaveReturnedWith('foo');
+ });
+
+ it('should do nothing when a non-memoized function is passed', () => {
+ const fn = jest.fn(() => 'foo');
+
+ expect(() => clear(fn)).not.toThrow();
+ });
+});
+
+describe('timeout', () => {
+ it('should memoize a function that takes one parameter and clear after x ms', () => {
+ jest.useFakeTimers();
+
+ const fn = jest.fn((i: number) => i + 1);
+ const memoized = jest.fn(memoize(fn, { maxAge: 3000 }));
+
+ memoized(5);
+ jest.advanceTimersByTime(2000);
+ memoized(5);
+ jest.advanceTimersByTime(2000);
+ memoized(5);
+ jest.advanceTimersByTime(3000);
+ memoized(5);
+
+ expect(fn).toHaveBeenCalledTimes(2);
+
+ expect(memoized).toHaveNthReturnedWith(1, 6);
+ expect(memoized).toHaveNthReturnedWith(2, 6);
+ expect(memoized).toHaveNthReturnedWith(3, 6);
+ expect(memoized).toHaveNthReturnedWith(4, 6);
+ });
+
+ it('should memoize a function caching for two parameters and clearing both after x ms each one', () => {
+ jest.useFakeTimers();
+
+ const fn = jest.fn((i: number) => i + 1);
+ const memoized = jest.fn(memoize(fn, { maxAge: 3000 }));
+
+ memoized(5);
+ jest.advanceTimersByTime(2000);
+ memoized(6);
+ jest.advanceTimersByTime(2000);
+
+ memoized(6);
+ memoized(5);
+
+ expect(fn).toHaveBeenCalledTimes(3);
+
+ expect(memoized).toHaveNthReturnedWith(1, 6);
+ expect(memoized).toHaveNthReturnedWith(2, 7);
+ expect(memoized).toHaveNthReturnedWith(3, 7);
+ expect(memoized).toHaveNthReturnedWith(4, 6);
+ });
+});
diff --git a/packages/memo/src/memoize.ts b/packages/memo/src/memoize.ts
new file mode 100644
index 0000000000000..3b9262233cfd9
--- /dev/null
+++ b/packages/memo/src/memoize.ts
@@ -0,0 +1,59 @@
+export type MemoizableFunction = (this: T, arg: A) => R;
+export type MemoizedFunction = (this: T, arg: A) => R;
+
+export type Options = {
+ maxAge: number;
+};
+
+const store = new WeakMap, Map>();
+
+const isCachedValue = (cachedValue: R | undefined, arg: A, cache: Map): cachedValue is R =>
+ cache.has(arg) && cache.get(arg) === cachedValue;
+
+export const memoize = (fn: MemoizableFunction, _options?: Options): MemoizedFunction => {
+ const cache = new Map();
+ const cacheTimers = new Map>();
+
+ const memoized: MemoizedFunction = function (this, arg) {
+ const cleanUp = (): void => {
+ cache.delete(arg);
+ cacheTimers.delete(arg);
+ };
+
+ const cachedValue = cache.get(arg);
+
+ if (isCachedValue(cachedValue, arg, cache)) {
+ const oldTimer = cacheTimers.get(arg);
+ if (oldTimer) {
+ clearTimeout(oldTimer);
+ }
+
+ if (_options) {
+ const timer = setTimeout(cleanUp, _options.maxAge);
+ cacheTimers.set(arg, timer);
+ }
+
+ return cachedValue;
+ }
+
+ const result = fn.call(this, arg);
+
+ cache.set(arg, result);
+
+ if (_options) {
+ const timer = setTimeout(cleanUp, _options.maxAge);
+ cacheTimers.set(arg, timer);
+ }
+
+ return result;
+ };
+
+ store.set(memoized as MemoizableFunction, cache);
+
+ return memoized;
+};
+
+export const clear = (fn: MemoizedFunction): void => {
+ const cache = store.get(fn);
+ cache?.clear();
+};
diff --git a/packages/memo/tsconfig.cjs.json b/packages/memo/tsconfig.cjs.json
new file mode 100644
index 0000000000000..f06fae752ab38
--- /dev/null
+++ b/packages/memo/tsconfig.cjs.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist/cjs"
+ },
+ "exclude": ["**/*.spec.*", "jest.config.*"]
+}
diff --git a/packages/memo/tsconfig.esm.json b/packages/memo/tsconfig.esm.json
new file mode 100644
index 0000000000000..196377c39d63d
--- /dev/null
+++ b/packages/memo/tsconfig.esm.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "node",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist/esm"
+ },
+ "exclude": ["**/*.spec.*", "jest.config.*"]
+}
diff --git a/packages/memo/tsconfig.json b/packages/memo/tsconfig.json
new file mode 100644
index 0000000000000..9b3a9596ad65e
--- /dev/null
+++ b/packages/memo/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@rocket.chat/tsconfig/base.json",
+ "include": ["src/**/*"]
+}
diff --git a/yarn.lock b/yarn.lock
index cf07e2ab30f3a..bbd37c3a3d191 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9974,6 +9974,7 @@ __metadata:
dependencies:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/emitter": "workspace:~"
+ "@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/logger": "workspace:^"
"@rocket.chat/media-signaling": "workspace:^"
"@rocket.chat/models": "workspace:^"
@@ -9981,6 +9982,7 @@ __metadata:
"@types/jest": "npm:~30.0.0"
drachtio-srf: "patch:drachtio-srf@npm%3A5.0.12#~/.yarn/patches/drachtio-srf-npm-5.0.12-b0b1afaad6.patch"
eslint: "npm:~9.39.5"
+ jest: "npm:~30.2.0"
typescript: "npm:~5.9.3"
languageName: unknown
linkType: soft
@@ -10000,12 +10002,19 @@ __metadata:
languageName: unknown
linkType: soft
-"@rocket.chat/memo@npm:^0.31.25, @rocket.chat/memo@npm:~0.31.25":
- version: 0.31.25
- resolution: "@rocket.chat/memo@npm:0.31.25"
- checksum: 10/92d595c68d76a5258fb37ed4639e2709ba290c5d240df1272d81a2ab6b4be28ee2dd5b721dad940fe2638a89e8d14e684a970c59890003a06ce6088c655b7c0e
- languageName: node
- linkType: hard
+"@rocket.chat/memo@npm:^0.31.25, @rocket.chat/memo@workspace:packages/memo, @rocket.chat/memo@workspace:~":
+ version: 0.0.0-use.local
+ resolution: "@rocket.chat/memo@workspace:packages/memo"
+ dependencies:
+ "@rocket.chat/jest-presets": "workspace:~"
+ "@rocket.chat/tsconfig": "workspace:~"
+ eslint: "npm:~9.39.5"
+ jest: "npm:~30.2.0"
+ prettier: "npm:~3.3.3"
+ ts-jest: "npm:~29.4.11"
+ typescript: "npm:~5.9.3"
+ languageName: unknown
+ linkType: soft
"@rocket.chat/message-parser@workspace:^, @rocket.chat/message-parser@workspace:packages/message-parser, @rocket.chat/message-parser@workspace:~":
version: 0.0.0-use.local
@@ -10124,7 +10133,7 @@ __metadata:
"@rocket.chat/logo": "npm:^0.33.2"
"@rocket.chat/media-calls": "workspace:^"
"@rocket.chat/media-signaling": "workspace:^"
- "@rocket.chat/memo": "npm:~0.31.25"
+ "@rocket.chat/memo": "workspace:~"
"@rocket.chat/message-parser": "workspace:^"
"@rocket.chat/message-types": "workspace:~"
"@rocket.chat/mock-providers": "workspace:^"