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,183 @@
import type { IOmnichannelRoom, IVisitor } from '@rocket.chat/core-typings';
import { Box, Margins, Tag, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { ContextualbarScrollableContent, ContextualbarFooter, InfoPanelField, InfoPanelLabel, InfoPanelText } from '@rocket.chat/ui-client';
import type { IRouterPaths } from '@rocket.chat/ui-contexts';
import { useToastMessageDispatch, useRoute, useUserSubscription, useTranslation, usePermission, useUserId } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import DepartmentField from './DepartmentField';
import VisitorClientInfo from './VisitorClientInfo';
import MarkdownText from '../../../../../components/MarkdownText';
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
import { useFormatDuration } from '../../../../../hooks/useFormatDuration';
import { useFormattedRelativeTime } from '../../../../../hooks/useFormattedRelativeTime';
import CustomField from '../../../components/CustomField';
import { useValidCustomFields } from '../../../contactInfo/hooks/useValidCustomFields';
import { AgentField, SlaField, ContactField, SourceField } from '../../components';
import PriorityField from '../../components/PriorityField';
import { useOmnichannelRoomInfo } from '../../hooks/useOmnichannelRoomInfo';
import { formatQueuedAt } from '../../utils/formatQueuedAt';
export type ChatInfoProps = {
id: string;
route: keyof IRouterPaths;
};
function ChatInfo({ id, route }: ChatInfoProps) {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const formatDateAndTime = useFormatDateAndTime();
const formatDuration = useFormatDuration();
const { data: room } = useOmnichannelRoomInfo(id); // FIXME: `room` is serialized, but we need to deserialize it
const {
_id: roomId,
ts,
tags,
closedAt,
departmentId,
v,
servedBy,
metrics,
topic,
waitingResponse,
responseBy,
slaId,
priorityId,
livechatData,
source,
queuedAt,
} = room ?? {};
const inactivityTime = useFormattedRelativeTime(
responseBy?.lastMessageTs ? Date.now() - new Date(responseBy.lastMessageTs).getTime() : 0,
);
const routePath = useRoute(route || 'omnichannel-directory');
const subscription = useUserSubscription(id);
const hasGlobalEditRoomPermission = usePermission('save-others-livechat-room-info');
const hasLocalEditRoomPermission = servedBy?._id === useUserId();
const visitorId = v?._id;
const queueStartedAt = queuedAt || ts;
const queueTime = useMemo(() => formatQueuedAt(room), [room]);
const customFieldEntries = useValidCustomFields(livechatData, 'room');
const onEditClick = useStableCallback(() => {
const hasEditAccess = !!subscription || hasLocalEditRoomPermission || hasGlobalEditRoomPermission;
if (!hasEditAccess) {
return dispatchToastMessage({ type: 'error', message: t('Not_authorized') });
}
routePath.push(
route
? {
tab: 'room-info',
context: 'edit',
id,
}
: {
page: 'chats',
id,
bar: 'edit',
},
);
});
return (
<>
<ContextualbarScrollableContent padding={24}>
<Margins block='x4'>
{source && <SourceField room={room as unknown as IOmnichannelRoom} />}
{room && v && <ContactField contact={v as IVisitor} room={room as unknown as IOmnichannelRoom} />}
{visitorId && <VisitorClientInfo uid={visitorId} />}
{servedBy && <AgentField agent={servedBy as unknown as IOmnichannelRoom['servedBy']} />}
{departmentId && <DepartmentField departmentId={departmentId} />}
{tags && tags.length > 0 && (
<InfoPanelField>
<InfoPanelLabel id={`${roomId}-tags`}>{t('Tags')}</InfoPanelLabel>
<InfoPanelText>
<ul aria-labelledby={`${roomId}-tags`}>
{tags.map((tag) => (
<Box is='li' key={tag} marginInlineEnd={4} display='inline'>
<Tag style={{ display: 'inline' }} disabled>
{tag}
</Tag>
</Box>
))}
</ul>
</InfoPanelText>
</InfoPanelField>
)}
{topic && (
<InfoPanelField>
<InfoPanelLabel>{t('Topic')}</InfoPanelLabel>
<InfoPanelText withTruncatedText={false}>
<MarkdownText variant='inline' content={topic} />
</InfoPanelText>
</InfoPanelField>
)}
{queueStartedAt && (
<InfoPanelField>
<InfoPanelLabel>{t('Queue_Time')}</InfoPanelLabel>
<InfoPanelText>{queueTime}</InfoPanelText>
</InfoPanelField>
)}
{closedAt && ts && (
<InfoPanelField>
<InfoPanelLabel>{t('Chat_Duration')}</InfoPanelLabel>
<InfoPanelText>{formatDuration((new Date(closedAt).getTime() - new Date(ts).getTime()) / 1000)}</InfoPanelText>
</InfoPanelField>
)}
{ts && (
<InfoPanelField>
<InfoPanelLabel>{t('Created_at')}</InfoPanelLabel>
<InfoPanelText>{formatDateAndTime(ts)}</InfoPanelText>
</InfoPanelField>
)}
{closedAt && (
<InfoPanelField>
<InfoPanelLabel>{t('Closed_At')}</InfoPanelLabel>
<InfoPanelText>{formatDateAndTime(closedAt)}</InfoPanelText>
</InfoPanelField>
)}
{servedBy?.ts && (
<InfoPanelField>
<InfoPanelLabel>{t('Taken_at')}</InfoPanelLabel>
<InfoPanelText>{formatDateAndTime(servedBy.ts)}</InfoPanelText>
</InfoPanelField>
)}
{metrics?.response?.avg && formatDuration(metrics.response.avg) && (
<InfoPanelField>
<InfoPanelLabel>{t('Avg_response_time')}</InfoPanelLabel>
<InfoPanelText>{formatDuration(metrics.response.avg)}</InfoPanelText>
</InfoPanelField>
)}
{!waitingResponse && responseBy?.lastMessageTs && (
<InfoPanelField>
<InfoPanelLabel>{t('Inactivity_Time')}</InfoPanelLabel>
<InfoPanelText>{inactivityTime}</InfoPanelText>
</InfoPanelField>
)}
{customFieldEntries?.length > 0 &&
customFieldEntries.map(([key, value]) => <CustomField key={key} id={key} value={value as string} />)}
{slaId && <SlaField id={slaId} />}
{priorityId && <PriorityField id={priorityId} />}
</Margins>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button icon='pencil' onClick={onEditClick}>
{t('Edit')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
}
export default ChatInfo;

View File

@@ -0,0 +1,53 @@
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useRoute, useRouteParameter, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ChatInfo from './ChatInfo';
import RoomEdit from './RoomEdit';
import { useRoom } from '../../../../room/contexts/RoomContext';
const PATH = 'live';
const HEADER_DATA = {
info: { icon: 'info-circled', title: 'Room_Info' },
edit: { icon: 'pencil', title: 'edit-room' },
} as const;
const ChatsContextualBar = () => {
const { t } = useTranslation();
const context = useRouteParameter('context') as 'edit' | 'info' | undefined;
const directoryRoute = useRoute(PATH);
const room = useRoom();
const { closeTab } = useRoomToolbox();
const handleRoomEditBarCloseButtonClick = () => {
directoryRoute.push({ id: room._id, tab: 'room-info' });
};
const { icon, title } = useMemo(() => HEADER_DATA[context ?? 'info'] || HEADER_DATA.info, [context]);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name={icon} />
<ContextualbarTitle>{t(title)}</ContextualbarTitle>
<ContextualbarClose onClick={closeTab} />
</ContextualbarHeader>
{context === 'edit' ? (
<RoomEdit id={room._id} onClose={handleRoomEditBarCloseButtonClick} />
) : (
<ChatInfo route={PATH} id={room._id} />
)}
</ContextualbarDialog>
);
};
export default ChatsContextualBar;

View File

@@ -0,0 +1,27 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import Field from '../../../components/Field';
import Info from '../../../components/Info';
import Label from '../../../components/Label';
import { useDepartmentInfo } from '../../hooks/useDepartmentInfo';
export type DepartmentFieldProps = {
departmentId: string;
};
const DepartmentField = ({ departmentId }: DepartmentFieldProps) => {
const { t } = useTranslation();
const { data, isLoading, isError } = useDepartmentInfo(departmentId);
return (
<Field>
<Label>{t('Department')}</Label>
{isLoading && <Skeleton />}
{isError && <Box color='danger'>{t('Something_went_wrong')}</Box>}
{!isLoading && !isError && <Info>{data?.department?.name || t('Department_not_found')}</Info>}
</Field>
);
};
export default DepartmentField;

View File

@@ -0,0 +1,178 @@
import type { ILivechatVisitor, IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings';
import { Field, FieldLabel, FieldRow, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { CustomFieldsForm, ContextualbarContent, ContextualbarFooter, ContextualbarScrollableContent } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useId } from 'react';
import { useController, useForm } from 'react-hook-form';
import { hasAtLeastOnePermission } from '../../../../../../lib/authorization';
import { roomsQueryKeys } from '../../../../../../lib/queryKeys';
import { SlaPoliciesSelect, PrioritiesSelect } from '../../../../additionalForms';
import Tags from '../../../../components/Tags';
import { useOmnichannelPriorities } from '../../../../hooks/useOmnichannelPriorities';
import { FormSkeleton } from '../../../components/FormSkeleton';
import { useCustomFieldsMetadata } from '../../../hooks/useCustomFieldsMetadata';
import { useSlaPolicies } from '../../../hooks/useSlaPolicies';
type RoomEditFormData = {
topic: string;
tags: string[];
livechatData: any;
slaId: string;
priorityId: string;
};
export type RoomEditProps = {
room: Serialized<IOmnichannelRoom>;
visitor: Serialized<ILivechatVisitor>;
reload?: () => void;
reloadInfo?: () => void;
onClose: () => void;
};
const ROOM_INTIAL_VALUE = {
topic: '',
tags: [],
livechatData: {},
slaId: '',
};
const getInitialValuesRoom = (room: Serialized<IOmnichannelRoom>): RoomEditFormData => {
const { topic, tags, livechatData, slaId, priorityId } = room ?? ROOM_INTIAL_VALUE;
return {
topic: topic ?? '',
tags: tags ?? [],
livechatData: livechatData ?? {},
slaId: slaId ?? '',
priorityId: priorityId ?? '',
};
};
function RoomEdit({ room, visitor, reload, reloadInfo, onClose }: RoomEditProps) {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const canViewCustomFields = hasAtLeastOnePermission(['view-livechat-room-customfields', 'edit-livechat-room-customfields']);
const saveRoom = useEndpoint('POST', '/v1/livechat/room.saveInfo');
const { data: slaPolicies, isLoading: isSlaPoliciesLoading } = useSlaPolicies();
const { data: customFieldsMetadata, isLoading: isCustomFieldsLoading } = useCustomFieldsMetadata({
scope: 'room',
enabled: canViewCustomFields,
});
const { data: priorities, isLoading: isPrioritiesLoading } = useOmnichannelPriorities();
const {
register,
control,
formState: { isDirty: isFormDirty, isValid: isFormValid, isSubmitting },
handleSubmit,
} = useForm<RoomEditFormData>({
mode: 'onChange',
defaultValues: getInitialValuesRoom(room),
});
const { field: tagsField } = useController({ control, name: 'tags' });
const { field: slaIdField } = useController({ control, name: 'slaId' });
const { field: priorityIdField } = useController({ control, name: 'priorityId' });
const handleSave = useCallback(
async (data: RoomEditFormData) => {
if (!isFormValid) {
return;
}
const { topic, tags, livechatData, slaId, priorityId } = data;
const guestData = {
_id: visitor._id,
};
const roomData = {
_id: room._id,
topic,
tags: tags.sort(),
livechatData,
priorityId,
...(slaId && { slaId }),
};
try {
await saveRoom({ guestData, roomData });
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.info(room._id) });
dispatchToastMessage({ type: 'success', message: t('Saved') });
reload?.();
reloadInfo?.();
onClose();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[dispatchToastMessage, isFormValid, onClose, queryClient, reload, reloadInfo, room._id, saveRoom, t, visitor._id],
);
const topicField = useId();
// TODO: this loading should be checked in the `RoomEditWithData`
// This component should not have logical data
if (isCustomFieldsLoading || isSlaPoliciesLoading || isPrioritiesLoading) {
return (
<ContextualbarContent>
<FormSkeleton />
</ContextualbarContent>
);
}
return (
<>
<ContextualbarScrollableContent is='form' onSubmit={handleSubmit(handleSave)}>
{canViewCustomFields && customFieldsMetadata && (
<CustomFieldsForm formName='livechatData' formControl={control} metadata={customFieldsMetadata} />
)}
<Field>
<FieldLabel htmlFor={topicField}>{t('Topic')}</FieldLabel>
<FieldRow>
<TextInput {...register('topic')} id={topicField} flexGrow={1} />
</FieldRow>
</Field>
<Field>
<Tags tags={tagsField.value} handler={tagsField.onChange} department={room.departmentId} />
</Field>
{SlaPoliciesSelect && !!slaPolicies?.length && (
<SlaPoliciesSelect label={t('SLA_Policy')} value={slaIdField.value} options={slaPolicies} onChange={slaIdField.onChange} />
)}
{PrioritiesSelect && !!priorities?.length && (
<PrioritiesSelect label={t('Priority')} value={priorityIdField.value} options={priorities} onChange={priorityIdField.onChange} />
)}
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button flexGrow={1} onClick={onClose}>
{t('Cancel')}
</Button>
<Button
marginInlineEnd='none'
flexGrow={1}
onClick={handleSubmit(handleSave)}
loading={isSubmitting}
disabled={!isFormValid || !isFormDirty}
primary
>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
}
export default RoomEdit;

View File

@@ -0,0 +1,39 @@
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import RoomEdit from './RoomEdit';
import { FormSkeleton } from '../../../components';
import { useOmnichannelRoomInfo } from '../../../hooks/useOmnichannelRoomInfo';
import { useVisitorInfo } from '../../../hooks/useVisitorInfo';
export type RoomEditWithDataProps = {
id: string;
reload?: () => void;
reloadInfo?: () => void;
onClose: () => void;
};
function RoomEditWithData({ id: roomId, reload, reloadInfo, onClose }: RoomEditWithDataProps) {
const { t } = useTranslation();
const { data: room, isLoading: isRoomLoading, isError: isRoomError } = useOmnichannelRoomInfo(roomId);
const { _id: visitorId } = room?.v ?? {};
const { data: visitor, isLoading: isVisitorLoading, isError: isVisitorError } = useVisitorInfo(visitorId!, { enabled: !!visitorId });
if (isRoomLoading || isVisitorLoading) {
return <FormSkeleton />;
}
if (isRoomError || !room) {
return <Box marginBlockStart={16}>{t('Room_not_found')}</Box>;
}
if (isVisitorError || !visitor) {
return <Box marginBlockStart={16}>{t('Visitor_not_found')}</Box>;
}
return <RoomEdit room={room} visitor={visitor} reload={reload} reloadInfo={reloadInfo} onClose={onClose} />;
}
export default RoomEditWithData;

View File

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

View File

@@ -0,0 +1,78 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import UAParser from 'ua-parser-js';
import { omnichannelQueryKeys } from '../../../../../lib/queryKeys';
import Field from '../../../components/Field';
import Info from '../../../components/Info';
import Label from '../../../components/Label';
import { FormSkeleton } from '../../components/FormSkeleton';
export type VisitorClientInfoProps = {
uid: string;
};
const VisitorClientInfo = ({ uid }: VisitorClientInfoProps) => {
const { t } = useTranslation();
const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info');
const {
isPending,
isError,
data: visitor,
} = useQuery({
queryKey: omnichannelQueryKeys.visitorInfo(uid),
queryFn: async () => {
const { visitor } = await getVisitorInfo({ visitorId: uid });
return visitor;
},
});
if (isPending) {
return <FormSkeleton />;
}
if (isError || !visitor.userAgent) {
return null;
}
const ua = new UAParser();
ua.setUA(visitor.userAgent);
const clientData = {
os: `${ua.getOS().name} ${ua.getOS().version}`,
browser: `${ua.getBrowser().name} ${ua.getBrowser().version}`,
host: visitor.host,
ip: visitor.ip,
};
return (
<>
{clientData.os && (
<Field>
<Label>{t('OS')}</Label>
<Info>{clientData.os}</Info>
</Field>
)}
{clientData.browser && (
<Field>
<Label>{t('Browser')}</Label>
<Info>{clientData.browser}</Info>
</Field>
)}
{clientData.host && (
<Field>
<Label>{t('Host')}</Label>
<Info>{clientData.host}</Info>
</Field>
)}
{clientData.ip && (
<Field>
<Label>{t('IP')}</Label>
<Info>{clientData.ip}</Info>
</Field>
)}
</>
);
};
export default VisitorClientInfo;

View File

@@ -0,0 +1,231 @@
import { Button, ButtonGroup, Field, FieldLabel, FieldRow, InputBox, Select, TextInput } from '@rocket.chat/fuselage';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarTitle,
ContextualbarClose,
ContextualbarScrollableContent,
ContextualbarFooter,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { usePermission } from '@rocket.chat/ui-contexts';
import { format } from 'date-fns';
import { useId } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
import { CurrentChatTags } from '../../additionalForms';
import AutoCompleteUnits from '../../additionalForms/AutoCompleteUnits';
import AutoCompleteDepartmentMultiple from '../../components/AutoCompleteDepartmentMultiple';
import AutoCompleteMultipleAgent from '../../components/AutoCompleteMultipleAgent';
import { useCustomFieldsQuery } from '../../hooks/useCustomFieldsQuery';
import type { ChatsFiltersQuery } from '../contexts/ChatsContext';
import { useChatsContext } from '../contexts/ChatsContext';
export type ChatsFiltersContextualBarProps = {
onClose: () => void;
};
const ChatsFiltersContextualBar = ({ onClose }: ChatsFiltersContextualBarProps) => {
const { t } = useTranslation();
const canViewLivechatRooms = usePermission('view-livechat-rooms');
const canViewCustomFields = usePermission('view-livechat-room-customfields');
const { data: isEnterprise = false } = useHasLicenseModule('livechat-enterprise');
const { data } = useCustomFieldsQuery();
const contactCustomFields = data?.customFields.filter((customField) => customField.scope !== 'visitor');
const { filtersQuery, setFiltersQuery, resetFiltersQuery, hasAppliedFilters } = useChatsContext();
const { handleSubmit, control, reset } = useForm<ChatsFiltersQuery>({
values: filtersQuery,
});
const statusOptions: [string, string][] = [
['all', t('All')],
['closed', t('Closed')],
['opened', t('Room_Status_Open')],
['onhold', t('On_Hold_Chats')],
['queued', t('Queued')],
];
const handleSubmitFilters = (data: ChatsFiltersQuery) => setFiltersQuery(({ guest }) => ({ ...data, guest }));
const handleResetFilters = () => {
resetFiltersQuery();
reset();
};
const formId = useId();
const fromFieldId = useId();
const toFieldId = useId();
const servedByFieldId = useId();
const statusFieldId = useId();
const departmentFieldId = useId();
const tagsFieldId = useId();
const unitsFieldId = useId();
return (
<ContextualbarDialog onClose={onClose}>
<ContextualbarHeader>
<ContextualbarIcon name='customize' />
<ContextualbarTitle>{t('Filters')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarScrollableContent is='form' id={formId} onSubmit={handleSubmit(handleSubmitFilters)}>
<Field>
<FieldLabel htmlFor={fromFieldId}>{t('From')}</FieldLabel>
<FieldRow>
<Controller
name='from'
control={control}
render={({ field }) => (
<InputBox type='date' id={fromFieldId} placeholder={t('From')} max={format(new Date(), 'yyyy-MM-dd')} {...field} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={toFieldId}>{t('To')}</FieldLabel>
<FieldRow>
<Controller
name='to'
control={control}
render={({ field }) => (
<InputBox type='date' id={toFieldId} placeholder={t('To')} max={format(new Date(), 'yyyy-MM-dd')} {...field} />
)}
/>
</FieldRow>
</Field>
{canViewLivechatRooms && (
<Field>
<FieldLabel is='span' id={servedByFieldId}>
{t('Served_By')}
</FieldLabel>
<FieldRow>
<Controller
name='servedBy'
control={control}
render={({ field: { value, onChange } }) => (
<AutoCompleteMultipleAgent aria-labelledby={servedByFieldId} value={value} onChange={onChange} />
)}
/>
</FieldRow>
</Field>
)}
<Field>
<FieldLabel is='span' id={statusFieldId}>
{t('Status')}
</FieldLabel>
<Controller
name='status'
control={control}
render={({ field }) => (
<Select {...field} aria-labelledby={statusFieldId} options={statusOptions} placeholder={t('Select_an_option')} />
)}
/>
</Field>
<Field>
<FieldLabel is='span' id={departmentFieldId}>
{t('Department')}
</FieldLabel>
<FieldRow>
<Controller
name='department'
control={control}
render={({ field: { value, onChange } }) => (
<AutoCompleteDepartmentMultiple
aria-labelledby={departmentFieldId}
showArchived
value={value}
onChange={onChange}
onlyMyDepartments
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel id={tagsFieldId}>{t('Tags')}</FieldLabel>
<FieldRow>
<Controller
name='tags'
control={control}
render={({ field: { value, onChange } }) => (
<CurrentChatTags aria-labelledby={tagsFieldId} value={value} handler={onChange} viewAll />
)}
/>
</FieldRow>
</Field>
{isEnterprise && (
<Field>
<FieldLabel is='span' id={unitsFieldId}>
{t('Units')}
</FieldLabel>
<FieldRow>
<Controller
name='units'
control={control}
render={({ field: { value, onChange } }) => (
<AutoCompleteUnits aria-labelledby={unitsFieldId} value={value} onChange={onChange} />
)}
/>
</FieldRow>
</Field>
)}
{canViewCustomFields &&
contactCustomFields?.map((customField) => {
if (customField.type === 'select') {
return (
<Field key={customField._id}>
<FieldLabel is='span' id={customField._id}>
{customField.label}
</FieldLabel>
<FieldRow>
<Controller
name={customField._id}
control={control}
render={({ field }) => (
<Select
{...field}
aria-labelledby={customField._id}
value={field.value as string}
options={(customField.options || '').split(',').map((item) => [item, item])}
/>
)}
/>
</FieldRow>
</Field>
);
}
return (
<Field key={customField._id}>
<FieldLabel htmlFor={customField._id}>{customField.label}</FieldLabel>
<FieldRow>
<Controller
name={customField._id}
control={control}
render={({ field }) => <TextInput {...field} id={customField._id} value={field.value as string} />}
/>
</FieldRow>
</Field>
);
})}
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button disabled={!hasAppliedFilters} onClick={handleResetFilters}>
{t('Clear_filters')}
</Button>
<Button type='submit' form={formId} primary>
{t('Apply')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default ChatsFiltersContextualBar;

View File

@@ -0,0 +1,16 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import ChatsTable from './ChatsTable';
import NotAuthorizedPage from '../../../notAuthorized/NotAuthorizedPage';
const ChatsTab = () => {
const hasAccess = usePermission('view-l-room');
if (hasAccess) {
return <ChatsTable />;
}
return <NotAuthorizedPage />;
};
export default ChatsTab;

View File

@@ -0,0 +1,125 @@
import { Pagination, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage';
import {
GenericTable,
GenericTableBody,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableLoadingTable,
usePagination,
useSort,
} from '@rocket.chat/ui-client';
import { usePermission } from '@rocket.chat/ui-contexts';
import { hashKey } from '@tanstack/react-query';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import ChatFilterByText from './ChatsTableFilter';
import ChatsTableRow from './ChatsTableRow';
import { useCurrentChats } from './hooks/useCurrentChats';
import { useChatsQuery } from './useChatsQuery';
import GenericNoResults from '../../../../../components/GenericNoResults/GenericNoResults';
import { links } from '../../../../../lib/links';
import { useOmnichannelPriorities } from '../../../hooks/useOmnichannelPriorities';
import { useChatsContext } from '../../contexts/ChatsContext';
const ChatsTable = () => {
const { t } = useTranslation();
const canRemoveClosedChats = usePermission('remove-closed-livechat-room');
const { filtersQuery: filters } = useChatsContext();
const { enabled: isPriorityEnabled } = useOmnichannelPriorities();
const chatsQuery = useChatsQuery();
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const { sortBy, sortDirection, setSort } = useSort<'fname' | 'ts'>('ts', 'desc');
const query = useMemo(
() => chatsQuery(filters, [sortBy, sortDirection], current, itemsPerPage),
[itemsPerPage, filters, sortBy, sortDirection, current, chatsQuery],
);
const { data, isLoading, isSuccess, isError, refetch } = useCurrentChats(query);
const [defaultQuery] = useState(hashKey([query]));
const queryHasChanged = defaultQuery !== hashKey([query]);
const headers = (
<>
<GenericTableHeaderCell key='fname' direction={sortDirection} active={sortBy === 'fname'} onClick={setSort} sort='fname'>
{t('Name')}
</GenericTableHeaderCell>
{isPriorityEnabled && (
<GenericTableHeaderCell key='priorityWeight' alignItems='center'>
{t('Priority')}
</GenericTableHeaderCell>
)}
<GenericTableHeaderCell key='source.type'>{t('Channel')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='servedBy'>{t('Agent')}</GenericTableHeaderCell>
<GenericTableHeaderCell width='x100'>{t('Verification')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='department.name'>{t('Department')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='ts' direction={sortDirection} active={sortBy === 'ts'} onClick={setSort} sort='ts'>
{t('Started_At')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='lm'>{t('Last_Message')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='status'>{t('Status')}</GenericTableHeaderCell>
{canRemoveClosedChats && <GenericTableHeaderCell key='remove' width='x60' />}
</>
);
return (
<>
<ChatFilterByText />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingTable headerCells={headers.props.children.filter(Boolean).length} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data?.rooms.length === 0 && queryHasChanged && <GenericNoResults />}
{isSuccess && data?.rooms.length === 0 && !queryHasChanged && (
<GenericNoResults
icon='message'
title={t('No_chats_yet')}
description={t('No_chats_yet_description')}
linkHref={links.go.omnichannelDocs}
linkText={t('Learn_more_about_conversations')}
/>
)}
{isSuccess && data?.rooms.length > 0 && (
<>
<GenericTable aria-label={t('Omnichannel_Contact_Center_Chats')} fixed={false}>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data?.rooms.map((room) => (
<ChatsTableRow key={room._id} {...room} />
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
)}
{isError && (
<States>
<StatesIcon name='warning' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions>
<StatesAction onClick={() => refetch()}>{t('Reload_page')}</StatesAction>
</StatesActions>
</States>
)}
</>
);
};
export default ChatsTable;

View File

@@ -0,0 +1,70 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ChatsTableFilter from './ChatsTableFilter';
import type { ChatsContextValue } from '../../contexts/ChatsContext';
jest.mock('../../contexts/ChatsContext', () => ({
useChatsContext: (): ChatsContextValue => ({
filtersQuery: {
guest: '',
servedBy: [],
status: 'all',
department: [],
from: '',
to: '',
tags: [],
units: [],
},
setFiltersQuery: jest.fn(),
resetFiltersQuery: jest.fn(),
displayFilters: {
from: undefined,
to: undefined,
guest: undefined,
servedBy: undefined,
department: undefined,
status: undefined,
tags: undefined,
},
removeFilter: jest.fn(),
hasAppliedFilters: false,
textInputRef: null,
}),
}));
jest.mock('../../hooks/useOmnichannelDirectoryRouter', () => ({
useOmnichannelDirectoryRouter: () => ({
navigate: jest.fn(),
getRouteName: jest.fn(),
}),
}));
describe('ChatsTableFilter', () => {
it('should show the "More" kebab menu when user has "remove-closed-livechat-rooms" permission', async () => {
render(<ChatsTableFilter />, {
wrapper: mockAppRoot().withPermission('remove-closed-livechat-rooms').build(),
});
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
});
it('should not show the "More" kebab menu when user lacks "remove-closed-livechat-rooms" permission', async () => {
render(<ChatsTableFilter />, {
wrapper: mockAppRoot().build(),
});
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
});
it('should show "Delete all closed chats" option inside the menu when user has the permission', async () => {
render(<ChatsTableFilter />, {
wrapper: mockAppRoot().withPermission('remove-closed-livechat-rooms').build(),
});
await userEvent.click(screen.getByRole('button', { name: 'More' }));
expect(await screen.findByRole('menuitem', { name: 'Delete_all_closed_chats' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import { Box, Button, Chip } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericMenu, GenericModal } from '@rocket.chat/ui-client';
import { useEndpoint, usePermission, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import type { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import FilterByText from '../../../../../components/FilterByText';
import { useChatsContext } from '../../contexts/ChatsContext';
import { useOmnichannelDirectoryRouter } from '../../hooks/useOmnichannelDirectoryRouter';
const ChatsTableFilter = () => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const queryClient = useQueryClient();
const removeClosedRooms = useEndpoint('POST', '/v1/livechat/rooms.removeAllClosedRooms');
const canRemoveAllClosedChats = usePermission('remove-closed-livechat-rooms');
const { filtersQuery, displayFilters, setFiltersQuery, removeFilter, textInputRef } = useChatsContext();
const handleRemoveAllClosed = useStableCallback(async () => {
const onDeleteAll = async () => {
try {
await removeClosedRooms();
queryClient.invalidateQueries({ queryKey: ['current-chats'] });
dispatchToastMessage({ type: 'success', message: t('Chat_removed') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(<GenericModal variant='danger' onConfirm={onDeleteAll} onCancel={() => setModal(null)} confirmText={t('Delete')} />);
});
const menuItems = canRemoveAllClosedChats
? [
{
items: [
{
id: 'delete-all-closed-chats',
variant: 'danger',
icon: 'trash',
content: t('Delete_all_closed_chats'),
onClick: handleRemoveAllClosed,
} as const,
],
},
]
: [];
return (
<>
<FilterByText
ref={textInputRef as RefObject<HTMLInputElement>}
value={filtersQuery.guest}
onChange={(event) => setFiltersQuery((prevState) => ({ ...prevState, guest: event.target.value }))}
>
<Button
onClick={() =>
omnichannelDirectoryRouter.navigate({
tab: 'chats',
context: 'filters',
})
}
icon='customize'
>
{t('Filters')}
</Button>
{menuItems.length > 0 && <GenericMenu placement='bottom-end' detached title={t('More')} sections={menuItems} />}
</FilterByText>
<Box display='flex' flexWrap='wrap' marginBlockEnd={4}>
{Object.entries(displayFilters).map(([value, label], index) => {
if (!label) {
return null;
}
return (
<Chip marginInlineEnd={8} marginBlockEnd={8} key={index} onClick={() => removeFilter(value)}>
{label}
</Chip>
);
})}
</Box>
</>
);
};
export default ChatsTableFilter;

View File

@@ -0,0 +1,125 @@
import type { IOmnichannelRoomWithDepartment } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Tag, Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericTableCell, GenericTableRow } from '@rocket.chat/ui-client';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import RemoveChatButton from './RemoveChatButton';
import { OmnichannelRoomIcon } from '../../../../../components/RoomIcon/OmnichannelRoomIcon';
import { useFormatDate } from '../../../../../hooks/useFormatDate';
import { useTimeFromNow } from '../../../../../hooks/useTimeFromNow';
import OmnichannelVerificationTag from '../../../components/OmnichannelVerificationTag';
import RoomActivityIcon from '../../../components/RoomActivityIcon';
import { useOmnichannelPriorities } from '../../../hooks/useOmnichannelPriorities';
import { useOmnichannelSource } from '../../../hooks/useOmnichannelSource';
import { PriorityIcon } from '../../../priorities/PriorityIcon';
import { useOmnichannelDirectoryRouter } from '../../hooks/useOmnichannelDirectoryRouter';
/**
* TODO: We should have a variant for aligning row cell instead of using custom CSS
**/
const customAlignTop = css`
td {
vertical-align: top;
}
`;
const ChatsTableRow = (room: IOmnichannelRoomWithDepartment) => {
const { t } = useTranslation();
const { _id, fname, tags, servedBy, ts, department, open, priorityWeight, lm, onHold, source, verified } = room;
const { enabled: isPriorityEnabled } = useOmnichannelPriorities();
const getTimeFromNow = useTimeFromNow(true);
const formatDate = useFormatDate();
const { getSourceLabel } = useOmnichannelSource();
const canRemoveClosedChats = usePermission('remove-closed-livechat-room');
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const getStatusText = (open = false, onHold = false): string => {
if (!open) {
return t('Closed');
}
if (open && !servedBy) {
return t('Queued');
}
return onHold ? t('On_Hold_Chats') : t('Room_Status_Open');
};
const onRowClick = useStableCallback((id: string) =>
omnichannelDirectoryRouter.navigate({
tab: 'chats',
context: 'info',
id,
}),
);
return (
<GenericTableRow className={customAlignTop} key={_id} tabIndex={0} onClick={() => onRowClick(_id)} action>
<GenericTableCell withTruncatedText>
<Box display='flex' flexDirection='column'>
<Box withTruncatedText fontScale='p2m' color='default'>
{fname}
</Box>
{tags && (
<Box color='hint' display='flex' flex-direction='row'>
{tags.map((tag: string) => (
<Box marginBlockStart={4} marginInlineEnd={4} withTruncatedText overflow={tag.length > 10 ? 'hidden' : 'visible'} key={tag}>
<Tag style={{ display: 'inline' }} disabled>
{tag}
</Tag>
</Box>
))}
</Box>
)}
</Box>
</GenericTableCell>
{isPriorityEnabled && (
<GenericTableCell>
<PriorityIcon level={priorityWeight} />
</GenericTableCell>
)}
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
<OmnichannelRoomIcon size='x20' source={source} />
<Box marginInlineStart={8}>{getSourceLabel(source)}</Box>
</Box>
</GenericTableCell>
<GenericTableCell withTruncatedText>{servedBy?.username}</GenericTableCell>
<GenericTableCell>
<Box display='flex'>
<OmnichannelVerificationTag verified={verified} />
</Box>
</GenericTableCell>
<GenericTableCell withTruncatedText>{department?.name}</GenericTableCell>
<GenericTableCell withTruncatedText>
<Box display='flex' flexDirection='column'>
<Box fontScale='p2m' withTruncatedText color='default'>
{formatDate(ts)}
</Box>
<Box withTruncatedText>{getTimeFromNow(ts)}</Box>
</Box>
</GenericTableCell>
<GenericTableCell withTruncatedText>
{lm && (
<Box display='flex' flexDirection='column'>
<Box fontScale='p2m' withTruncatedText color='default'>
{formatDate(lm)}
</Box>
<Box withTruncatedText>{getTimeFromNow(lm)}</Box>
</Box>
)}
</GenericTableCell>
<GenericTableCell withTruncatedText>
<RoomActivityIcon room={room} />
{getStatusText(open, onHold)}
</GenericTableCell>
{canRemoveClosedChats && <GenericTableCell>{!open && <RemoveChatButton _id={_id} />}</GenericTableCell>}
</GenericTableRow>
);
};
export default ChatsTableRow;

View File

@@ -0,0 +1,40 @@
import { IconButton } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useRemoveCurrentChatMutation } from './hooks/useRemoveCurrentChatMutation';
export type RemoveChatButtonProps = { _id: string };
const RemoveChatButton = ({ _id }: RemoveChatButtonProps) => {
const removeCurrentChatMutation = useRemoveCurrentChatMutation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const handleRemoveClick = useStableCallback(async () => {
removeCurrentChatMutation.mutate(_id);
});
const handleDelete = useStableCallback((e: MouseEvent) => {
e.stopPropagation();
const onDeleteAgent = async (): Promise<void> => {
try {
await handleRemoveClick();
dispatchToastMessage({ type: 'success', message: t('Chat_removed') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
setModal(null);
};
setModal(<GenericModal variant='danger' onConfirm={onDeleteAgent} onCancel={() => setModal(null)} confirmText={t('Delete')} />);
});
return <IconButton danger small icon='trash' title={t('Remove')} disabled={removeCurrentChatMutation.isPending} onClick={handleDelete} />;
};
export default RemoveChatButton;

View File

@@ -0,0 +1,21 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { GETLivechatRoomsParams, 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';
export const useCurrentChats = (query: GETLivechatRoomsParams): UseQueryResult<OperationResult<'GET', '/v1/livechat/rooms'>> => {
const currentChats = useEndpoint('GET', '/v1/livechat/rooms');
const debouncedQuery = useDebouncedValue(query, 500);
return useQuery({
queryKey: ['current-chats', debouncedQuery],
queryFn: () => currentChats(debouncedQuery),
// TODO: Update this to use an stream of room changes instead of polling
refetchOnWindowFocus: false,
gcTime: 0,
});
};

View File

@@ -0,0 +1,23 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import { useQueryClient, useMutation } from '@tanstack/react-query';
export const useRemoveCurrentChatMutation = (
options?: Omit<UseMutationOptions<null, Error, IRoom['_id']>, 'mutationFn'>,
): UseMutationResult<null, Error, IRoom['_id']> => {
const removeRoom = useEndpoint('POST', '/v1/livechat/rooms.delete');
const queryClient = useQueryClient();
return useMutation({
mutationFn: (rid) => removeRoom({ roomId: rid }),
...options,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: ['current-chats'],
});
options?.onSuccess?.(...args);
},
});
};

View File

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

View File

@@ -0,0 +1,97 @@
import { usePermission, useUserId } from '@rocket.chat/ui-contexts';
import { parse, endOfDay, startOfDay } from 'date-fns';
import { useCallback } from 'react';
import type { ChatsFiltersQuery } from '../../contexts/ChatsContext';
type CurrentChatQuery = {
agents?: string[];
offset?: number;
roomName?: string;
departmentId?: string[];
open?: boolean;
createdAt?: string;
closedAt?: string;
tags?: string[];
onhold?: boolean;
customFields?: string;
sort: string;
count?: number;
queued?: boolean;
units?: string[];
};
const sortDir = (sortDir: 'asc' | 'desc'): 1 | -1 => (sortDir === 'asc' ? 1 : -1);
export const useChatsQuery = () => {
const userIdLoggedIn = useUserId();
const canViewLivechatRooms = usePermission('view-livechat-rooms');
return useCallback(
(
{ guest, servedBy, department, status, from, to, tags, units, ...customFields }: ChatsFiltersQuery,
[column, direction]: [string, 'asc' | 'desc'],
current: number,
itemsPerPage: 25 | 50 | 100,
) => {
const query: CurrentChatQuery = {
...(guest && { roomName: guest }),
sort: JSON.stringify({
[column]: sortDir(direction),
ts: column === 'ts' ? sortDir(direction) : undefined,
}),
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
};
if (from || to) {
query.createdAt = JSON.stringify({
...(from && {
start: startOfDay(parse(from, 'yyyy-MM-dd', new Date())).toISOString(),
}),
...(to && {
end: endOfDay(parse(to, 'yyyy-MM-dd', new Date())).toISOString(),
}),
});
}
if (status !== 'all') {
query.open = status === 'opened' || status === 'onhold' || status === 'queued';
query.onhold = status === 'onhold';
query.queued = status === 'queued';
}
if (!canViewLivechatRooms) {
query.agents = userIdLoggedIn ? [userIdLoggedIn] : [];
}
if (canViewLivechatRooms && servedBy?.length) {
query.agents = servedBy.map((s) => s.value as string);
}
if (department?.length) {
query.departmentId = department.map((d) => d.value as string);
}
if (tags && tags.length > 0) {
query.tags = tags.map((tag) => tag.value);
}
if (units?.length) {
query.units = units.map((u) => u.value as string);
}
if (customFields && Object.keys(customFields).length > 0) {
const customFieldsQuery = Object.fromEntries(
Object.entries(customFields).filter((item) => item[1] !== undefined && item[1] !== ''),
);
if (Object.keys(customFieldsQuery).length > 0) {
query.customFields = JSON.stringify(customFieldsQuery);
}
}
return query;
},
[canViewLivechatRooms, userIdLoggedIn],
);
};