Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
f6cd389
chore: mock functions
aleksandernsilva Jun 11, 2025
172bb9e
feat: outbound-message license module
aleksandernsilva Jun 24, 2025
c255666
feat: formatPhoneNumber utility function
aleksandernsilva Jun 20, 2025
9beeecf
feat: OutboundMessageWizard
aleksandernsilva Jun 11, 2025
f1f2914
chore: OutboundMessageWizard storybook
aleksandernsilva Jul 3, 2025
1d9211a
feat: OutboundMessageModal
aleksandernsilva Jun 11, 2025
b00641c
feat: outbound message action
aleksandernsilva Jun 11, 2025
93756d9
feat: Outbound Message: Message Step (#36370)
aleksandernsilva Aug 1, 2025
49744e0
feat: Outbound Message: Replies Step (#36371)
aleksandernsilva Aug 1, 2025
b186132
feat: Outbound Message: Review Step (#36372)
aleksandernsilva Aug 1, 2025
edfb8ec
feat: Outbound Message Upsell Modal (#36335)
aleksandernsilva Aug 1, 2025
31e45a6
feat: Outbound Message: Recipient Step (#36369)
aleksandernsilva Aug 5, 2025
4683f95
chore: removed mocks
aleksandernsilva Aug 6, 2025
7c6ddd1
chore: adjusted mock
aleksandernsilva Aug 6, 2025
628422e
refactor: Outbound Message Recipient Step Improvements (#36654)
aleksandernsilva Aug 7, 2025
86edd4c
feat: Outbound message template improvements (#36711)
aleksandernsilva Aug 14, 2025
63bb859
feat: Outbound message form improvements (#36713)
aleksandernsilva Aug 15, 2025
3320e9b
refactor: Replaced agent fetch with department agents and added permi…
aleksandernsilva Aug 20, 2025
5a33bd1
fix: Outbound message upsell modal not being displayed when there's n…
aleksandernsilva Aug 20, 2025
e529279
fix: Template placeholder not being used as fallback when parameter i…
aleksandernsilva Aug 20, 2025
01477f0
feat(outbound): Added language description to template select compone…
aleksandernsilva Aug 21, 2025
23fb359
feat(outbound): Contact info shortcuts (#36666)
aleksandernsilva Aug 25, 2025
460e216
feat(outbound): UI permissions (#36794)
aleksandernsilva Sep 4, 2025
0f5258e
feat(outbound): Send message logic (#36631)
aleksandernsilva Sep 4, 2025
583f174
i18n: Outbound message pt-BR and en-US translations (#36878)
aleksandernsilva Sep 8, 2025
df32872
fix(outbound): agent autocomplete displaying incorrect avatar (#36892)
aleksandernsilva Sep 10, 2025
7096bf0
feat(outbound): Added required validation for template placeholder fi…
aleksandernsilva Sep 10, 2025
74e915f
refactor(outbound): recipient form fields (#36899)
aleksandernsilva Sep 10, 2025
44a26ce
fix: reading length from undefined
aleksandernsilva Sep 10, 2025
4a08211
feat: added default values to template placeholder fields
aleksandernsilva Sep 11, 2025
6677cc1
fix(outbound): Agents not being sorted alphabetically (#36901)
aleksandernsilva Sep 11, 2025
b47518b
fix(outbound): Contact name getting truncated by the phone number des…
aleksandernsilva Sep 11, 2025
440ffcd
fix: selected agent incorrect avatar
aleksandernsilva Sep 11, 2025
5058344
fix(outbound): Placeholder input not being focused after menu is clos…
aleksandernsilva Sep 11, 2025
ed9b6c6
fix(outbound): Upsell modal conditions (#36930)
aleksandernsilva Sep 15, 2025
ebb221a
fix(outbound): Clear placeholder once an agent is selected (#36939)
aleksandernsilva Sep 15, 2025
e1eefc8
refactor: removed providers fetch default stale time
aleksandernsilva Sep 15, 2025
508630e
refactor: adjusted send message error handling
aleksandernsilva Sep 15, 2025
92af2d2
chore: adjusted mocks
aleksandernsilva Sep 15, 2025
16e8290
chore: improved AutoCompleteContact typing
aleksandernsilva Sep 15, 2025
aeacd62
chore: changed storybook decorators to array
aleksandernsilva Sep 15, 2025
f4305e4
fix: field error and description mismatch
aleksandernsilva Sep 15, 2025
4018640
refactor: added provider type to phone number validation
aleksandernsilva Sep 15, 2025
5b066ba
test: corrected types and payload expectation
aleksandernsilva Sep 15, 2025
3c34dfc
test: added missing import
aleksandernsilva Sep 15, 2025
e162414
refactor: adjusted TemplatePlaceholderSelector typing
aleksandernsilva Sep 15, 2025
17f2f0a
feat: adjusted providers query key to include type
aleksandernsilva Sep 15, 2025
94de8f6
refactor: improved ContactInforOutboundMessageButton typing
aleksandernsilva Sep 15, 2025
8028bce
refactor: improved contact info submit logic
aleksandernsilva Sep 15, 2025
6567243
refactor: improved template utility functions
aleksandernsilva Sep 15, 2025
f3c4b21
chore: adjusted AutoCompletContact typing
aleksandernsilva Sep 15, 2025
c457d76
chore: reviews
aleksandernsilva Sep 15, 2025
3da023d
chore: bump fuselage
aleksandernsilva Sep 15, 2025
8895155
feat(outbound): Added closing confirmation to the modal (#36937)
aleksandernsilva Sep 15, 2025
88d1374
chore: changeset
aleksandernsilva Sep 16, 2025
ba6e3f9
i18n: normalized "Outbound Message" to "Outbound message"
aleksandernsilva Sep 16, 2025
b7b0542
feat: added close button to close confirmation modal
aleksandernsilva Sep 16, 2025
26a9371
chore: updated changeset
aleksandernsilva Sep 16, 2025
8b35745
refactor: improved contacts query keys
aleksandernsilva Sep 16, 2025
2c0f847
refactor: added missing key
aleksandernsilva Sep 16, 2025
74bfac9
feat: added documentation links
aleksandernsilva Sep 16, 2025
78a076a
test: adjusted OutboundMessageModal unit tests
aleksandernsilva Sep 16, 2025
4b9b1b8
test: adjusted doc links
aleksandernsilva Sep 16, 2025
1c12524
Merge branch 'develop' into feat/outbound-msg-ui
kodiakhq[bot] Sep 17, 2025
b6fcc40
Merge branch 'develop' into feat/outbound-msg-ui
kodiakhq[bot] Sep 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/rich-rules-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@rocket.chat/web-ui-registration': patch
'@rocket.chat/storybook-config': patch
'@rocket.chat/fuselage-ui-kit': patch
'@rocket.chat/ui-theming': patch
'@rocket.chat/ui-video-conf': patch
'@rocket.chat/uikit-playground': patch
'@rocket.chat/ui-composer': patch
'@rocket.chat/gazzodown': patch
'@rocket.chat/ui-avatar': patch
'@rocket.chat/ui-client': patch
'@rocket.chat/ui-voip': patch
'@rocket.chat/core-typings': minor
'@rocket.chat/apps-engine': minor
'@rocket.chat/license': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, useSetting, useAtLeastOnePermission } from '@rocket.chat/ui-contexts';
import { useTranslation, useSetting, useAtLeastOnePermission, usePermission } from '@rocket.chat/ui-contexts';

import { useCreateRoomModal } from './useCreateRoomModal';
import CreateDiscussion from '../../../components/CreateDiscussion';
import { useOutboundMessageModal } from '../../../components/Omnichannel/OutboundMessage/modals/OutboundMessageModal';
import CreateChannelModal from '../actions/CreateChannelModal';
import CreateDirectMessage from '../actions/CreateDirectMessage';
import CreateTeamModal from '../actions/CreateTeamModal';
Expand All @@ -20,11 +21,13 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => {
const canCreateTeam = useAtLeastOnePermission(CREATE_TEAM_PERMISSIONS);
const canCreateDirectMessages = useAtLeastOnePermission(CREATE_DIRECT_PERMISSIONS);
const canCreateDiscussion = useAtLeastOnePermission(CREATE_DISCUSSION_PERMISSIONS);
const canSendOutboundMessage = usePermission('outbound.send-messages');

const createChannel = useCreateRoomModal(CreateChannelModal);
const createTeam = useCreateRoomModal(CreateTeamModal);
const createDiscussion = useCreateRoomModal(CreateDiscussion);
const createDirectMessage = useCreateRoomModal(CreateDirectMessage);
const outboundMessageModal = useOutboundMessageModal();

const createChannelItem: GenericMenuItemProps = {
id: 'channel',
Expand Down Expand Up @@ -58,11 +61,18 @@ export const useCreateNewItems = (): GenericMenuItemProps[] => {
createDiscussion();
},
};
const createOutboundMessageItem: GenericMenuItemProps = {
id: 'outbound-message',
content: t('Outbound_message'),
icon: 'send',
onClick: () => outboundMessageModal.open(),
};

return [
...(canCreateDirectMessages ? [createDirectMessageItem] : []),
...(canCreateDiscussion && discussionEnabled ? [createDiscussionItem] : []),
...(canCreateChannel ? [createChannelItem] : []),
...(canCreateTeam && canCreateChannel ? [createTeamItem] : []),
...(canSendOutboundMessage ? [createOutboundMessageItem] : []),
];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Serialized } from '@rocket.chat/core-typings';
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import type { ComponentProps, ReactElement, SyntheticEvent } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useContactsList } from './useContactsList';

type OptionProps = {
role: 'option';
title?: string;
index: number;
label: string;
value: string;
selected: boolean;
focus: boolean;
onMouseDown(event: SyntheticEvent): void;
};

type AutoCompleteContactProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem' | 'value' | 'onChange'
> & {
value: string;
onChange: (value: string) => void;
renderItem?: (props: OptionProps, contact: Serialized<ILivechatContactWithManagerData>) => ReactElement;
};

const AutoCompleteContact = ({ value, placeholder, disabled, renderItem, onChange, ...props }: AutoCompleteContactProps): ReactElement => {
const { t } = useTranslation();
const [contactsFilter, setContactFilter] = useState<string>('');
const debouncedContactFilter = useDebouncedValue(contactsFilter, 500);

const {
data: contactsItems = [],
fetchNextPage,
isPending,
} = useContactsList({
filter: debouncedContactFilter,
});

return (
<PaginatedSelectFiltered
{...props}
aria-busy={isPending}
placeholder={isPending ? t('Loading...') : placeholder}
aria-disabled={isPending || disabled}
disabled={isPending || disabled}
value={value}
flexShrink={0}
filter={contactsFilter}
setFilter={setContactFilter as (value: string | number | undefined) => void}
options={contactsItems}
onChange={onChange}
endReached={() => fetchNextPage()}
renderItem={renderItem ? (props: OptionProps) => renderItem(props, contactsItems[props.index]) : undefined}
/>
);
};

export default memo(AutoCompleteContact);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './AutoCompleteContact';
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';

import { omnichannelQueryKeys } from '../../lib/queryKeys';

export type ContactOption = Serialized<ILivechatContactWithManagerData> & {
value: string;
label: string;
};

type ContactOptions = {
filter: string;
limit?: number;
};

const DEFAULT_QUERY_LIMIT = 25;

const formatContactItem = (contact: Serialized<ILivechatContactWithManagerData>): ContactOption => ({
...contact,
label: contact.name || contact._id,
value: contact._id,
});

export const useContactsList = (options: ContactOptions) => {
const getContacts = useEndpoint('GET', '/v1/omnichannel/contacts.search');
const { filter, limit = DEFAULT_QUERY_LIMIT } = options;

return useInfiniteQuery({
queryKey: omnichannelQueryKeys.contacts({ filter, limit }),
queryFn: async ({ pageParam: offset = 0 }) => {
const { contacts, ...data } = await getContacts({
searchText: filter,
// sort: `{ name: -1 }`,
...(limit && { count: limit }),
...(offset && { offset }),
});

return {
...data,
contacts: contacts.map(formatContactItem),
};
},
select: (data) => data.pages.flatMap<ContactOption>((page) => page.contacts),
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { render, screen } from '@testing-library/react';

import AutoCompleteDepartmentAgent from './AutoCompleteDepartmentAgent';

it('should not display the placeholder when there is a value', () => {
const { rerender } = render(<AutoCompleteDepartmentAgent value='' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);

expect(screen.getByPlaceholderText('Select an agent')).toBeInTheDocument();

rerender(<AutoCompleteDepartmentAgent value='agent1' onChange={jest.fn()} placeholder='Select an agent' agents={[]} />);

expect(screen.queryByPlaceholderText('Select an agent')).not.toBeInTheDocument();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings';
import { AutoComplete, Box, Chip, Option, OptionAvatar, OptionContent } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { AllHTMLAttributes, ReactElement } from 'react';
import { useMemo, useState } from 'react';

type AutoCompleteDepartmentAgentProps = Omit<AllHTMLAttributes<HTMLInputElement>, 'onChange'> & {
error?: boolean;
value: string;
onChange(value: string): void;
agents?: Serialized<ILivechatDepartmentAgents>[];
};

const AutoCompleteDepartmentAgent = ({ value, onChange, agents, placeholder, ...props }: AutoCompleteDepartmentAgentProps) => {
const [filter, setFilter] = useState('');
const debouncedFilter = useDebouncedValue(filter, 1000);

const options = useMemo(() => {
if (!agents) {
return [];
}

return agents
.filter((agent) => agent.username?.includes(debouncedFilter))
.sort((a, b) => a.username.localeCompare(b.username))
.map((agent) => ({
value: agent.agentId,
label: agent.username,
}));
}, [agents, debouncedFilter]);

return (
<AutoComplete
{...props}
placeholder={!value ? placeholder : undefined}
filter={filter}
setFilter={setFilter}
value={value}
onChange={onChange as (value: string | string[]) => void}
options={options}
renderSelected={({ selected: { value, label }, ...props }): ReactElement => {
return (
<Chip {...props} height='x20' value={value} onClick={() => onChange('')} mie={4}>
<UserAvatar size='x20' username={label} />
<Box is='span' margin='none' mis={4}>
{label}
</Box>
</Chip>
);
}}
renderItem={({ value, label, ...props }): ReactElement => (
<Option key={value} {...props}>
<OptionAvatar>
<UserAvatar username={label} size='x20' />
</OptionAvatar>
<OptionContent>{label}</OptionContent>
</Option>
)}
/>
);
};

export default AutoCompleteDepartmentAgent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ILivechatContact, Serialized } from '@rocket.chat/core-typings';
import { Option, OptionDescription, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactElement } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useTimeFromNow } from '../../../../hooks/useTimeFromNow';
import useOutboundProvidersList from '../hooks/useOutboundProvidersList';
import { findLastChatFromChannel } from '../utils/findLastChatFromChannel';

type AutoCompleteOutboundProviderProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
> & {
contact?: Serialized<Omit<ILivechatContact, 'contactManager'>> | null;
value: string;
onChange: (value: string) => void;
};

const AutoCompleteOutboundProvider = ({
contact,
disabled,
value,
placeholder,
onChange,
...props
}: AutoCompleteOutboundProviderProps): ReactElement => {
const [channelsFilter, setChannelsFilter] = useState<string>('');
const { t } = useTranslation();
const getTimeFromNow = useTimeFromNow(true);

const { data: options = [], isPending } = useOutboundProvidersList({
select: ({ providers = [] }) => {
return providers.map((prov) => ({
label: prov.providerName,
value: prov.providerId,
}));
},
});

return (
<PaginatedSelectFiltered
{...props}
aria-busy={isPending}
placeholder={isPending ? t('Loading...') : placeholder}
aria-disabled={isPending || disabled}
disabled={isPending || disabled}
value={value}
flexShrink={0}
filter={channelsFilter}
setFilter={setChannelsFilter as (value: string | number | undefined) => void}
options={options}
onChange={onChange}
renderItem={({ label, value, ...props }) => {
const lastChat = findLastChatFromChannel(contact?.channels, value);

return (
<Option {...props} label={label} value={value}>
{lastChat ? (
<OptionDescription>{t('Last_message_received__time__', { time: getTimeFromNow(lastChat) })}</OptionDescription>
) : null}
</Option>
);
}}
/>
);
};

export default AutoCompleteOutboundProvider;
Loading
Loading