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,27 @@
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import ChatsFiltersContextualBar from './chats/ChatsFiltersContextualBar';
import { useOmnichannelDirectoryRouter } from './hooks/useOmnichannelDirectoryRouter';
import ContactHistoryMessagesList from '../contactHistory/MessageList/ContactHistoryMessagesList';
const ChatsContextualBar = () => {
const router = useRouter();
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const handleOpenRoom = () => id && router.navigate(`/live/${id}`);
const handleClose = () => omnichannelDirectoryRouter.navigate({ tab: 'chats' });
if (context === 'filters') {
return <ChatsFiltersContextualBar onClose={handleClose} />;
}
if (context === 'info' && id) {
return <ContactHistoryMessagesList chatId={id} onClose={handleClose} onOpenRoom={handleOpenRoom} />;
}
return null;
};
export default ChatsContextualBar;

View File

@@ -0,0 +1,39 @@
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import ContactInfo from '../contactInfo/ContactInfo';
import ContactInfoError from '../contactInfo/ContactInfoError';
import EditContactInfo from '../contactInfo/EditContactInfo';
import EditContactInfoWithData from '../contactInfo/EditContactInfoWithData';
import { useOmnichannelDirectoryRouter } from './hooks/useOmnichannelDirectoryRouter';
const ContactContextualBar = () => {
const contactId = useRouteParameter('id');
const context = useRouteParameter('context');
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const handleClose = () => omnichannelDirectoryRouter.navigate({ tab: 'contacts' });
const handleCancel = () => {
if (!contactId) {
return;
}
return omnichannelDirectoryRouter.navigate({ tab: 'contacts', context: 'details', id: contactId });
};
if (context === 'edit' && contactId) {
return <EditContactInfoWithData id={contactId} onClose={handleClose} onCancel={handleCancel} />;
}
if (context === 'new' && !contactId) {
return <EditContactInfo onClose={handleClose} onCancel={handleClose} />;
}
if (!contactId) {
return <ContactInfoError onClose={handleClose} />;
}
return <ContactInfo id={contactId} onClose={handleClose} />;
};
export default ContactContextualBar;

View File

@@ -0,0 +1,19 @@
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import ChatsContextualBar from './ChatsContextualBar';
import ContactContextualBar from './ContactContextualBar';
const ContextualBarRouter = () => {
const tab = useRouteParameter('tab');
switch (tab) {
case 'contacts':
return <ContactContextualBar />;
case 'chats':
return <ChatsContextualBar />;
default:
return null;
}
};
export default ContextualBarRouter;

View File

@@ -0,0 +1,63 @@
import { Box, Callout, Tabs, TabsItem } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import ContextualBarRouter from './ContextualBarRouter';
import ChatsTab from './chats/ChatsTab';
import ContactTab from './contacts/ContactTab';
import { useOmnichannelDirectoryRouter } from './hooks/useOmnichannelDirectoryRouter';
import ChatsProvider from './providers/ChatsProvider';
import { useIsOverMacLimit } from '../hooks/useIsOverMacLimit';
const OmnichannelDirectoryPage = () => {
const { t } = useTranslation();
const tab = useRouteParameter('tab');
const context = useRouteParameter('context');
const isWorkspaceOverMacLimit = useIsOverMacLimit();
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
useEffect(() => {
if (!omnichannelDirectoryRouter.getRouteName() || (omnichannelDirectoryRouter.getRouteName() && !!tab)) {
return;
}
omnichannelDirectoryRouter.navigate({ tab: 'chats' });
}, [omnichannelDirectoryRouter, tab]);
const handleTabClick = useCallback((tab: string) => omnichannelDirectoryRouter.navigate({ tab }), [omnichannelDirectoryRouter]);
return (
<ChatsProvider>
<Page flexDirection='row'>
<Page>
<PageHeader title={t('Omnichannel_Contact_Center')} />
<Tabs flexShrink={0}>
<TabsItem selected={tab === 'chats'} onClick={() => handleTabClick('chats')}>
{t('Chats')}
</TabsItem>
<TabsItem selected={tab === 'contacts'} onClick={() => handleTabClick('contacts')}>
{t('Contacts')}
</TabsItem>
</Tabs>
<PageContent>
{isWorkspaceOverMacLimit && (
<Box marginBlockStart={16}>
<Callout type='danger' icon='warning' title={t('The_workspace_has_exceeded_the_monthly_limit_of_active_contacts')}>
{t('Talk_to_your_workspace_admin_to_address_this_issue')}
</Callout>
</Box>
)}
{tab === 'chats' && <ChatsTab />}
{tab === 'contacts' && <ContactTab />}
</PageContent>
</Page>
{context && <ContextualBarRouter />}
</Page>
</ChatsProvider>
);
};
export default OmnichannelDirectoryPage;

View File

@@ -0,0 +1,16 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import OmnichannelDirectoryPage from './OmnichannelDirectoryPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const OmnichannelDirectoryRouter = () => {
const canViewDirectory = usePermission('view-omnichannel-contact-center');
if (!canViewDirectory) {
return <NotAuthorizedPage />;
}
return <OmnichannelDirectoryPage />;
};
export default OmnichannelDirectoryRouter;

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

View File

@@ -0,0 +1,54 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { FormSkeleton } from './FormSkeleton';
import { UserStatus } from '../../../../components/UserStatus';
import AgentInfoDetails from '../../components/AgentInfoDetails';
import Field from '../../components/Field';
import Info from '../../components/Info';
import Label from '../../components/Label';
export type AgentFieldProps = {
agent: IOmnichannelRoom['servedBy'];
isSmall?: boolean;
};
const AgentField = ({ agent, isSmall = false }: AgentFieldProps) => {
const { t } = useTranslation();
const { username = '' } = agent ?? {};
const getUserInfo = useEndpoint('GET', '/v1/users.info');
const { data, isLoading } = useQuery({
queryKey: ['/v1/users.info', username],
queryFn: () => getUserInfo({ username }),
});
if (isLoading) {
return <FormSkeleton />;
}
const {
user: { name, status },
} = data ?? { user: {} };
const displayName = name || username;
return (
<Field>
<Label>{t('Agent')}</Label>
<Info style={{ display: 'flex' }}>
<UserAvatar size={isSmall ? 'x28' : 'x40'} title={username} username={username || ''} />
<AgentInfoDetails
marginInlineStart={isSmall ? 'x8' : 'x10'}
name={displayName}
shortName={username}
status={<UserStatus status={status} />}
/>
</Info>
</Field>
);
};
export default AgentField;

View File

@@ -0,0 +1,59 @@
import type { IOmnichannelGenericRoom, IVisitor } from '@rocket.chat/core-typings';
import { Avatar, Box } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { FormSkeleton } from './FormSkeleton';
import { UserStatus } from '../../../../components/UserStatus';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import AgentInfoDetails from '../../components/AgentInfoDetails';
import Field from '../../components/Field';
import Info from '../../components/Info';
import Label from '../../components/Label';
export type ContactFieldProps = {
contact: IVisitor;
room: IOmnichannelGenericRoom;
};
const ContactField = ({ contact, room }: ContactFieldProps) => {
const { t } = useTranslation();
const { status } = contact;
const { fname, t: type } = room;
const avatarUrl = roomCoordinator.getRoomDirectives(type).getAvatarPath(room) || '';
const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info');
const { data, isPending, isError } = useQuery({
queryKey: ['/v1/livechat/visitors.info', contact._id],
queryFn: () => getVisitorInfo({ visitorId: contact._id }),
});
if (isPending) {
return <FormSkeleton />;
}
if (isError || !data?.visitor) {
return <Box marginBlockStart={16}>{t('Contact_not_found')}</Box>;
}
const {
visitor: { username, name, phone },
} = data;
const displayName = name || username;
const phoneNumber = phone?.[0]?.phoneNumber;
const contactIdentifier = [...new Set([username, phoneNumber])].filter(Boolean).join(' · ');
return (
<Field>
<Label>{t('Contact')}</Label>
<Info style={{ display: 'flex' }}>
<Avatar size='x40' title={fname} url={avatarUrl} />
<AgentInfoDetails marginInlineStart={10} name={displayName} shortName={contactIdentifier} status={<UserStatus status={status} />} />
</Info>
</Field>
);
};
export default ContactField;

View File

@@ -0,0 +1,15 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type FormSkeletonProps = ComponentPropsWithoutRef<typeof Box>;
export const FormSkeleton = (props: FormSkeletonProps) => (
<Box width='full' paddingBlock={24} {...props}>
<Skeleton marginBlockEnd={8} />
<Skeleton marginBlockEnd={4} />
<Skeleton marginBlockEnd={4} />
<Skeleton marginBlockEnd={8} />
<Skeleton marginBlockEnd={4} />
<Skeleton marginBlockEnd={8} />
</Box>
);

View File

@@ -0,0 +1,35 @@
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { FormSkeleton } from './FormSkeleton';
import Field from '../../components/Field';
import Info from '../../components/Info';
import Label from '../../components/Label';
import { usePriorityInfo } from '../hooks/usePriorityInfo';
export type PriorityFieldProps = {
id: string;
};
const PriorityField = ({ id }: PriorityFieldProps) => {
const { t } = useTranslation();
const { data, isLoading, isError } = usePriorityInfo(id);
if (isLoading) {
return <FormSkeleton />;
}
if (isError || !data) {
return <Box marginBlockStart={16}>{t('Custom_Field_Not_Found')}</Box>;
}
const { dirty, name, i18n } = data;
return (
<Field>
<Label>{t('Priority')}</Label>
<Info>{dirty ? name : t(i18n)}</Info>
</Field>
);
};
export default PriorityField;

View File

@@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { FormSkeleton } from './FormSkeleton';
import Field from '../../components/Field';
import Info from '../../components/Info';
import Label from '../../components/Label';
import { useSlaInfo } from '../hooks/useSlaInfo';
export type SlaFieldProps = {
id: string;
};
const SlaField = ({ id }: SlaFieldProps) => {
const { t } = useTranslation();
const { data, isLoading, isError } = useSlaInfo(id);
const slaFieldId = useId();
if (isLoading) {
return <FormSkeleton />;
}
if (isError || !data) {
return <Box marginBlockStart={16}>{t('Custom_Field_Not_Found')}</Box>;
}
const { name } = data;
return (
<Field>
<Label id={slaFieldId}>{t('SLA_Policy')}</Label>
<Info aria-labelledby={slaFieldId}>{name}</Info>
</Field>
);
};
export default SlaField;

View File

@@ -0,0 +1,51 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { OmnichannelRoomIcon } from '../../../../components/RoomIcon/OmnichannelRoomIcon';
import Field from '../../components/Field';
import Info from '../../components/Info';
import Label from '../../components/Label';
import { useOmnichannelSource } from '../../hooks/useOmnichannelSource';
export type SourceFieldProps = {
room: IOmnichannelRoom;
};
const SourceField = ({ room }: SourceFieldProps) => {
const { t } = useTranslation();
const { getSourceName } = useOmnichannelSource();
const defaultTypesVisitorData: {
widget: string | undefined;
email: string | undefined;
sms: string;
app: string;
api: string;
other: string;
} = {
widget: '',
email: room?.source.id,
sms: t('External'),
app: room.source.label || t('External'),
api: room.source.label || t('External'),
other: t('External'),
};
return (
<Field>
<Label>{t('Channel')}</Label>
<Info>
<Box display='flex' alignItems='center'>
<OmnichannelRoomIcon source={room.source} status={room.v.status} size='x24' />
<Label marginInline={8} marginBlockEnd='0'>
{getSourceName(room.source)}
</Label>
{defaultTypesVisitorData[room.source.type]}
</Box>
</Info>
</Field>
);
};
export default SourceField;

View File

@@ -0,0 +1,5 @@
export { default as AgentField } from './AgentField';
export { default as SlaField } from './SlaField';
export { default as SourceField } from './SourceField';
export { default as ContactField } from './ContactField';
export { FormSkeleton } from './FormSkeleton';

View File

@@ -0,0 +1,58 @@
import type { ILivechatContactChannel } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { GenericMenu } from '@rocket.chat/ui-client';
import { useSetModal, usePermission } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import RemoveContactModal from './RemoveContactModal';
import { useOmnichannelDirectoryRouter } from '../hooks/useOmnichannelDirectoryRouter';
export type ContactItemMenuProps = {
_id: string;
name: string;
channels: ILivechatContactChannel[];
};
const ContactItemMenu = ({ _id, name, channels }: ContactItemMenuProps) => {
const { t } = useTranslation();
const setModal = useSetModal();
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const canEditContact = usePermission('update-livechat-contact');
const canDeleteContact = usePermission('delete-livechat-contact');
const handleContactEdit = useStableCallback((): void =>
omnichannelDirectoryRouter.navigate({
tab: 'contacts',
context: 'edit',
id: _id,
}),
);
const handleContactRemoval = useStableCallback(() => {
setModal(<RemoveContactModal _id={_id} name={name} channelsCount={channels.length} onClose={() => setModal(null)} />);
});
const menuOptions: GenericMenuItemProps[] = [
{
id: 'edit',
icon: 'edit',
content: t('Edit'),
onClick: () => handleContactEdit(),
disabled: !canEditContact,
},
{
id: 'delete',
icon: 'trash',
content: t('Delete'),
onClick: () => handleContactRemoval(),
variant: 'danger',
disabled: !canDeleteContact,
},
];
return <GenericMenu detached title={t('More_actions')} sections={[{ title: '', items: menuOptions }]} placement='bottom-end' />;
};
export default ContactItemMenu;

View File

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

View File

@@ -0,0 +1,160 @@
import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle, Box, Button } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableHeader,
GenericTableBody,
GenericTableHeaderCell,
GenericTableLoadingTable,
usePagination,
useSort,
} from '@rocket.chat/ui-client';
import { hashKey } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ContactTableRow from './ContactTableRow';
import { useCurrentContacts } from './hooks/useCurrentContacts';
import FilterByText from '../../../../components/FilterByText';
import GenericNoResults from '../../../../components/GenericNoResults';
import { links } from '../../../../lib/links';
import { useOmnichannelDirectoryRouter } from '../hooks/useOmnichannelDirectoryRouter';
function ContactTable() {
const { t } = useTranslation();
const [term, setTerm] = useState('');
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const { sortBy, sortDirection, setSort } = useSort<'name' | 'channels.lastChat.ts' | 'contactManager.username' | 'lastChat.ts'>('name');
const query = useDebouncedValue(
useMemo(
() => ({
searchText: term,
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[itemsPerPage, current, sortBy, sortDirection, term],
),
500,
);
const onButtonNewClick = useStableCallback(() =>
omnichannelDirectoryRouter.navigate({
tab: 'contacts',
context: 'new',
}),
);
const { data, isLoading, isError, isSuccess, refetch } = useCurrentContacts(query);
const [defaultQuery] = useState(hashKey([query]));
const queryHasChanged = defaultQuery !== hashKey([query]);
const headers = (
<>
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
{t('Name')}
</GenericTableHeaderCell>
<GenericTableHeaderCell
key='lastChannel'
direction={sortDirection}
active={sortBy === 'channels.lastChat.ts'}
onClick={setSort}
sort='channels.lastChat.ts'
>
{t('Last_channel')}
</GenericTableHeaderCell>
<GenericTableHeaderCell
key='contactManager'
direction={sortDirection}
active={sortBy === 'contactManager.username'}
onClick={setSort}
sort='contactManager.username'
>
{t('Contact_Manager')}
</GenericTableHeaderCell>
<GenericTableHeaderCell
key='lastchat'
direction={sortDirection}
active={sortBy === 'lastChat.ts'}
onClick={setSort}
sort='lastChat.ts'
>
{t('Last_Chat')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='spacer' width={40} />
</>
);
return (
<>
{((isSuccess && data?.contacts.length > 0) || queryHasChanged) && (
<FilterByText value={term} onChange={(event) => setTerm(event.target.value)}>
<Button onClick={onButtonNewClick} primary>
{t('New_contact')}
</Button>
</FilterByText>
)}
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingTable headerCells={headers.props.children.filter(Boolean).length} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data?.contacts.length === 0 && queryHasChanged && <GenericNoResults />}
{isSuccess && data?.contacts.length === 0 && !queryHasChanged && (
<GenericNoResults
icon='user-plus'
title={t('No_contacts_yet')}
description={t('No_contacts_yet_description')}
buttonTitle={t('New_contact')}
buttonAction={onButtonNewClick}
linkHref={links.go.omnichannelDocs}
linkText={t('Learn_more_about_contacts')}
/>
)}
{isSuccess && data?.contacts.length > 0 && (
<>
<GenericTable aria-label={t('Omnichannel_Contact_Center_Contacts')}>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data?.contacts.map((contact) => (
<ContactTableRow key={contact._id} {...contact} />
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
{isError && (
<Box marginBlockStart={20}>
<States>
<StatesIcon variation='danger' name='circle-exclamation' />
<StatesTitle>{t('Connection_error')}</StatesTitle>
<StatesActions>
<StatesAction icon='reload' onClick={() => refetch()}>
{t('Reload_page')}
</StatesAction>
</StatesActions>
</States>
</Box>
)}
</>
);
}
export default ContactTable;

View File

@@ -0,0 +1,86 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import { GenericTableCell, GenericTableRow } from '@rocket.chat/ui-client';
import ContactItemMenu from './ContactItemMenu';
import { OmnichannelRoomIcon } from '../../../../components/RoomIcon/OmnichannelRoomIcon';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import { useTimeFromNow } from '../../../../hooks/useTimeFromNow';
import { useOmnichannelSource } from '../../hooks/useOmnichannelSource';
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 ContactTableRow = ({ _id, name, contactManager, lastChat, channels }: ILivechatContactWithManagerData) => {
const { getSourceLabel } = useOmnichannelSource();
const getTimeFromNow = useTimeFromNow(true);
const formatDate = useFormatDate();
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
const latestChannel = channels?.sort((a, b) => {
if (a.lastChat && b.lastChat) {
return a.lastChat.ts > b.lastChat.ts ? -1 : 1;
}
return 0;
})[0];
const onRowClick = useStableCallback((id: string) =>
omnichannelDirectoryRouter.navigate({
id,
tab: 'contacts',
context: 'details',
}),
);
return (
<GenericTableRow
action
key={_id}
tabIndex={0}
height='40px'
rcx-show-call-button-on-hover
className={customAlignTop}
onClick={() => onRowClick(_id)}
>
<GenericTableCell fontScale='p2m' color='default' withTruncatedText>
{name}
</GenericTableCell>
<GenericTableCell withTruncatedText>
{latestChannel?.details && (
<Box withTruncatedText display='flex' alignItems='center'>
<OmnichannelRoomIcon size='x20' source={latestChannel?.details} />
<Box withTruncatedText marginInlineStart={8}>
{getSourceLabel(latestChannel?.details)}
</Box>
</Box>
)}
</GenericTableCell>
<GenericTableCell withTruncatedText>{contactManager?.username}</GenericTableCell>
<GenericTableCell withTruncatedText>
{lastChat && (
<Box display='flex' flexDirection='column'>
<Box fontScale='p2m' withTruncatedText color='default'>
{formatDate(lastChat.ts)}
</Box>
<Box withTruncatedText>{getTimeFromNow(lastChat.ts)}</Box>
</Box>
)}
</GenericTableCell>
<GenericTableCell>
<ContactItemMenu _id={_id} name={name} channels={channels} />
</GenericTableCell>
</GenericTableRow>
);
};
export default ContactTableRow;

View File

@@ -0,0 +1,79 @@
import { Box, Input } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { ChangeEvent, SubmitEvent } from 'react';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
export type RemoveContactModalProps = {
_id: string;
name: string;
channelsCount: number;
onClose: () => void;
};
const RemoveContactModal = ({ _id, name, channelsCount, onClose }: RemoveContactModalProps) => {
const { t } = useTranslation();
const [text, setText] = useState<string>('');
const queryClient = useQueryClient();
const removeContact = useEndpoint('POST', '/v1/omnichannel/contacts.delete');
const dispatchToast = useToastMessageDispatch();
const contactDeleteModalId = useId();
const handleSubmit = useStableCallback((event: SubmitEvent<HTMLFormElement>): void => {
event.preventDefault();
removeContactMutation.mutate();
});
const confirmationText = t('Delete').toLowerCase();
const removeContactMutation = useMutation({
mutationFn: () => removeContact({ contactId: _id }),
onSuccess: async () => {
dispatchToast({ type: 'success', message: t('Contact_has_been_deleted') });
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['current-contacts'],
}),
queryClient.invalidateQueries({
queryKey: ['getContactsByIds', _id],
}),
]);
onClose();
},
onError: (error) => {
dispatchToast({ type: 'error', message: error });
onClose();
},
});
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit} {...props} />}
onCancel={onClose}
confirmText={t('Delete')}
title={t('Delete_Contact')}
onClose={onClose}
variant='danger'
confirmDisabled={text !== confirmationText}
>
<Box is='p' id={`${contactDeleteModalId}-description`} marginBlockEnd={16}>
{t('Are_you_sure_delete_contact', { contactName: name, channelsCount, confirmationText })}
</Box>
<Box marginBlockEnd={16} display='flex' justifyContent='stretch'>
<Input
value={text}
name='confirmContactRemoval'
aria-label={t('Confirm_contact_removal')}
aria-describedby={`${contactDeleteModalId}-description`}
onChange={(event: ChangeEvent<HTMLInputElement>) => setText(event.currentTarget.value)}
/>
</Box>
</GenericModal>
);
};
export default RemoveContactModal;

View File

@@ -0,0 +1,17 @@
import type { OperationResult, PaginatedRequest } 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 useCurrentContacts = (
query: PaginatedRequest<{
searchText: string;
}>,
): UseQueryResult<OperationResult<'GET', '/v1/omnichannel/contacts.search'>> => {
const currentContacts = useEndpoint('GET', '/v1/omnichannel/contacts.search');
return useQuery({
queryKey: ['current-contacts', query],
queryFn: () => currentContacts(query),
});
};

View File

@@ -0,0 +1,71 @@
import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage';
import type { RefObject, SetStateAction } from 'react';
import { createContext, useContext } from 'react';
export type ChatsFiltersQuery = {
guest: string;
servedBy: PaginatedMultiSelectOption[];
status: string;
department: PaginatedMultiSelectOption[];
from: string;
to: string;
tags: { _id: string; label: string; value: string }[];
units?: PaginatedMultiSelectOption[];
[key: string]: unknown;
};
export const initialValues: ChatsFiltersQuery = {
guest: '',
servedBy: [],
status: 'all',
department: [],
from: '',
to: '',
tags: [],
units: [],
};
export type ChatsContextValue = {
filtersQuery: ChatsFiltersQuery;
setFiltersQuery: (value: SetStateAction<ChatsFiltersQuery>) => void;
resetFiltersQuery: () => void;
displayFilters: {
from: string | undefined;
to: string | undefined;
guest: string | undefined;
servedBy: string | undefined;
department: string | undefined;
status: string | undefined;
tags: string | undefined;
};
removeFilter: (filter: keyof ChatsFiltersQuery) => void;
hasAppliedFilters: boolean;
textInputRef: RefObject<HTMLInputElement | null> | null;
};
export const ChatsContext = createContext<ChatsContextValue>({
filtersQuery: initialValues,
setFiltersQuery: () => undefined,
resetFiltersQuery: () => undefined,
displayFilters: {
from: undefined,
to: undefined,
guest: undefined,
servedBy: undefined,
department: undefined,
status: undefined,
tags: undefined,
},
removeFilter: () => undefined,
hasAppliedFilters: false,
textInputRef: null,
});
export const useChatsContext = (): ChatsContextValue => {
const context = useContext(ChatsContext);
if (!context) {
throw new Error('Must be running in Chats Context');
}
return context;
};

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
import { useCustomFieldsQuery } from '../../hooks/useCustomFieldsQuery';
import { formatCustomFieldsMetadata } from '../utils/formatCustomFieldsMetadata';
type UseCustomFieldsMetadataOptions = {
enabled?: boolean;
scope: 'visitor' | 'room';
};
export const useCustomFieldsMetadata = ({ enabled = true, scope }: UseCustomFieldsMetadataOptions) => {
const { data, isSuccess } = useCustomFieldsQuery();
return useQuery({
queryKey: omnichannelQueryKeys.livechat.customFieldsMetadata(scope),
queryFn: async () => formatCustomFieldsMetadata(data?.customFields, scope),
enabled: enabled && isSuccess,
});
};

View File

@@ -0,0 +1,13 @@
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';
export const useDepartmentInfo = (departmentId: string): UseQueryResult<OperationResult<'GET', '/v1/livechat/department/:_id'>> => {
const deptInfo = useEndpoint('GET', `/v1/livechat/department/:_id`, { _id: departmentId });
return useQuery({
queryKey: ['livechat/department', departmentId],
queryFn: () => deptInfo({}),
});
};

View File

@@ -0,0 +1,45 @@
import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import type { ChatsFiltersQuery } from '../contexts/ChatsContext';
const statusTextMap: { [key: string]: TranslationKey } = {
all: 'All',
closed: 'Closed',
opened: 'Room_Status_Open',
onhold: 'On_Hold_Chats',
queued: 'Queued',
};
export const useDisplayFilters = (filtersQuery: ChatsFiltersQuery) => {
const { t } = useTranslation();
const formatDate = useFormatDate();
const { guest, servedBy, status, department, from, to, tags, units, ...customFields } = filtersQuery;
const displayCustomFields = Object.entries(customFields).reduce(
(acc, [key, value]) => {
acc[key] = value ? `${key}: ${value}` : undefined;
return acc;
},
{} as { [key: string]: string | undefined },
);
return {
from: from !== '' ? `${t('From')}: ${formatDate(from)}` : undefined,
to: to !== '' ? `${t('To')}: ${formatDate(to)}` : undefined,
guest: guest !== '' ? `${t('Text')}: ${guest}` : undefined,
servedBy: servedBy.length ? `${t('Served_By')}: ${parseMultiSelect(servedBy)}` : undefined,
department: department.length ? `${t('Department')}: ${parseMultiSelect(department)}` : undefined,
status: status !== 'all' ? `${t('Status')}: ${t(statusTextMap[status])}` : undefined,
tags: tags.length > 0 ? `${t('Tags')}: ${tags.map((tag) => tag.label).join(', ')}` : undefined,
units: units?.length ? `${t('Units')}: ${parseMultiSelect(units)}` : undefined,
...displayCustomFields,
};
};
const parseMultiSelect = (data: PaginatedMultiSelectOption[]) => {
return data.map((a) => (a.label as string).split(' (')[0]).join(', ');
};

View File

@@ -0,0 +1,34 @@
import type { RouteParameters, SearchParameters } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
export const useOmnichannelDirectoryRouter = () => {
const router = useRouter();
const routeName = router.getRouteName();
const validRoutes = routeName === 'omnichannel-directory' || routeName === 'omnichannel-current-chats';
const handleNavigate = useCallback(
(params?: RouteParameters, search?: SearchParameters) => {
if (!routeName || !validRoutes) {
return;
}
return router.navigate({ name: routeName, params, search });
},
[validRoutes, routeName, router],
);
const getDirectoryRouteName = useCallback(() => {
if (!routeName || !validRoutes) {
return undefined;
}
return routeName;
}, [routeName, validRoutes]);
return {
...router,
navigate: handleNavigate,
getRouteName: getDirectoryRouteName,
};
};

View File

@@ -0,0 +1,8 @@
import type { IOmnichannelRoom, IRoom, Serialized } from '@rocket.chat/core-typings';
import { useRoomInfoEndpoint } from '../../../../hooks/useRoomInfoEndpoint';
export const useOmnichannelRoomInfo = (rid: IRoom['_id']) =>
useRoomInfoEndpoint(rid, {
select: (data) => (data.room as Serialized<IOmnichannelRoom>) ?? null,
});

View File

@@ -0,0 +1,15 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useOmnichannelPriorities } from '../../hooks/useOmnichannelPriorities';
export const usePriorityInfo = (priorityId: string) => {
const { enabled } = useOmnichannelPriorities();
const getPriority = useEndpoint('GET', `/v1/livechat/priorities/:priorityId`, { priorityId });
return useQuery({
queryKey: ['/v1/livechat/priorities', priorityId],
queryFn: () => getPriority(),
gcTime: 0,
enabled,
});
};

View File

@@ -0,0 +1,14 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
export const useSlaInfo = (slaId: string) => {
const { data: isEnterprise = false } = useHasLicenseModule('livechat-enterprise');
const getSLA = useEndpoint('GET', '/v1/livechat/sla/:slaId', { slaId });
return useQuery({
queryKey: ['/v1/livechat/sla/:slaId', slaId],
queryFn: () => getSLA(),
enabled: isEnterprise && !!slaId,
});
};

View File

@@ -0,0 +1,21 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { millisecondsToMinutes } from 'date-fns';
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
export const useSlaPolicies = () => {
const { data: isEnterprise = false } = useHasLicenseModule('livechat-enterprise');
const getSlaPolicies = useEndpoint('GET', '/v1/livechat/sla');
const { data: { sla } = {}, ...props } = useQuery({
queryKey: ['/v1/livechat/sla'],
queryFn: () => getSlaPolicies({}),
staleTime: millisecondsToMinutes(10),
enabled: isEnterprise,
});
return {
data: sla,
...props,
};
};

View File

@@ -0,0 +1,13 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useVisitorInfo = (visitorId: string, { enabled = true, gcTime = 0 } = {}) => {
const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info');
const { data: { visitor } = {}, ...props } = useQuery({
queryKey: ['/v1/livechat/visitors.info', visitorId],
queryFn: () => getVisitorInfo({ visitorId }),
enabled,
gcTime,
});
return { data: visitor, ...props };
};

View File

@@ -0,0 +1,51 @@
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { ReactNode } from 'react';
import { useMemo, useRef } from 'react';
import { ChatsContext, initialValues } from '../contexts/ChatsContext';
import { useDisplayFilters } from '../hooks/useDisplayFilters';
export type ChatsProviderProps = {
children: ReactNode;
};
const ChatsProvider = ({ children }: ChatsProviderProps) => {
const textInputRef = useRef<HTMLInputElement>(null);
const [filtersQuery, setFiltersQuery] = useLocalStorage('newConversationsQuery', initialValues);
const displayFilters = useDisplayFilters(filtersQuery);
const contextValue = useMemo(
() => ({
filtersQuery,
setFiltersQuery,
resetFiltersQuery: () =>
setFiltersQuery((prevState) => {
const customFields = Object.keys(prevState).filter((item) => !Object.keys(initialValues).includes(item));
const initialCustomFields = customFields.reduce(
(acc, cv) => {
acc[cv] = '';
return acc;
},
{} as { [key: string]: string },
);
return { ...initialValues, ...initialCustomFields };
}),
displayFilters,
removeFilter: (filter: keyof typeof initialValues) => {
if (filter === 'guest' && textInputRef.current) {
textInputRef.current.focus();
}
return setFiltersQuery((prevState) => ({ ...prevState, [filter]: initialValues[filter] }));
},
hasAppliedFilters: Object.values(displayFilters).filter((value) => value !== undefined).length > 0,
textInputRef,
}),
[displayFilters, filtersQuery, setFiltersQuery],
);
return <ChatsContext.Provider value={contextValue}>{children}</ChatsContext.Provider>;
};
export default ChatsProvider;

View File

@@ -0,0 +1,21 @@
import type { ILivechatCustomField, CustomFieldMetadata } from '@rocket.chat/core-typings';
export const formatCustomFieldsMetadata = (
customFields: ILivechatCustomField[] | undefined,
scope: 'visitor' | 'room',
): CustomFieldMetadata[] => {
if (!customFields) {
return [];
}
return customFields
.filter((field) => field.visibility === 'visible' && field.scope === scope)
.map(({ _id, label, options, defaultValue, required }) => ({
name: _id,
label,
type: options ? 'select' : 'text',
required,
defaultValue,
options: options?.split(',').map((item) => [item.trim(), item.trim()]),
}));
};

View File

@@ -0,0 +1,24 @@
import type { IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings';
import { formatDistance } from 'date-fns';
export const formatQueuedAt = (room: Serialized<IOmnichannelRoom> | undefined) => {
const { servedBy, closedAt, open, queuedAt, ts } = room || {};
const queueStartedAt = queuedAt || ts;
// Room served: time from queueStartedAt to servedBy.ts
if (servedBy?.ts != null && queueStartedAt != null) {
return formatDistance(new Date(servedBy.ts), new Date(queueStartedAt), { addSuffix: false });
}
// Room open and not served: time from queueStartedAt to now
if (open && queueStartedAt != null) {
return formatDistance(new Date(), new Date(queueStartedAt), { addSuffix: false });
}
// Room closed and not served: time from queueStartedAt to closedAt
if (closedAt != null && queueStartedAt != null) {
return formatDistance(new Date(closedAt), new Date(queueStartedAt), { addSuffix: false });
}
return '';
};