Skip to content

Commit b0a4602

Browse files
feat: Outbound Message UI (RocketChat#36207)
1 parent 3c28676 commit b0a4602

120 files changed

Lines changed: 6198 additions & 101 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/rich-rules-sleep.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@rocket.chat/web-ui-registration': patch
3+
'@rocket.chat/storybook-config': patch
4+
'@rocket.chat/fuselage-ui-kit': patch
5+
'@rocket.chat/ui-theming': patch
6+
'@rocket.chat/ui-video-conf': patch
7+
'@rocket.chat/uikit-playground': patch
8+
'@rocket.chat/ui-composer': patch
9+
'@rocket.chat/gazzodown': patch
10+
'@rocket.chat/ui-avatar': patch
11+
'@rocket.chat/ui-client': patch
12+
'@rocket.chat/ui-voip': patch
13+
'@rocket.chat/core-typings': minor
14+
'@rocket.chat/apps-engine': minor
15+
'@rocket.chat/license': minor
16+
'@rocket.chat/i18n': minor
17+
'@rocket.chat/meteor': minor
18+
---
19+
20+
Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat

apps/meteor/client/NavBarV2/NavBarPagesGroup/hooks/useCreateNewItems.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
2-
import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts';
2+
import { useTranslation, useSetting, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts';
33

44
import { useCreateRoomModal } from './useCreateRoomModal';
55
import CreateDiscussion from '../../../components/CreateDiscussion';
6+
import { useOutboundMessageModal } from '../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal';
67
import CreateChannelModal from '../actions/CreateChannelModal';
78
import CreateDirectMessage from '../actions/CreateDirectMessage';
89
import CreateTeamModal from '../actions/CreateTeamModal';
@@ -20,11 +21,13 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => {
2021
const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS);
2122
const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS);
2223
const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS);
24+
const canSendOutboundMessage = usePermission('outbound.send-messages');
2325

2426
const createChannel = useCreateRoomModal(CreateChannelModal);
2527
const createTeam = useCreateRoomModal(CreateTeamModal);
2628
const createDiscussion = useCreateRoomModal(CreateDiscussion);
2729
const createDirectMessage = useCreateRoomModal(CreateDirectMessage);
30+
const outboundMessageModal = useOutboundMessageModal();
2831

2932
const createChannelItem: GenericMenuItemProps = {
3033
id: 'channel',
@@ -58,11 +61,18 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => {
5861
createDiscussion();
5962
},
6063
};
64+
const createOutboundMessageItem: GenericMenuItemProps = {
65+
id: 'outbound-message',
66+
content: t('Outbound_message'),
67+
icon: 'send',
68+
onClick: () => outboundMessageModal.open(),
69+
};
6170

6271
return [
6372
...(canCreateDirectMessages ? [createDirectMessageItem] : []),
6473
...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []),
6574
...(canCreateChannel ? [createChannelItem] : []),
6675
...(canCreateTeam && canCreateChannel ? [createTeamItem] : []),
76+
...(canSendOutboundMessage ? [createOutboundMessageItem] : []),
6777
];
6878
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Serialized } from '@rocket.chat/core-typings';
2+
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
3+
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
4+
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
5+
import type { ComponentProps, ReactElement, SyntheticEvent } from 'react';
6+
import { memo, useState } from 'react';
7+
import { useTranslation } from 'react-i18next';
8+
9+
import { useContactsList } from './useContactsList';
10+
11+
type OptionProps = {
12+
role: 'option';
13+
title?: string;
14+
index: number;
15+
label: string;
16+
value: string;
17+
selected: boolean;
18+
focus: boolean;
19+
onMouseDown(event: SyntheticEvent): void;
20+
};
21+
22+
type AutoCompleteContactProps = Omit<
23+
ComponentProps<typeof PaginatedSelectFiltered>,
24+
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem' | 'value' | 'onChange'
25+
> & {
26+
value: string;
27+
onChange: (value: string) => void;
28+
renderItem?: (props: OptionProps, contact: Serialized<ILivechatContactWithManagerData>) => ReactElement;
29+
};
30+
31+
const AutoCompleteContact = ({ value, placeholder, disabled, renderItem, onChange, ...props }: AutoCompleteContactProps): ReactElement => {
32+
const { t } = useTranslation();
33+
const [contactsFilter, setContactFilter] = useState<string>('');
34+
const debouncedContactFilter = useDebouncedValue(contactsFilter, 500);
35+
36+
const {
37+
data: contactsItems = [],
38+
fetchNextPage,
39+
isPending,
40+
} = useContactsList({
41+
filter: debouncedContactFilter,
42+
});
43+
44+
return (
45+
<PaginatedSelectFiltered
46+
{...props}
47+
aria-busy={isPending}
48+
placeholder={isPending ? t('Loading...') : placeholder}
49+
aria-disabled={isPending || disabled}
50+
disabled={isPending || disabled}
51+
value={value}
52+
flexShrink={0}
53+
filter={contactsFilter}
54+
setFilter={setContactFilter as (value: string | number | undefined) => void}
55+
options={contactsItems}
56+
onChange={onChange}
57+
endReached={() => fetchNextPage()}
58+
renderItem={renderItem ? (props: OptionProps) => renderItem(props, contactsItems[props.index]) : undefined}
59+
/>
60+
);
61+
};
62+
63+
export default memo(AutoCompleteContact);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './AutoCompleteContact';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Serialized } from '@rocket.chat/core-typings';
2+
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
3+
import { useEndpoint } from '@rocket.chat/ui-contexts';
4+
import { useInfiniteQuery } from '@tanstack/react-query';
5+
6+
import { omnichannelQueryKeys } from '../../lib/queryKeys';
7+
8+
export type ContactOption = Serialized<ILivechatContactWithManagerData> & {
9+
value: string;
10+
label: string;
11+
};
12+
13+
type ContactOptions = {
14+
filter: string;
15+
limit?: number;
16+
};
17+
18+
const DEFAULT_QUERY_LIMIT = 25;
19+
20+
const formatContactItem = (contact: Serialized<ILivechatContactWithManagerData>): ContactOption => ({
21+
...contact,
22+
label: contact.name || contact._id,
23+
value: contact._id,
24+
});
25+
26+
export const useContactsList = (options: ContactOptions) => {
27+
const getContacts = useEndpoint('GET', '/v1/omnichannel/contacts.search');
28+
const { filter, limit = DEFAULT_QUERY_LIMIT } = options;
29+
30+
return useInfiniteQuery({
31+
queryKey: omnichannelQueryKeys.contacts({ filter, limit }),
32+
queryFn: async ({ pageParam: offset = 0 }) => {
33+
const { contacts, ...data } = await getContacts({
34+
searchText: filter,
35+
// sort: `{ name: -1 }`,
36+
...(limit && { count: limit }),
37+
...(offset && { offset }),
38+
});
39+
40+
return {
41+
...data,
42+
contacts: contacts.map(formatContactItem),
43+
};
44+
},
45+
select: (data) => data.pages.flatMap<ContactOption>((page) => page.contacts),
46+
initialPageParam: 0,
47+
getNextPageParam: (lastPage) => {
48+
const offset = lastPage.offset + lastPage.count;
49+
return offset < lastPage.total ? offset : undefined;
50+
},
51+
});
52+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import AutoCompleteDepartmentAgent from './AutoCompleteDepartmentAgent';
4+
5+
it('should not display the placeholder when there is a value', () => {
6+
const { rerender } = render(<AutoCompleteDepartmentAgent value='' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);
7+
8+
expect(screen.getByPlaceholderText('Select an agent')).toBeInTheDocument();
9+
10+
rerender(<AutoCompleteDepartmentAgent value='agent1' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);
11+
12+
expect(screen.queryByPlaceholderText('Select an agent')).not.toBeInTheDocument();
13+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings';
2+
import { AutoComplete, Box, Chip, Option, OptionAvatar, OptionContent } from '@rocket.chat/fuselage';
3+
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
4+
import { UserAvatar } from '@rocket.chat/ui-avatar';
5+
import type { AllHTMLAttributes, ReactElement } from 'react';
6+
import { useMemo, useState } from 'react';
7+
8+
type AutoCompleteDepartmentAgentProps = Omit<AllHTMLAttributes<HTMLInputElement>, 'onChange'> & {
9+
error?: boolean;
10+
value: string;
11+
onChange(value: string): void;
12+
agents?: Serialized<ILivechatDepartmentAgents>[];
13+
};
14+
15+
const AutoCompleteDepartmentAgent = ({ value, onChange, agents, placeholder, ...props }: AutoCompleteDepartmentAgentProps) => {
16+
const [filter, setFilter] = useState('');
17+
const debouncedFilter = useDebouncedValue(filter, 1000);
18+
19+
const options = useMemo(() => {
20+
if (!agents) {
21+
return [];
22+
}
23+
24+
return agents
25+
.filter((agent) => agent.username?.includes(debouncedFilter))
26+
.sort((a, b) => a.username.localeCompare(b.username))
27+
.map((agent) => ({
28+
value: agent.agentId,
29+
label: agent.username,
30+
}));
31+
}, [agents, debouncedFilter]);
32+
33+
return (
34+
<AutoComplete
35+
{...props}
36+
placeholder={!value ? placeholder : undefined}
37+
filter={filter}
38+
setFilter={setFilter}
39+
value={value}
40+
onChange={onChange as (value: string | string[]) => void}
41+
options={options}
42+
renderSelected={({ selected: { value, label }, ...props }): ReactElement => {
43+
return (
44+
<Chip {...props} height='x20' value={value} onClick={() => onChange('')} mie={4}>
45+
<UserAvatar size='x20' username={label} />
46+
<Box is='span' margin='none' mis={4}>
47+
{label}
48+
</Box>
49+
</Chip>
50+
);
51+
}}
52+
renderItem={({ value, label, ...props }): ReactElement => (
53+
<Option key={value} {...props}>
54+
<OptionAvatar>
55+
<UserAvatar username={label} size='x20' />
56+
</OptionAvatar>
57+
<OptionContent>{label}</OptionContent>
58+
</Option>
59+
)}
60+
/>
61+
);
62+
};
63+
64+
export default AutoCompleteDepartmentAgent;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
2+
import { Option, OptionDescription, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
3+
import type { ComponentProps, ReactElement } from 'react';
4+
import { useState } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
7+
import { useTimeFromNow } from '../../../../hooks/useTimeFromNow';
8+
import useOutboundProvidersList from '../hooks/useOutboundProvidersList';
9+
import { findLastChatFromChannel } from '../utils/findLastChatFromChannel';
10+
11+
type AutoCompleteOutboundProviderProps = Omit<
12+
ComponentProps<typeof PaginatedSelectFiltered>,
13+
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
14+
> & {
15+
contact?: Serialized<Omit<ILivechatContact, 'contactManager'>> | null;
16+
value: string;
17+
onChange: (value: string) => void;
18+
};
19+
20+
const AutoCompleteOutboundProvider = ({
21+
contact,
22+
disabled,
23+
value,
24+
placeholder,
25+
onChange,
26+
...props
27+
}: AutoCompleteOutboundProviderProps): ReactElement => {
28+
const [channelsFilter, setChannelsFilter] = useState<string>('');
29+
const { t } = useTranslation();
30+
const getTimeFromNow = useTimeFromNow(true);
31+
32+
const { data: options = [], isPending } = useOutboundProvidersList({
33+
select: ({ providers = [] }) => {
34+
return providers.map((prov) => ({
35+
label: prov.providerName,
36+
value: prov.providerId,
37+
}));
38+
},
39+
});
40+
41+
return (
42+
<PaginatedSelectFiltered
43+
{...props}
44+
aria-busy={isPending}
45+
placeholder={isPending ? t('Loading...') : placeholder}
46+
aria-disabled={isPending || disabled}
47+
disabled={isPending || disabled}
48+
value={value}
49+
flexShrink={0}
50+
filter={channelsFilter}
51+
setFilter={setChannelsFilter as (value: string | number | undefined) => void}
52+
options={options}
53+
onChange={onChange}
54+
renderItem={({ label, value, ...props }) => {
55+
const lastChat = findLastChatFromChannel(contact?.channels, value);
56+
57+
return (
58+
<Option {...props} label={label} value={value}>
59+
{lastChat ? (
60+
<OptionDescription>{t('Last_message_received__time__', { time: getTimeFromNow(lastChat) })}</OptionDescription>
61+
) : null}
62+
</Option>
63+
);
64+
}}
65+
/>
66+
);
67+
};
68+
69+
export default AutoCompleteOutboundProvider;

0 commit comments

Comments
 (0)