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,78 @@
import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings';
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useAgentsList } from './useAgentsList';
import { createFakeAgent } from '../../../../tests/mocks/data';
const formatAgentItem = (agent: Serialized<ILivechatAgent>) => ({
_id: agent._id,
label: `${agent.name || agent._id} (@${agent.username})`,
value: agent._id,
});
const mockGetAgents = jest.fn();
const appRoot = new MockedAppRootBuilder()
.withTranslations('en', 'core', { All: 'All', Empty_no_agent_selected: 'Empty, no agent selected' })
.withEndpoint('GET', '/v1/livechat/users/agent', mockGetAgents);
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch agents', async () => {
const limit = 5;
const data = Array.from({ length: 10 }, () => createFakeAgent());
mockGetAgents.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
const users = data.slice(offset, offset + count);
return {
users,
count,
offset,
total: data.length,
};
});
const { result } = renderHook(() => useAgentsList({ filter: '', limit }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatAgentItem)));
await act(() => result.current.fetchNextPage());
await waitFor(() => expect(result.current.data).toEqual(data.map(formatAgentItem)));
await act(() => result.current.fetchNextPage());
// should not fetch again since total was reached
expect(mockGetAgents).toHaveBeenCalledTimes(2);
});
it('should include "All" item if haveAll is true', async () => {
mockGetAgents.mockResolvedValueOnce({
users: Array.from({ length: 5 }, () => createFakeAgent()),
count: 5,
offset: 0,
total: 5,
});
const { result } = renderHook(() => useAgentsList({ filter: '', haveAll: true }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data[0].label).toBe('All'));
});
it('should include "Empty_no_agent_selected" item if haveNoAgentsSelectedOption is true', async () => {
mockGetAgents.mockResolvedValueOnce({
users: Array.from({ length: 5 }, () => createFakeAgent()),
count: 5,
offset: 0,
total: 5,
});
const { result } = renderHook(() => useAgentsList({ filter: '', haveNoAgentsSelectedOption: true }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data[0].label).toBe('Empty, no agent selected'));
});

View File

@@ -0,0 +1,92 @@
import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
type AgentsListOptions = {
filter: string;
haveAll?: boolean;
haveNoAgentsSelectedOption?: boolean;
excludeId?: string;
showIdleAgents?: boolean;
onlyAvailable?: boolean;
limit?: number;
};
type AgentOption = {
_id: string;
value: string;
label: string;
};
const DEFAULT_QUERY_LIMIT = 25;
export const useAgentsList = (options: AgentsListOptions) => {
const { t } = useTranslation();
const getAgents = useEndpoint('GET', '/v1/livechat/users/agent');
const {
filter,
onlyAvailable = false,
showIdleAgents = true,
excludeId,
haveAll,
haveNoAgentsSelectedOption,
limit = DEFAULT_QUERY_LIMIT,
} = options;
const formatAgentItem = (agent: Serialized<ILivechatAgent>) => ({
_id: agent._id,
label: `${agent.name || agent._id} (@${agent.username})`,
value: agent._id,
});
return useInfiniteQuery({
queryKey: ['/v1/livechat/users/agent', { filter, onlyAvailable, showIdleAgents, excludeId, haveAll, haveNoAgentsSelectedOption }],
queryFn: async ({ pageParam: offset = 0 }) => {
const { users, ...data } = await getAgents({
...(filter && { text: filter }),
...(excludeId && { excludeId }),
showIdleAgents,
onlyAvailable,
offset,
count: limit,
sort: `{ "name": 1 }`,
});
return {
...data,
users: users.map(formatAgentItem),
};
},
select: (data) => {
const items = data.pages.flatMap<AgentOption>((page) => page.users);
if (haveAll) {
items.unshift({
label: t('All'),
value: 'all',
_id: 'all',
});
}
if (haveNoAgentsSelectedOption) {
items.unshift({
label: t('Empty_no_agent_selected'),
value: 'no-agent-selected',
_id: 'no-agent-selected',
});
}
return items;
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
initialData: () => ({
pages: [{ users: [], offset: 0, count: 0, total: Infinity }],
pageParams: [0],
}),
});
};

View File

@@ -0,0 +1,33 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const useCannedResponseFilterOptions = (): string[][] => {
const { t } = useTranslation();
const getDepartments = useEndpoint('GET', '/v1/livechat/department');
const defaultOptions = useMemo(
() => [
['all', t('All')],
['global', t('Public')],
['user', t('Private')],
],
[t],
);
const [options, setOptions] = useState(defaultOptions);
useEffect(() => {
const fetchData = async (): Promise<void> => {
const { departments } = await getDepartments({ text: '' });
const newOptions = departments.map((department: any) => [department._id, department.name]);
setOptions(defaultOptions.concat(newOptions));
};
fetchData();
}, [defaultOptions, getDepartments]);
return options;
};

View File

@@ -0,0 +1,59 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { cannedResponsesQueryKeys } from '../../../lib/queryKeys';
export const useCannedResponseList = ({ filter, type }: { filter: string; type: string }) => {
const getCannedResponses = useEndpoint('GET', '/v1/canned-responses');
const getDepartments = useEndpoint('GET', '/v1/livechat/department');
const count = 25;
return useInfiniteQuery({
queryKey: cannedResponsesQueryKeys.list({ filter, type }),
queryFn: async ({ pageParam: offset }) => {
const { cannedResponses, total } = await getCannedResponses({
...(filter && { text: filter }),
...(type && ['global', 'user'].find((option) => option === type) && { scope: type }),
...(type &&
!['global', 'user', 'all'].find((option) => option === type) && {
scope: 'department',
departmentId: type,
}),
offset,
count,
});
// TODO: Optimize by creating an endpoint that returns canned responses with department names
// TODO: Use another query for departments to avoid refetching
const { departments } = await getDepartments({ text: '' });
return {
items: cannedResponses.map((cannedResponse): IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] } => {
const departmentName = cannedResponse.departmentId
? departments.find((department) => department._id === cannedResponse.departmentId)?.name
: undefined;
return {
...cannedResponse,
_updatedAt: new Date(cannedResponse._updatedAt),
_createdAt: new Date(cannedResponse._createdAt),
departmentName,
};
}),
itemCount: total,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage, _, lastOffset) => {
const nextOffset = lastOffset + count;
if (nextOffset >= lastPage.itemCount) return undefined;
return nextOffset;
},
select: ({ pages }) => ({
cannedItems: pages.flatMap((page) => page.items),
total: pages.at(-1)?.itemCount,
}),
});
};

View File

@@ -0,0 +1,27 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
const CannedResponse = lazy(() => import('../cannedResponses/contextualBar/CannedResponse/WrapCannedResponseList'));
export const useCannedResponsesRoomAction = () => {
const { data: licensed = false } = useHasLicenseModule('canned-responses');
const enabled = useSetting('Canned_Responses_Enable', false);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!licensed || !enabled) {
return undefined;
}
return {
id: 'canned-responses',
groups: ['live'],
title: 'Canned_Responses',
icon: 'canned-response',
tabComponent: CannedResponse,
order: 0,
};
}, [enabled, licensed]);
};

View File

@@ -0,0 +1,18 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const ContactInfoRouter = lazy(() => import('../contactInfo/ContactInfoRouter'));
export const useContactProfileRoomAction = () => {
return useMemo(
(): RoomToolboxActionConfig => ({
id: 'contact-profile',
groups: ['live'],
title: 'Contact_Info',
icon: 'user',
tabComponent: ContactInfoRouter,
order: 1,
}),
[],
);
};

View File

@@ -0,0 +1,48 @@
import type { RouteParameters } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect } from 'react';
export const useContactRoute = () => {
const { navigate, getRouteParameters, getRouteName } = useRouter();
const currentRouteName = getRouteName();
const currentParams = getRouteParameters();
const handleNavigate = useCallback(
({ id, ...params }: RouteParameters) => {
if (!currentRouteName) {
return;
}
// TODO: Map out all the routes that use this hook and properly handle them
if (currentRouteName === 'omnichannel-directory' || currentRouteName === 'omnichannel-current-chats') {
return navigate({
name: currentRouteName,
params: {
...currentParams,
tab: 'contacts',
id: id || currentParams.id,
...params,
},
});
}
navigate({
name: currentRouteName,
params: {
...currentParams,
id: currentParams.id,
...params,
},
});
},
[navigate, currentParams, currentRouteName],
);
useEffect(() => {
if (!currentParams.context) {
handleNavigate({ context: 'details' });
}
}, [currentParams.context, handleNavigate]);
return handleNavigate;
};

View File

@@ -0,0 +1,11 @@
import type { OperationResult } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
export const useCustomFieldsQuery = (): UseQueryResult<OperationResult<'GET', '/v1/livechat/custom-fields'>> => {
const getCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields');
return useQuery({ queryKey: omnichannelQueryKeys.livechat.customFields(), queryFn: async () => getCustomFields() });
};

View File

@@ -0,0 +1,140 @@
import type { ILivechatDepartment, Serialized } from '@rocket.chat/core-typings';
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useDepartmentsList } from './useDepartmentsList';
import { createFakeDepartment } from '../../../../tests/mocks/data';
const formatDepartmentItem = (department: Serialized<ILivechatDepartment>) => ({
_id: department._id,
label: department.archived ? `${department.name} [Archived]` : department.name,
value: department._id,
});
const mockGetDepartments = jest.fn();
const mockGetUnitDepartments = jest.fn();
const mockGetDepartment = jest.fn();
const appRoot = new MockedAppRootBuilder()
.withTranslations('en', 'core', { All: 'All', None: 'None', Archived: 'Archived' })
.withEndpoint('GET', '/v1/livechat/department', mockGetDepartments)
.withEndpoint('GET', '/v1/livechat/units/:unitId/departments/available', mockGetUnitDepartments)
.withEndpoint('GET', '/v1/livechat/department/:_id', mockGetDepartment);
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch departments', async () => {
const limit = 5;
const data = Array.from({ length: 10 }, () => createFakeDepartment());
mockGetDepartments.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
const departments = data.slice(offset, offset + count);
return {
departments,
count,
offset,
total: data.length,
};
});
const { result } = renderHook(() => useDepartmentsList({ filter: '', limit }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatDepartmentItem)));
await act(() => result.current.fetchNextPage());
await waitFor(() => expect(result.current.data).toEqual(data.map(formatDepartmentItem)));
await act(() => result.current.fetchNextPage());
// should not fetch again since total was reached
expect(mockGetDepartments).toHaveBeenCalledTimes(2);
});
it('should fetch unit departments when unitId is provided', async () => {
const unitDepartmentsList = Array.from({ length: 10 }, () => createFakeDepartment());
const departmentItems = unitDepartmentsList.map(formatDepartmentItem);
mockGetUnitDepartments.mockResolvedValue({
departments: unitDepartmentsList,
count: 5,
offset: 0,
total: 5,
});
const { result } = renderHook(() => useDepartmentsList({ filter: '', unitId: '123' }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data).toEqual(departmentItems));
expect(mockGetDepartment).not.toHaveBeenCalled();
expect(mockGetDepartments).not.toHaveBeenCalled();
expect(mockGetUnitDepartments).toHaveBeenCalled();
});
it('should format archived departments correctly', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: [createFakeDepartment({ name: 'Test Department', archived: true })],
count: 1,
offset: 0,
total: 1,
});
const { result } = renderHook(() => useDepartmentsList({ filter: '' }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data.length).toBe(1));
expect(result.current.data[0].label).toBe('Test Department [Archived]');
});
it('should include "All" item if haveAll is true', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: Array.from({ length: 5 }, () => createFakeDepartment()),
count: 5,
offset: 0,
total: 5,
});
const { result } = renderHook(() => useDepartmentsList({ filter: '', haveAll: true }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data[0].label).toBe('All'));
});
it('should include "None" item if haveNone is true', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: Array.from({ length: 5 }, () => createFakeDepartment()),
count: 5,
offset: 0,
total: 5,
});
const { result } = renderHook(() => useDepartmentsList({ filter: '', haveNone: true }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data[0].label).toBe('None'));
});
it('should fetch the selected department if selectedDepartmentId is provided', async () => {
const departmentsList = Array.from({ length: 10 }, () => createFakeDepartment());
const departmentItems = departmentsList.map(formatDepartmentItem);
const selectedDepartmentMock = createFakeDepartment({ _id: 'selected-department-id' });
const selectedDepartmentItem = formatDepartmentItem(selectedDepartmentMock);
mockGetDepartment.mockResolvedValueOnce({ department: selectedDepartmentMock });
mockGetDepartments.mockResolvedValueOnce({
departments: [...departmentsList, createFakeDepartment({ _id: 'selected-department-id' })],
count: 10,
offset: 0,
total: 10,
});
const { result } = renderHook(() => useDepartmentsList({ filter: '', selectedDepartmentId: selectedDepartmentMock._id }), {
wrapper: appRoot.build(),
});
await waitFor(() => expect(result.current.data).toEqual([...departmentItems, selectedDepartmentItem]));
expect(mockGetDepartments).toHaveBeenCalled();
expect(mockGetDepartment).toHaveBeenCalled();
});

View File

@@ -0,0 +1,111 @@
import type { ILivechatDepartment, Serialized } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { DepartmentListItem } from '../types/DepartmentsDefinitions';
type DepartmentsListOptions = {
filter: string;
onlyMyDepartments?: boolean;
haveAll?: boolean;
haveNone?: boolean;
excludeId?: string;
enabled?: boolean;
showArchived?: boolean;
selectedDepartmentId?: string;
limit?: number;
unitId?: string;
};
const DEFAULT_QUERY_LIMIT = 50;
export const useDepartmentsList = (options: DepartmentsListOptions) => {
const { t } = useTranslation();
const { enabled, filter, unitId, showArchived, excludeId, onlyMyDepartments, limit = DEFAULT_QUERY_LIMIT } = options;
const getLivechatDepartments = useEndpoint('GET', '/v1/livechat/department');
const getUnitDepartments = useEndpoint('GET', '/v1/livechat/units/:unitId/departments/available', { unitId: options.unitId || 'none' });
const getDepartments = options.unitId !== undefined ? getUnitDepartments : getLivechatDepartments;
const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: options.selectedDepartmentId ?? '' });
const formatDepartmentItem = ({ _id, name, ...department }: Serialized<ILivechatDepartment>): DepartmentListItem => ({
_id,
label: department.archived ? `${name} [${t('Archived')}]` : name,
value: _id,
});
const selectedQueryResult = useQuery({
queryKey: ['/v1/livechat/department/:_id', options.selectedDepartmentId],
queryFn: async () => getDepartment({}),
select: (data) => formatDepartmentItem(data.department),
enabled: options.selectedDepartmentId !== undefined,
});
const listQueryResult = useInfiniteQuery({
queryKey: ['/v1/livechat/department', options],
queryFn: async ({ pageParam: offset = 0 }) => {
const { departments, ...data } = await getDepartments({
onlyMyDepartments: `${!!onlyMyDepartments}`,
text: filter,
offset,
count: limit,
sort: `{ "name": 1 }`,
...(!unitId && {
excludeDepartmentId: excludeId,
enabled: enabled ? 'true' : 'false',
showArchived: showArchived ? 'true' : 'false',
}),
});
return {
...data,
departments: departments.map(formatDepartmentItem).filter((item) => item._id !== options.selectedDepartmentId),
};
},
select: (data) => {
const items = data.pages.flatMap<DepartmentListItem>((page) => page.departments);
if (options.haveAll) {
items.unshift({
_id: '',
label: t('All'),
value: 'all',
});
}
if (options.haveNone) {
items.unshift({
_id: '',
label: t('None'),
value: '',
});
}
return items;
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
initialData: () => ({
pages: [{ departments: [], offset: 0, count: 0, total: Infinity }],
pageParams: [0],
}),
});
const data = useMemo(() => {
if (selectedQueryResult.data) {
return [...listQueryResult.data, selectedQueryResult.data];
}
return listQueryResult.data;
}, [selectedQueryResult.data, listQueryResult.data]);
return {
...listQueryResult,
data,
};
};

View File

@@ -0,0 +1,6 @@
import { useOmnichannel } from './useOmnichannel';
export const useIsOverMacLimit = (): boolean => {
const { isOverMacLimit } = useOmnichannel();
return isOverMacLimit;
};

View File

@@ -0,0 +1,23 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IRoom, IOmnichannelGenericRoom } from '@rocket.chat/core-typings';
import { useIsOverMacLimit } from './useIsOverMacLimit';
const getPeriod = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
export const useIsRoomOverMacLimit = (room: IRoom) => {
const isOverMacLimit = useIsOverMacLimit();
if (!isOmnichannelRoom(room)) {
return false;
}
if (room.closedAt) {
return false;
}
const { v: { activity = [] } = {} } = room as IOmnichannelGenericRoom;
const currentPeriod = getPeriod(new Date());
return isOverMacLimit && !activity.includes(currentPeriod);
};

View File

@@ -0,0 +1,29 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useOmnichannel } from './useOmnichannel';
type Props = {
department?: string;
text?: string;
viewAll?: boolean;
};
export const useLivechatTags = (options: Props) => {
const getTags = useEndpoint('GET', '/v1/livechat/tags');
const { isEnterprise } = useOmnichannel();
const { department, text, viewAll } = options;
return useQuery({
queryKey: ['/v1/livechat/tags', text, department],
queryFn: () =>
getTags({
text: text || '',
...(department && { department }),
viewAll: viewAll ? 'true' : 'false',
}),
enabled: isEnterprise,
});
};

View File

@@ -0,0 +1,50 @@
import type { ILivechatMonitor, Serialized } from '@rocket.chat/core-typings';
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useMonitorsList } from './useMonitorsList';
import { createFakeMonitor } from '../../../../tests/mocks/data';
const formatMonitorItem = (monitor: Serialized<ILivechatMonitor>) => ({
_id: monitor._id,
label: monitor.username,
value: monitor._id,
});
const mockGetMonitors = jest.fn();
const appRoot = new MockedAppRootBuilder().withEndpoint('GET', '/v1/livechat/monitors', mockGetMonitors);
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch monitors', async () => {
const limit = 5;
const data = Array.from({ length: 10 }, () => createFakeMonitor());
mockGetMonitors.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
const monitors = data.slice(offset, offset + count);
return {
monitors,
count,
offset,
total: data.length,
};
});
const { result } = renderHook(() => useMonitorsList({ filter: '', limit }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatMonitorItem)));
await act(() => result.current.fetchNextPage());
await waitFor(() => expect(result.current.data).toEqual(data.map(formatMonitorItem)));
await act(() => result.current.fetchNextPage());
// should not fetch again since total was reached
expect(mockGetMonitors).toHaveBeenCalledTimes(2);
});

View File

@@ -0,0 +1,54 @@
import type { Serialized, ILivechatMonitor } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
type MonitorsListOptions = {
filter: string;
limit?: number;
};
type MonitorListItem = {
_id: string;
label: string;
value: string;
};
const DEFAULT_QUERY_LIMIT = 25;
export const useMonitorsList = (options: MonitorsListOptions) => {
const { filter, limit = DEFAULT_QUERY_LIMIT } = options;
const getMonitors = useEndpoint('GET', '/v1/livechat/monitors');
const formatMonitorItem = (monitor: Serialized<ILivechatMonitor>): MonitorListItem => ({
_id: monitor._id,
label: monitor.username,
value: monitor._id,
});
return useInfiniteQuery({
queryKey: ['/v1/livechat/monitors', options],
queryFn: async ({ pageParam: offset = 0 }) => {
const { monitors, ...data } = await getMonitors({
text: filter,
offset,
count: limit,
sort: JSON.stringify({ username: 1 }),
});
return {
...data,
monitors: monitors.map(formatMonitorItem),
};
},
select: (data) => data.pages.flatMap<MonitorListItem>((page) => page.monitors),
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
initialData: () => ({
pages: [{ monitors: [], offset: 0, count: 0, total: Infinity }],
pageParams: [0],
}),
});
};

View File

@@ -0,0 +1,6 @@
import { useContext } from 'react';
import type { OmnichannelContextValue } from '../../../contexts/OmnichannelContext';
import { OmnichannelContext } from '../../../contexts/OmnichannelContext';
export const useOmnichannel = (): OmnichannelContextValue => useContext(OmnichannelContext);

View File

@@ -0,0 +1,3 @@
import { useOmnichannel } from './useOmnichannel';
export const useOmnichannelAgentAvailable = (): boolean => useOmnichannel().agentAvailable;

View File

@@ -0,0 +1,24 @@
import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { useOmnichannelDirectoryRouter } from '../directory/hooks/useOmnichannelDirectoryRouter';
export const useOmnichannelCloseRoute = () => {
const hideConversationAfterClosing = useUserPreference<boolean>('omnichannelHideConversationAfterClosing') ?? true;
const router = useRouter();
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const navigateHome = useCallback(() => {
if (!hideConversationAfterClosing) {
return;
}
if (omnichannelDirectoryRouter.getRouteName()) {
omnichannelDirectoryRouter.navigate();
} else {
router.navigate({ name: 'home' });
}
}, [hideConversationAfterClosing, omnichannelDirectoryRouter, router]);
return { navigateHome };
};

View File

@@ -0,0 +1,3 @@
import { useOmnichannel } from './useOmnichannel';
export const useOmnichannelEnabled = (): boolean => useOmnichannel().enabled;

View File

@@ -0,0 +1,6 @@
import { useOmnichannel } from './useOmnichannel';
export const useOmnichannelEnterpriseEnabled = (): boolean => {
const { enabled: isEnabled, isEnterprise } = useOmnichannel();
return isEnabled && isEnterprise;
};

View File

@@ -0,0 +1,24 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const ExternalFrameContainer = lazy(() => import('../ExternalFrameContainer'));
export const useOmnichannelExternalFrameRoomAction = () => {
const enabled = useSetting('Omnichannel_External_Frame_Enabled', false);
return useMemo((): RoomToolboxActionConfig | undefined => {
if (!enabled) {
return undefined;
}
return {
id: 'omnichannel-external-frame',
groups: ['live'],
title: 'Omnichannel_External_Frame',
icon: 'cube',
tabComponent: ExternalFrameContainer,
order: -1,
};
}, [enabled]);
};

View File

@@ -0,0 +1,3 @@
import { useOmnichannel } from './useOmnichannel';
export const useOmnichannelPriorities = () => useOmnichannel().livechatPriorities;

View File

@@ -0,0 +1,64 @@
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
import { Palette } from '@rocket.chat/fuselage';
import type { Keys } from '@rocket.chat/icons';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useOmnichannelPriorities } from './useOmnichannelPriorities';
type PrioritiesConfig = { iconName: Keys; color?: string; variant?: 'secondary-danger' | 'secondary-warning' | 'secondary-info' };
export const PRIORITIES_CONFIG: Record<number, PrioritiesConfig> = {
[LivechatPriorityWeight.NOT_SPECIFIED]: {
iconName: 'circle-unfilled',
},
[LivechatPriorityWeight.HIGHEST]: {
iconName: 'chevron-double-up',
color: Palette.badge['badge-background-level-4'].toString(),
variant: 'secondary-danger',
},
[LivechatPriorityWeight.HIGH]: {
iconName: 'chevron-up',
color: Palette.badge['badge-background-level-4'].toString(),
variant: 'secondary-danger',
},
[LivechatPriorityWeight.MEDIUM]: {
iconName: 'equal',
color: Palette.badge['badge-background-level-3'].toString(),
variant: 'secondary-warning',
},
[LivechatPriorityWeight.LOW]: {
iconName: 'chevron-down',
color: Palette.badge['badge-background-level-2'].toString(),
variant: 'secondary-info',
},
[LivechatPriorityWeight.LOWEST]: {
iconName: 'chevron-double-down',
color: Palette.badge['badge-background-level-2'].toString(),
variant: 'secondary-info',
},
};
export const useOmnichannelPrioritiesConfig = (level: LivechatPriorityWeight, showUnprioritized: boolean) => {
const { t } = useTranslation();
const { iconName, color, variant } = PRIORITIES_CONFIG[level];
const { data: priorities } = useOmnichannelPriorities();
const name = useMemo(() => {
const { _id, dirty, name, i18n } = priorities.find((p) => p.sortItem === level) || {};
if (!_id) {
return '';
}
return dirty ? name : t(i18n as TranslationKey);
}, [level, priorities, t]);
if (!showUnprioritized && level === LivechatPriorityWeight.NOT_SPECIFIED) {
return null;
}
return { iconName, name, color, variant };
};

View File

@@ -0,0 +1,57 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useOmnichannelPriorities } from './useOmnichannelPriorities';
import { PRIORITIES_CONFIG } from './useOmnichannelPrioritiesConfig';
import { roomsQueryKeys } from '../../../lib/queryKeys';
export const useOmnichannelPrioritiesMenu = (rid: IRoom['_id']) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const updateRoomPriority = useEndpoint('POST', '/v1/livechat/room/:rid/priority', { rid });
const removeRoomPriority = useEndpoint('DELETE', '/v1/livechat/room/:rid/priority', { rid });
const { data: priorities } = useOmnichannelPriorities();
const dispatchToastMessage = useToastMessageDispatch();
return useMemo(() => {
const handlePriorityChange = (priorityId: string) => async () => {
try {
if (priorityId) {
await updateRoomPriority({ priorityId });
} else {
await removeRoomPriority();
}
queryClient.invalidateQueries({ queryKey: ['current-chats'] });
queryClient.invalidateQueries({ queryKey: roomsQueryKeys.info(rid) });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const unprioritizedOption = {
id: 'unprioritized',
icon: PRIORITIES_CONFIG[LivechatPriorityWeight.NOT_SPECIFIED].iconName,
iconColor: PRIORITIES_CONFIG[LivechatPriorityWeight.NOT_SPECIFIED].color,
content: t('Unprioritized'),
onClick: handlePriorityChange(''),
};
const options = priorities.map(({ _id: priorityId, name, i18n, dirty, sortItem }) => {
const label = dirty && name ? name : t(i18n);
return {
id: priorityId,
icon: PRIORITIES_CONFIG[sortItem].iconName,
iconColor: PRIORITIES_CONFIG[sortItem].color,
content: label,
onClick: handlePriorityChange(priorityId),
};
});
return priorities.length ? [unprioritizedOption, ...options] : [];
}, [t, priorities, updateRoomPriority, removeRoomPriority, queryClient, rid, dispatchToastMessage]);
};

View File

@@ -0,0 +1,5 @@
import type { OmichannelRoutingConfig } from '@rocket.chat/core-typings';
import { useOmnichannel } from './useOmnichannel';
export const useOmnichannelRouteConfig = (): OmichannelRoutingConfig | undefined => useOmnichannel().routeConfig;

View File

@@ -0,0 +1,3 @@
import { useOmnichannel } from './useOmnichannel';
export const useOmnichannelShowQueueLink = (): boolean => useOmnichannel().showOmnichannelQueueLink;

View File

@@ -0,0 +1,34 @@
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useOmnichannelSource = () => {
const { t } = useTranslation();
const getSourceName = useCallback(
(source: IOmnichannelSource, allowAlias = true) => {
const roomSource = source.alias || source.id || source.type;
const defaultTypesLabels: { [key: string]: string } = {
widget: t('Livechat'),
email: t('Email'),
sms: t('SMS'),
app: (allowAlias && source.alias) || t('Custom_APP'),
api: (allowAlias && source.alias) || t('Custom_API'),
other: t('Custom_Integration'),
};
return defaultTypesLabels[source.type] || roomSource;
},
[t],
);
const getSourceLabel = useCallback(
(source: IOmnichannelSource) => {
return source?.destination || source?.label || t('No_app_label_provided');
},
[t],
);
return { getSourceName, getSourceLabel };
};

View File

@@ -0,0 +1,5 @@
import type { Inquiries } from '@rocket.chat/core-typings';
import { useOmnichannel } from './useOmnichannel';
export const useQueuedInquiries = (): Inquiries => useOmnichannel().inquiries;

View File

@@ -0,0 +1,17 @@
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
import { lazy, useMemo } from 'react';
const ChatsContextualBar = lazy(() => import('../directory/chats/ChatInfo/ChatsContextualBar'));
export const useRoomInfoRoomAction = () =>
useMemo(
(): RoomToolboxActionConfig => ({
id: 'room-info',
groups: ['live'],
title: 'Room_Info',
icon: 'info-circled',
tabComponent: ChatsContextualBar,
order: 0,
}),
[],
);

View File

@@ -0,0 +1,13 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { useTranslation } from 'react-i18next';
export const useScopeDict = (scope: IOmnichannelCannedResponse['scope'], departmentName: ILivechatDepartment['name'] | undefined) => {
const { t } = useTranslation();
const dict: Record<string, string> = {
global: t('Public'),
user: t('Private'),
};
return dict[scope] || departmentName;
};

View File

@@ -0,0 +1,51 @@
import type { ILivechatTag, Serialized } from '@rocket.chat/core-typings';
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TagListItem } from './useTagsList';
import { useTagsList } from './useTagsList';
import { createFakeTag } from '../../../../tests/mocks/data';
const formatTagItem = (tag: Serialized<ILivechatTag>): TagListItem => ({
_id: tag._id,
label: tag.name,
value: tag.name,
});
const mockGetTags = jest.fn();
const appRoot = new MockedAppRootBuilder().withEndpoint('GET', '/v1/livechat/tags', mockGetTags);
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch tags', async () => {
const limit = 5;
const data = Array.from({ length: 10 }, () => createFakeTag());
mockGetTags.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
const tags = data.slice(offset, offset + count);
return {
tags,
count,
offset,
total: data.length,
};
});
const { result } = renderHook(() => useTagsList({ filter: '', limit }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatTagItem)));
await act(() => result.current.fetchNextPage());
await waitFor(() => expect(result.current.data).toEqual(data.map(formatTagItem)));
await act(() => result.current.fetchNextPage());
// should not fetch again since total was reached
expect(mockGetTags).toHaveBeenCalledTimes(2);
});

View File

@@ -0,0 +1,59 @@
import type { ILivechatTag, Serialized } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
type TagsListOptions = {
filter: string;
department?: string;
viewAll?: boolean;
limit?: number;
};
export type TagListItem = {
_id: string;
label: string;
value: string;
};
const DEFAULT_QUERY_LIMIT = 25;
export const useTagsList = (options: TagsListOptions) => {
const { viewAll, department, filter, limit = DEFAULT_QUERY_LIMIT } = options;
const getTags = useEndpoint('GET', '/v1/livechat/tags');
const formatTagItem = (tag: Serialized<ILivechatTag>): TagListItem => ({
_id: tag._id,
label: tag.name,
value: tag.name,
});
return useInfiniteQuery({
queryKey: ['/v1/livechat/tags', options],
queryFn: async ({ pageParam: offset = 0 }) => {
const { tags, ...data } = await getTags({
text: filter,
offset,
count: limit,
...(viewAll && { viewAll: 'true' }),
...(department && { department }),
sort: JSON.stringify({ name: 1 }),
});
return {
...data,
tags: tags.map(formatTagItem),
};
},
select: (data) => data.pages.flatMap<TagListItem>((page) => page.tags),
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
initialData: () => ({
pages: [{ tags: [], offset: 0, count: 0, total: Infinity }],
pageParams: [0],
}),
});
};

View File

@@ -0,0 +1,50 @@
import type { IOmnichannelBusinessUnit, Serialized } from '@rocket.chat/core-typings';
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useUnitsList } from './useUnitsList';
import { createFakeBusinessUnit } from '../../../../tests/mocks/data';
const formatUnitItem = (u: Serialized<IOmnichannelBusinessUnit>) => ({
_id: u._id,
label: u.name,
value: u._id,
});
const mockGetUnits = jest.fn();
const appRoot = new MockedAppRootBuilder().withEndpoint('GET', '/v1/livechat/units', mockGetUnits);
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch business units', async () => {
const limit = 5;
const data = Array.from({ length: 10 }, () => createFakeBusinessUnit());
mockGetUnits.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
const units = data.slice(offset, offset + count);
return {
units,
count,
offset,
total: data.length,
};
});
const { result } = renderHook(() => useUnitsList({ filter: '', limit }), { wrapper: appRoot.build() });
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatUnitItem)));
await act(() => result.current.fetchNextPage());
await waitFor(() => expect(result.current.data).toEqual(data.map(formatUnitItem)));
await act(() => result.current.fetchNextPage());
// should not fetch again since total was reached
expect(mockGetUnits).toHaveBeenCalledTimes(2);
});

View File

@@ -0,0 +1,68 @@
import type { IOmnichannelBusinessUnit, Serialized } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
type UnitsListOptions = {
filter: string;
haveNone?: boolean;
limit?: number;
};
export type UnitOption = {
_id: string;
value: string;
label: string;
};
const DEFAULT_QUERY_LIMIT = 25;
export const useUnitsList = (options: UnitsListOptions) => {
const { t } = useTranslation();
const { haveNone = false, filter, limit = DEFAULT_QUERY_LIMIT } = options;
const getUnits = useEndpoint('GET', '/v1/livechat/units');
const formatUnitItem = (u: Serialized<IOmnichannelBusinessUnit>): UnitOption => ({
_id: u._id,
label: u.name,
value: u._id,
});
return useInfiniteQuery({
queryKey: ['/v1/livechat/units', options],
queryFn: async ({ pageParam: offset = 0 }) => {
const { units, ...data } = await getUnits({
...(filter && { text: filter }),
offset,
count: limit,
sort: `{ "name": 1 }`,
});
return {
...data,
units: units.map(formatUnitItem),
};
},
select: (data) => {
const items = data.pages.flatMap<UnitOption>((page) => page.units);
haveNone &&
items.unshift({
_id: '',
label: t('None'),
value: '',
});
return items;
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
initialData: () => ({
pages: [{ units: [], offset: 0, count: 0, total: Infinity }],
pageParams: [0],
}),
});
};