From 9cf079721b21d077ac36fc1c503bdee675ccf359 Mon Sep 17 00:00:00 2001 From: Hugo Costa Date: Mon, 14 Oct 2024 19:21:55 -0300 Subject: [PATCH 01/12] feat: new E2EE composer hint (#33283) --- .changeset/many-files-turn.md | 11 ++ .../views/room/composer/ComposerContainer.tsx | 4 +- .../ComposerFederation/ComposerFederation.tsx | 5 +- .../views/room/composer/ComposerMessage.tsx | 5 +- .../room/composer/messageBox/MessageBox.tsx | 26 ++--- .../messageBox/MessageBoxHint.spec.tsx | 109 ++++++++++++++++++ .../composer/messageBox/MessageBoxHint.tsx | 60 ++++++++++ packages/i18n/src/locales/en.i18n.json | 1 + .../MessageComposer.stories.tsx | 74 +++++++----- .../MessageComposerHint.stories.tsx | 31 +++++ 10 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 .changeset/many-files-turn.md create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx create mode 100644 packages/ui-composer/src/MessageComposer/MessageComposerHint.stories.tsx diff --git a/.changeset/many-files-turn.md b/.changeset/many-files-turn.md new file mode 100644 index 000000000000..6f53374ddd86 --- /dev/null +++ b/.changeset/many-files-turn.md @@ -0,0 +1,11 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": patch +"@rocket.chat/ui-composer": minor +--- + +Adds a warning to inform users they are about to send unencrypted messages in an E2E Encrypted room if they have the `Unencrypted messages in encrypted rooms` setting enabled. + + + + diff --git a/apps/meteor/client/views/room/composer/ComposerContainer.tsx b/apps/meteor/client/views/room/composer/ComposerContainer.tsx index 225ac00e13c7..7d3e09847b34 100644 --- a/apps/meteor/client/views/room/composer/ComposerContainer.tsx +++ b/apps/meteor/client/views/room/composer/ComposerContainer.tsx @@ -42,7 +42,7 @@ const ComposerContainer = ({ children, ...props }: ComposerMessageProps): ReactE } if (isFederation) { - return ; + return ; } if (isAnonymous) { @@ -68,7 +68,7 @@ const ComposerContainer = ({ children, ...props }: ComposerMessageProps): ReactE return ( <> {children} - + ); }; diff --git a/apps/meteor/client/views/room/composer/ComposerFederation/ComposerFederation.tsx b/apps/meteor/client/views/room/composer/ComposerFederation/ComposerFederation.tsx index 497e4e9ff2c4..cb42865a1a98 100644 --- a/apps/meteor/client/views/room/composer/ComposerFederation/ComposerFederation.tsx +++ b/apps/meteor/client/views/room/composer/ComposerFederation/ComposerFederation.tsx @@ -1,4 +1,3 @@ -import type { IRoom } from '@rocket.chat/core-typings'; import { useSetting } from '@rocket.chat/ui-contexts'; import React from 'react'; import type { ReactElement } from 'react'; @@ -9,7 +8,7 @@ import ComposerMessage from '../ComposerMessage'; import ComposerFederationDisabled from './ComposerFederationDisabled'; import ComposerFederationJoinRoomDisabled from './ComposerFederationJoinRoomDisabled'; -const ComposerFederation = ({ room, subscription, children, ...props }: ComposerMessageProps & { room: IRoom }): ReactElement => { +const ComposerFederation = ({ subscription, children, ...props }: ComposerMessageProps): ReactElement => { const federationEnabled = useSetting('Federation_Matrix_enabled') === true; const federationModuleEnabled = useHasLicenseModule('federation') === true; @@ -24,7 +23,7 @@ const ComposerFederation = ({ room, subscription, children, ...props }: Composer return ( <> {children} - + ); }; diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index 790b1739bde7..3f37f7a40443 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -14,7 +14,6 @@ export type ComposerMessageProps = { tmid?: IMessage['_id']; children?: ReactNode; subscription?: ISubscription; - readOnly?: boolean; tshow?: boolean; previewUrls?: string[]; onResize?: () => void; @@ -25,7 +24,7 @@ export type ComposerMessageProps = { onUploadFiles?: (files: readonly File[]) => void; }; -const ComposerMessage = ({ tmid, readOnly, onSend, ...props }: ComposerMessageProps): ReactElement => { +const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps): ReactElement => { const chat = useChat(); const room = useRoom(); const dispatchToastMessage = useToastMessageDispatch(); @@ -89,7 +88,7 @@ const ComposerMessage = ({ tmid, readOnly, onSend, ...props }: ComposerMessagePr return ; } - return ; + return ; }; export default memo(ComposerMessage); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 00d9c66a2046..1577cc50dc24 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -9,14 +9,12 @@ import { MessageComposerToolbar, MessageComposerActionsDivider, MessageComposerToolbarSubmit, - MessageComposerHint, MessageComposerButton, } from '@rocket.chat/ui-composer'; import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { ReactElement, MouseEventHandler, FormEvent, ClipboardEventHandler, MouseEvent } from 'react'; import React, { memo, useRef, useReducer, useCallback } from 'react'; -import { Trans } from 'react-i18next'; import { useSubscription } from 'use-subscription'; import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI'; @@ -42,6 +40,7 @@ import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview'; import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs'; import MessageBoxActionsToolbar from './MessageBoxActionsToolbar'; import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar'; +import MessageBoxHint from './MessageBoxHint'; import MessageBoxReplies from './MessageBoxReplies'; import { useMessageBoxAutoFocus } from './hooks/useMessageBoxAutoFocus'; import { useMessageBoxPlaceholder } from './hooks/useMessageBoxPlaceholder'; @@ -79,7 +78,6 @@ const getEmptyArray = () => a; type MessageBoxProps = { tmid?: IMessage['_id']; - readOnly: boolean; onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise; onJoin?: () => Promise; onResize?: () => void; @@ -104,7 +102,6 @@ const MessageBox = ({ onUploadFiles, onEscape, onTyping, - readOnly, tshow, previewUrls, }: MessageBoxProps): ReactElement => { @@ -385,21 +382,12 @@ const MessageBox = ({ suspended={suspended} /> )} - {isEditing && ( - - esc to cancel 路 enter to save - - ) : undefined - } - > - {t('Editing_message')} - - )} - {readOnly && !isEditing && {t('This_room_is_read_only')}} + {isRecordingVideo && } {isRecordingAudio && } diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx new file mode 100644 index 000000000000..4ebe89264d4e --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx @@ -0,0 +1,109 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { E2ERoomState } from '../../../../../app/e2e/client/E2ERoomState'; +import { useRoom } from '../../contexts/RoomContext'; +import { useE2EERoomState } from '../../hooks/useE2EERoomState'; +import MessageBoxHint from './MessageBoxHint'; + +jest.mock('../../hooks/useE2EERoomState', () => ({ + useE2EERoomState: jest.fn(), +})); + +jest.mock('../../contexts/RoomContext', () => ({ + useRoom: jest.fn(), +})); + +const renderOptions = { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Editing_message: 'Editing message', + Editing_message_hint: 'esc to cancel 路 enter to save', + This_room_is_read_only: 'This room is read only', + E2EE_Composer_Unencrypted_Message: "You're sending an unencrypted message", + }) + .build(), + legacyRoot: true, +}; + +describe('MessageBoxHint', () => { + beforeEach(() => { + (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: false }); + (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.WAITING_KEYS); + }); + + describe('Editing message', () => { + it('renders hint text when isEditing is true', () => { + render(, renderOptions); + expect(screen.getByText('Editing message')).toBeInTheDocument(); + expect(screen.getByText('Editing message')).toBeInTheDocument(); + }); + + it('renders helpText when isEditing is true and it is not mobile', () => { + render(, renderOptions); + expect(screen.getByText(/to save/)).toBeInTheDocument(); + }); + + it('renders hint without helpText when isEditing is true and it is mobile', () => { + render(, renderOptions); + expect(screen.queryByText(/to save/)).not.toBeInTheDocument(); + }); + }); + + describe('Read only', () => { + beforeEach(() => { + (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true }); + }); + + it('renders hint text when Read only is true', () => { + render(, renderOptions); + expect(screen.getByText('This room is read only')).toBeInTheDocument(); + }); + }); + + describe('Unencrypted message', () => { + it('renders hint text when E2EE room with unencrypted messages is true', () => { + render(, renderOptions); + expect(screen.getByText("You're sending an unencrypted message")).toBeInTheDocument(); + }); + + it('renders "Read only" hint text when E2EE room with unencrypted messages is true', () => { + (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true }); + + render(, renderOptions); + expect(screen.getByText('This room is read only')).toBeInTheDocument(); + }); + + it('renders "Editing message" hint text when isEditing is truem, E2EE is enabled and unencrypted messages is true', () => { + (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true }); + + render(, renderOptions); + expect(screen.getByText('Editing message')).toBeInTheDocument(); + }); + + it('does not renders hint text when E2ERoomState is READY', () => { + (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.READY); + + render(, renderOptions); + expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); + }); + + it('does not renders hint text when E2ERoomState is DISABLED', () => { + (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.DISABLED); + + render(, renderOptions); + expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); + }); + + it('does not renders hint text when unencrypted messages is true and E2EE is disabled', () => { + render(, renderOptions); + expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); + }); + + it('does not renders hint text when unencrypted messages is false and E2EE is enabled', () => { + render(, renderOptions); + expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx new file mode 100644 index 000000000000..077fecc21516 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx @@ -0,0 +1,60 @@ +import { MessageComposerHint } from '@rocket.chat/ui-composer'; +import type { ReactElement } from 'react'; +import React, { memo } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; + +import { E2ERoomState } from '../../../../../app/e2e/client/E2ERoomState'; +import { useRoom } from '../../contexts/RoomContext'; +import { useE2EERoomState } from '../../hooks/useE2EERoomState'; + +type MessageBoxHintProps = { + isEditing?: boolean; + e2eEnabled?: boolean; + unencryptedMessagesAllowed?: boolean; + isMobile?: boolean; +}; + +const MessageBoxHint = ({ isEditing, e2eEnabled, unencryptedMessagesAllowed, isMobile }: MessageBoxHintProps): ReactElement | null => { + const room = useRoom(); + const isReadOnly = room?.ro || false; + const { t } = useTranslation(); + + const e2eRoomState = useE2EERoomState(room._id); + + const isUnencryptedHintVisible = + e2eEnabled && + unencryptedMessagesAllowed && + e2eRoomState && + e2eRoomState !== E2ERoomState.READY && + e2eRoomState !== E2ERoomState.DISABLED && + !isEditing && + !isReadOnly; + + if (!isEditing && !isUnencryptedHintVisible && !isReadOnly) { + return null; + } + + const renderHintText = (): string => { + if (isEditing) { + return t('Editing_message'); + } + if (isReadOnly) { + return t('This_room_is_read_only'); + } + if (isUnencryptedHintVisible) { + return t('E2EE_Composer_Unencrypted_Message'); + } + return ''; + }; + + return ( + : undefined} + > + {renderHintText()} + + ); +}; + +export default memo(MessageBoxHint); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index a5cb3f542715..371c22b578bf 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1805,6 +1805,7 @@ "E2E_Encryption_enabled_for_room": "End-to-end encryption enabled for #{{roomName}}", "E2E_Encryption_disabled_for_room": "End-to-end encryption disabled for #{{roomName}}", "E2EE_not_available_OTR": "This room has OTR enabled, E2E encryption cannot work with OTR.", + "E2EE_Composer_Unencrypted_Message": "You're sending an unencrypted message", "Markdown_Parser": "Markdown Parser", "Markdown_SupportSchemesForLink": "Markdown Support Schemes for Link", "E2E Encryption_Description": "Keep conversations private, ensuring only the sender and intended recipients are able to read them.", diff --git a/packages/ui-composer/src/MessageComposer/MessageComposer.stories.tsx b/packages/ui-composer/src/MessageComposer/MessageComposer.stories.tsx index 9a0d80b1fcd0..9ee5645e7de7 100644 --- a/packages/ui-composer/src/MessageComposer/MessageComposer.stories.tsx +++ b/packages/ui-composer/src/MessageComposer/MessageComposer.stories.tsx @@ -11,6 +11,7 @@ import { MessageComposerActionsDivider, MessageComposerToolbarSubmit, MessageComposerSkeleton, + MessageComposerHint, } from '.'; export default { @@ -18,44 +19,65 @@ export default { component: MessageComposer, } as ComponentMeta; +const _MessageToolbarActions: ComponentStory = () => ( + + + + + + + + + + + + + + + + + + +); + export const _MessageComposer: ComponentStory = () => ( - - - - - - - - - - - - - + <_MessageToolbarActions /> ); +export const MessageComposerWithHints: ComponentStory = () => ( + <> + + esc to cancel 路 enter to save + + } + > + Editing message + + + + + <_MessageToolbarActions /> + + + + + + +); + export const MessageComposerWithSubmitActions: ComponentStory = () => ( - - - - - - - - - - - - - + <_MessageToolbarActions /> + + + + {Object.entries(displayFilters).map(([value, label], index) => { + if (!label) { + return null; + } + + return ( + removeFilter(value)}> + {label} + + ); + })} + + + ); +}; + +export default ChatFilterByText; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx new file mode 100644 index 000000000000..0fb0e9917505 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatFiltersContextualBar.tsx @@ -0,0 +1,186 @@ +import { Button, ButtonGroup, Field, FieldLabel, FieldRow, InputBox, Select, TextInput } from '@rocket.chat/fuselage'; +import { useEndpoint, usePermission, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { format } from 'date-fns'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import AutoCompleteAgent from '../../../../components/AutoCompleteAgent'; +import AutoCompleteDepartment from '../../../../components/AutoCompleteDepartment'; +import { + ContextualbarHeader, + ContextualbarIcon, + ContextualbarTitle, + ContextualbarClose, + ContextualbarScrollableContent, + ContextualbarFooter, +} from '../../../../components/Contextualbar'; +import { CurrentChatTags } from '../../additionalForms'; +import type { ChatsFiltersQuery } from './useChatsFilters'; +import { useChatsFilters } from './useChatsFilters'; + +type ChatFiltersContextualBarProps = { + onClose: () => void; +}; + +const ChatFiltersContextualBar = ({ onClose }: ChatFiltersContextualBarProps) => { + const t = useTranslation(); + const canViewLivechatRooms = usePermission('view-livechat-rooms'); + const canViewCustomFields = usePermission('view-livechat-room-customfields'); + + const allCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields'); + const { data } = useQuery(['livechat/custom-fields'], async () => allCustomFields()); + const contactCustomFields = data?.customFields.filter((customField) => customField.scope !== 'visitor'); + + const { filtersQuery, setFiltersQuery, resetFiltersQuery } = useChatsFilters(); + const queryClient = useQueryClient(); + + const { + formState: { isDirty }, + handleSubmit, + control, + reset, + } = useForm({ + 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 })); + queryClient.invalidateQueries(['current-chats']); + }; + + const handleResetFilters = () => { + resetFiltersQuery(); + reset(); + }; + + return ( + <> + + + {t('Filters')} + + + + + {t('From')} + + } + /> + + + + {t('To')} + + } + /> + + + {canViewLivechatRooms && ( + + {t('Served_By')} + + } + /> + + + )} + + {t('Status')} + [item, item])} + /> + )} + /> + + + ); + } + + return ( + + {customField.label} + + } + /> + + + ); + })} + + + + + + + + + ); +}; + +export default ChatFiltersContextualBar; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx index 46b5b6784ce0..f24f4779bee3 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatTable.tsx @@ -1,139 +1,93 @@ -import { Tag, Box, Pagination, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; +import { Pagination, States, StatesIcon, StatesTitle, StatesActions, StatesAction } from '@rocket.chat/fuselage'; +import { usePermission, useTranslation } from '@rocket.chat/ui-contexts'; import { hashQueryKey } from '@tanstack/react-query'; -import moment from 'moment'; -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo } from 'react'; -import FilterByText from '../../../../components/FilterByText'; import GenericNoResults from '../../../../components/GenericNoResults/GenericNoResults'; import { GenericTable, GenericTableBody, - GenericTableCell, GenericTableHeader, GenericTableHeaderCell, GenericTableLoadingTable, - GenericTableRow, } from '../../../../components/GenericTable'; import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import { useOmnichannelPriorities } from '../../../../omnichannel/hooks/useOmnichannelPriorities'; import { useCurrentChats } from '../../currentChats/hooks/useCurrentChats'; +import ChatFilterByText from './ChatFilterByText'; +import ChatTableRow from './ChatTableRow'; +import { useChatsFilters } from './useChatsFilters'; +import { useChatsQuery } from './useChatsQuery'; const ChatTable = () => { const t = useTranslation(); - const [text, setText] = useState(''); - const userIdLoggedIn = useUserId(); - const directoryRoute = useRoute('omnichannel-directory'); + const canRemoveClosedChats = usePermission('remove-closed-livechat-room'); + + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + const { filtersQuery: filters } = useChatsFilters(); + const chatsQuery = useChatsQuery(); const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); - const { sortBy, sortDirection, setSort } = useSort<'fname' | 'department' | 'ts' | 'chatDuration' | 'closedAt'>('fname'); + const { sortBy, sortDirection, setSort } = useSort<'fname' | 'priorityWeight' | 'department.name' | 'servedBy' | 'ts' | 'lm' | 'status'>( + 'fname', + ); const query = useMemo( - () => ({ - sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, - roomName: text || '', - agents: userIdLoggedIn ? [userIdLoggedIn] : [], - ...(itemsPerPage && { count: itemsPerPage }), - ...(current && { offset: current }), - }), - [sortBy, current, sortDirection, itemsPerPage, userIdLoggedIn, text], + () => chatsQuery(filters, [sortBy, sortDirection], current, itemsPerPage), + [itemsPerPage, filters, sortBy, sortDirection, current, chatsQuery], ); - const onRowClick = useMutableCallback((id) => - directoryRoute.push({ - tab: 'chats', - context: 'info', - id, - }), - ); + const { data, isLoading, isSuccess, isError, refetch } = useCurrentChats(query); + + const [defaultQuery] = useState(hashQueryKey([query])); + const queryHasChanged = defaultQuery !== hashQueryKey([query]); const headers = ( <> - + {t('Contact_Name')} + {isPriorityEnabled && ( + + {t('Priority')} + + )} {t('Department')} - + + {t('Served_By')} + + {t('Started_At')} - - {t('Chat_Duration')} + + {t('Last_Message')} - - {t('Closed_At')} + + {t('Status')} + {canRemoveClosedChats && } ); - const { data, isLoading, isSuccess, isError, refetch } = useCurrentChats(query); - - const [defaultQuery] = useState(hashQueryKey([query])); - const queryHasChanged = defaultQuery !== hashQueryKey([query]); - - const renderRow = useCallback( - ({ _id, fname, ts, closedAt, department, tags }) => ( - onRowClick(_id)} action qa-user-id={_id}> - - - {fname} - {tags && ( - - {tags.map((tag: string) => ( - 10 ? 'hidden' : 'visible', - textOverflow: 'ellipsis', - }} - key={tag} - mie={4} - > - - {tag} - - - ))} - - )} - - - {department ? department.name : ''} - {moment(ts).format('L LTS')} - {moment(closedAt).from(moment(ts), true)} - {moment(closedAt).format('L LTS')} - - ), - [onRowClick], - ); - return ( <> - {((isSuccess && data?.rooms.length > 0) || queryHasChanged) && } + {isLoading && ( {headers} @@ -156,7 +110,11 @@ const ChatTable = () => { <> {headers} - {data?.rooms.map((room) => renderRow(room))} + + {data?.rooms.map((room) => ( + + ))} + { + const t = useTranslation(); + const { _id, fname, tags, servedBy, ts, lm, department, open, onHold, priorityWeight } = room; + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + const canRemoveClosedChats = usePermission('remove-closed-livechat-room'); + + const directoryRoute = useRoute('omnichannel-directory'); + + 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 = useEffectEvent((id) => + directoryRoute.push({ + tab: 'chats', + context: 'info', + id, + }), + ); + + return ( + onRowClick(_id)} action qa-user-id={_id}> + + + {fname} + {tags && ( + + {tags.map((tag: string) => ( + 10 ? 'hidden' : 'visible', + textOverflow: 'ellipsis', + }} + key={tag} + mie={4} + > + + {tag} + + + ))} + + )} + + + {isPriorityEnabled && ( + + + + )} + {department?.name} + {servedBy?.username} + {moment(ts).format('L LTS')} + {moment(lm).format('L LTS')} + + + {getStatusText(open, onHold)} + + {canRemoveClosedChats && {!open && }} + + ); +}; + +export default ChatTableRow; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts b/apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts new file mode 100644 index 000000000000..0727e08abf98 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/useChatsFilters.ts @@ -0,0 +1,86 @@ +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +import { useFormatDate } from '../../../../hooks/useFormatDate'; + +export type ChatsFiltersQuery = { + guest: string; + servedBy: string; + status: string; + department: string; + from: string; + to: string; + tags: { _id: string; label: string; value: string }[]; + [key: string]: unknown; +}; + +const statusTextMap: { [key: string]: string } = { + all: 'All', + closed: 'Closed', + opened: 'Room_Status_Open', + onhold: 'On_Hold_Chats', + queued: 'Queued', +}; + +const initialValues: ChatsFiltersQuery = { + guest: '', + servedBy: 'all', + status: 'all', + department: 'all', + from: '', + to: '', + tags: [], +}; + +const useDisplayFilters = (filtersQuery: ChatsFiltersQuery) => { + const t = useTranslation(); + const formatDate = useFormatDate(); + + const { guest, servedBy, status, department, from, to, tags, ...customFields } = filtersQuery; + + const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: department }); + const getAgent = useEndpoint('GET', '/v1/livechat/users/agent/:_id', { _id: servedBy }); + + const { data: departmentData } = useQuery(['getDepartmentDataForFilter', department], () => getDepartment({})); + const { data: agentData } = useQuery(['getAgentDataForFilter', servedBy], () => getAgent()); + + 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 !== 'all' ? `${t('Served_By')}: ${agentData?.user.name}` : undefined, + department: department !== 'all' ? `${t('Department')}: ${departmentData?.department.name}` : undefined, + status: status !== 'all' ? `${t('Status')}: ${t(statusTextMap[status] as TranslationKey)}` : undefined, + tags: tags.length > 0 ? tags.map((tag) => `${t('Tag')}: ${tag.label}`) : undefined, + ...displayCustomFields, + }; +}; + +export const useChatsFilters = () => { + const [filtersQuery, setFiltersQuery] = useLocalStorage('conversationsQuery', initialValues); + const displayFilters = useDisplayFilters(filtersQuery); + + const 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 }; + }); + + const removeFilter = (filter: keyof typeof initialValues) => + setFiltersQuery((prevState) => ({ ...prevState, [filter]: initialValues[filter] })); + + return { filtersQuery, setFiltersQuery, resetFiltersQuery, displayFilters, removeFilter }; +}; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts new file mode 100644 index 000000000000..193fd6d72aaa --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts @@ -0,0 +1,94 @@ +import type { GETLivechatRoomsParams } from '@rocket.chat/rest-typings'; +import { usePermission, useUserId } from '@rocket.chat/ui-contexts'; +import moment from 'moment'; + +import type { ChatsFiltersQuery } from './useChatsFilters'; + +type useQueryType = ( + debouncedParams: ChatsFiltersQuery, + [column, direction]: [string, 'asc' | 'desc'], + current: number, + itemsPerPage: 25 | 50 | 100, +) => GETLivechatRoomsParams; + +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; +}; + +const sortDir = (sortDir: 'asc' | 'desc'): 1 | -1 => (sortDir === 'asc' ? 1 : -1); + +export const useChatsQuery = () => { + const userIdLoggedIn = useUserId(); + const canViewLivechatRooms = usePermission('view-livechat-rooms'); + + const chatsQuery: useQueryType = ( + { guest, servedBy, department, status, from, to, tags, ...customFields }, + [column, direction], + current, + itemsPerPage, + ) => { + 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: moment(new Date(from)).set({ hour: 0, minutes: 0, seconds: 0 }).toISOString(), + }), + ...(to && { + end: moment(new Date(to)).set({ hour: 23, minutes: 59, seconds: 59 }).toISOString(), + }), + }); + } + + if (status !== 'all') { + query.open = status === 'opened' || status === 'onhold' || status === 'queued'; + query.onhold = status === 'onhold'; + query.queued = status === 'queued'; + } + + if (canViewLivechatRooms && servedBy && servedBy !== 'all') { + query.agents = [servedBy]; + } else { + query.agents = userIdLoggedIn ? [userIdLoggedIn] : []; + } + + if (department && department !== 'all') { + query.departmentId = department; + } + + if (tags && tags.length > 0) { + query.tags = tags.map((tag) => tag.value); + } + + 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; + }; + + return chatsQuery; +}; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts index a9745288f967..7c3a15c21c38 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts @@ -91,6 +91,7 @@ test.describe('Omnichannel Contact Center', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await poOmniSection.btnContactCenter.click(); + await poOmniSection.tabContacts.click(); await page.waitForURL(URL.contactCenter); }); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts index 87a34a66356e..e1f2cfb23de2 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts @@ -22,4 +22,8 @@ export class OmnichannelSection { get btnContactCenter(): Locator { return this.page.locator('role=button[name="Contact Center"]'); } + + get tabContacts(): Locator { + return this.page.locator('role=tab[name="Contacts"]'); + } } diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index cba7fbede924..16cfa0142d9a 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -1,3 +1,4 @@ +import type { ILivechatDepartment } from './ILivechatDepartment'; import type { ILivechatPriority } from './ILivechatPriority'; import type { ILivechatVisitor } from './ILivechatVisitor'; import type { IMessage, MessageTypesValues } from './IMessage'; @@ -354,6 +355,8 @@ export type IOmnichannelRoomClosingInfo = Pick): room is IOmnichannelRoom & IRoom => room.t === 'l'; export const isVoipRoom = (room: IRoom): room is IVoipRoom & IRoom => room.t === 'v'; diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 8b7fd7162934..d28e11ef3e97 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -9,6 +9,7 @@ import type { ILivechatVisitorDTO, IMessage, IOmnichannelRoom, + IOmnichannelRoomWithDepartment, IRoom, ISetting, ILivechatAgentActivity, @@ -3941,7 +3942,7 @@ export type OmnichannelEndpoints = { DELETE: () => void; }; '/v1/livechat/rooms': { - GET: (params: GETLivechatRoomsParams) => PaginatedResult<{ rooms: IOmnichannelRoom[] }>; + GET: (params: GETLivechatRoomsParams) => PaginatedResult<{ rooms: IOmnichannelRoomWithDepartment[] }>; }; '/v1/livechat/room/:rid/priority': { POST: (params: POSTLivechatRoomPriorityParams) => void; From 094b155205bbfa617da6a9b5f0d99610385f0d93 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Mon, 14 Oct 2024 14:17:40 -0300 Subject: [PATCH 12/12] chore: Get livechat rooms rooms properly on chats table (#33568) --- .../views/omnichannel/directory/chats/useChatsQuery.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts index 193fd6d72aaa..494741d2d794 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts +++ b/apps/meteor/client/views/omnichannel/directory/chats/useChatsQuery.ts @@ -66,10 +66,12 @@ export const useChatsQuery = () => { query.queued = status === 'queued'; } + if (!canViewLivechatRooms) { + query.agents = userIdLoggedIn ? [userIdLoggedIn] : []; + } + if (canViewLivechatRooms && servedBy && servedBy !== 'all') { query.agents = [servedBy]; - } else { - query.agents = userIdLoggedIn ? [userIdLoggedIn] : []; } if (department && department !== 'all') {