diff --git a/app/definitions/IRoom.ts b/app/definitions/IRoom.ts index f63445a5e..6ebb88e0b 100644 --- a/app/definitions/IRoom.ts +++ b/app/definitions/IRoom.ts @@ -4,7 +4,7 @@ import { MarkdownAST } from '@rocket.chat/message-parser'; import { IAttachment } from './IAttachment'; import { IMessage } from './IMessage'; import { IServedBy } from './IServedBy'; -import { SubscriptionType } from './ISubscription'; +import { IVisitor, SubscriptionType } from './ISubscription'; import { IUser } from './IUser'; interface IRequestTranscript { @@ -28,11 +28,8 @@ export interface IRoom { broadcast: boolean; encrypted: boolean; ro: boolean; - v?: { - _id?: string; - token?: string; - status: 'online' | 'busy' | 'away' | 'offline'; - }; + v?: IVisitor; + status?: string; servedBy?: IServedBy; departmentId?: string; livechatData?: any; @@ -55,13 +52,11 @@ export enum OmnichannelSourceType { API = 'api', OTHER = 'other' // catch-all source type } -export interface IOmnichannelRoom extends Omit { +export interface IOmnichannelRoom extends Partial> { + _id: string; + rid: string; t: SubscriptionType.OMNICHANNEL; - v: { - _id?: string; - token?: string; - status: 'online' | 'busy' | 'away' | 'offline'; - }; + v: IVisitor; email?: { // Data used when the room is created from an email, via email Integration. inbox: string; @@ -83,6 +78,8 @@ export interface IOmnichannelRoom extends Omit { - if (searching > 0 || !(RocketChat.isOmnichannelModuleAvailable() && user?.roles?.includes('livechat-agent'))) { +interface IOmnichannelStatus { + searching: boolean; + goQueue: () => void; + queueSize: number; + inquiryEnabled: boolean; + user: IUser; +} + +const OmnichannelStatus = memo(({ searching, goQueue, queueSize, inquiryEnabled, user }: IOmnichannelStatus) => { + if (searching || !(RocketChat.isOmnichannelModuleAvailable() && user?.roles?.includes('livechat-agent'))) { return null; } + const { theme } = useTheme(); const [status, setStatus] = useState(isOmnichannelStatusAvailable(user)); useEffect(() => { @@ -48,16 +57,4 @@ const OmnichannelStatus = memo(({ searching, goQueue, theme, queueSize, inquiryE ); }); -OmnichannelStatus.propTypes = { - searching: PropTypes.bool, - goQueue: PropTypes.func, - queueSize: PropTypes.number, - inquiryEnabled: PropTypes.bool, - theme: PropTypes.string, - user: PropTypes.shape({ - roles: PropTypes.array, - statusLivechat: PropTypes.string - }) -}; - -export default withTheme(OmnichannelStatus); +export default OmnichannelStatus; diff --git a/app/ee/omnichannel/lib/index.js b/app/ee/omnichannel/lib/index.ts similarity index 57% rename from app/ee/omnichannel/lib/index.js rename to app/ee/omnichannel/lib/index.ts index 107bc31d0..c7c45dc65 100644 --- a/app/ee/omnichannel/lib/index.js +++ b/app/ee/omnichannel/lib/index.ts @@ -1,21 +1,24 @@ -import RocketChat from '../../../lib/rocketchat'; +import sdk from '../../../lib/rocketchat/services/sdk'; +import { IUser } from '../../../definitions'; import EventEmitter from '../../../utils/events'; import subscribeInquiry from './subscriptions/inquiry'; -export const isOmnichannelStatusAvailable = user => user?.statusLivechat === 'available'; +export const isOmnichannelStatusAvailable = (user: IUser): boolean => user?.statusLivechat === 'available'; // RC 0.26.0 -export const changeLivechatStatus = () => RocketChat.methodCallWrapper('livechat:changeLivechatStatus'); +export const changeLivechatStatus = () => sdk.methodCallWrapper('livechat:changeLivechatStatus'); // RC 2.4.0 -export const getInquiriesQueued = () => RocketChat.sdk.get('livechat/inquiries.queued'); +// @ts-ignore +export const getInquiriesQueued = () => sdk.get('livechat/inquiries.queued'); // this inquiry is added to the db by the subscriptions stream // and will be removed by the queue stream // RC 2.4.0 -export const takeInquiry = inquiryId => RocketChat.methodCallWrapper('livechat:takeInquiry', inquiryId); +export const takeInquiry = (inquiryId: string) => sdk.methodCallWrapper('livechat:takeInquiry', inquiryId); class Omnichannel { + private inquirySub: { stop: () => void } | null; constructor() { this.inquirySub = null; EventEmitter.addEventListener('INQUIRY_SUBSCRIBE', this.subscribeInquiry); @@ -36,5 +39,5 @@ class Omnichannel { }; } -// eslint-disable-next-line no-unused-vars +// eslint-disable-next-line @typescript-eslint/no-unused-vars const omnichannel = new Omnichannel(); diff --git a/app/ee/omnichannel/lib/subscriptions/inquiry.js b/app/ee/omnichannel/lib/subscriptions/inquiry.ts similarity index 59% rename from app/ee/omnichannel/lib/subscriptions/inquiry.js rename to app/ee/omnichannel/lib/subscriptions/inquiry.ts index 4e6cda682..4201fb2a9 100644 --- a/app/ee/omnichannel/lib/subscriptions/inquiry.js +++ b/app/ee/omnichannel/lib/subscriptions/inquiry.ts @@ -2,11 +2,28 @@ import log from '../../../../utils/log'; import { store } from '../../../../lib/auxStore'; import RocketChat from '../../../../lib/rocketchat'; import { inquiryQueueAdd, inquiryQueueRemove, inquiryQueueUpdate, inquiryRequest } from '../../actions/inquiry'; +import sdk from '../../../../lib/rocketchat/services/sdk'; +import { ILivechatDepartment } from '../../../../definitions/ILivechatDepartment'; +import { IOmnichannelRoom } from '../../../../definitions'; -const removeListener = listener => listener.stop(); +interface IArgsQueueOmnichannel extends IOmnichannelRoom { + type: string; +} -let connectedListener; -let queueListener; +interface IDdpMessage { + msg: string; + collection: string; + id: string; + fields: { + eventName: string; + args: IArgsQueueOmnichannel[]; + }; +} + +const removeListener = (listener: any) => listener.stop(); + +let connectedListener: any; +let queueListener: any; const streamTopic = 'stream-livechat-inquiry-queue-observer'; @@ -15,7 +32,7 @@ export default function subscribeInquiry() { store.dispatch(inquiryRequest()); }; - const handleQueueMessageReceived = ddpMessage => { + const handleQueueMessageReceived = (ddpMessage: IDdpMessage) => { const [{ type, ...sub }] = ddpMessage.fields.args; // added can be ignored, since it is handled by 'changed' event @@ -26,7 +43,9 @@ export default function subscribeInquiry() { // if the sub isn't on the queue anymore if (sub.status !== 'queued') { // remove it from the queue - store.dispatch(inquiryQueueRemove(sub._id)); + if (sub._id) { + store.dispatch(inquiryQueueRemove(sub._id)); + } return; } @@ -53,23 +72,28 @@ export default function subscribeInquiry() { } }; - connectedListener = RocketChat.onStreamData('connected', handleConnection); - queueListener = RocketChat.onStreamData(streamTopic, handleQueueMessageReceived); + connectedListener = sdk.onStreamData('connected', handleConnection); + queueListener = sdk.onStreamData(streamTopic, handleQueueMessageReceived); try { const { user } = store.getState().login; - RocketChat.getAgentDepartments(user.id).then(result => { + + if (!user.id) { + throw new Error('inquiry: @subscribeInquiry user.id not found'); + } + + RocketChat.getAgentDepartments(user.id).then((result: { success: boolean; departments: ILivechatDepartment[] }) => { if (result.success) { const { departments } = result; if (!departments.length || RocketChat.hasRole('livechat-manager')) { - RocketChat.subscribe(streamTopic, 'public').catch(e => console.log(e)); + sdk.subscribe(streamTopic, 'public').catch((e: unknown) => console.log(e)); } const departmentIds = departments.map(({ departmentId }) => departmentId); departmentIds.forEach(departmentId => { // subscribe to all departments of the agent - RocketChat.subscribe(streamTopic, `department/${departmentId}`).catch(e => console.log(e)); + sdk.subscribe(streamTopic, `department/${departmentId}`).catch((e: unknown) => console.log(e)); }); } }); diff --git a/app/ee/omnichannel/reducers/inquiry.test.ts b/app/ee/omnichannel/reducers/inquiry.test.ts new file mode 100644 index 000000000..63c0b3973 --- /dev/null +++ b/app/ee/omnichannel/reducers/inquiry.test.ts @@ -0,0 +1,98 @@ +import { + inquiryFailure, + inquiryQueueAdd, + inquiryQueueRemove, + inquiryQueueUpdate, + inquiryReset, + inquirySetEnabled, + inquirySuccess +} from '../actions/inquiry'; +import { mockedStore } from '../../../reducers/mockedStore'; +import { initialState } from './inquiry'; +import { IOmnichannelRoom, OmnichannelSourceType, SubscriptionType } from '../../../definitions'; + +describe('test inquiry reduce', () => { + const enabledObj = { + enabled: true + }; + + const queued: IOmnichannelRoom = { + _id: '_id', + rid: 'rid', + name: 'Rocket Chat', + ts: new Date(), + message: 'ola', + status: 'queued', + v: { + _id: 'id-visitor', + username: 'guest-24', + token: '123456789', + status: 'online' + }, + t: SubscriptionType.OMNICHANNEL, + queueOrder: '1', + estimatedWaitingTimeQueue: '0', + estimatedServiceTimeAt: new Date(), + source: { + type: OmnichannelSourceType.WIDGET, + _updatedAt: new Date(), + queuedAt: new Date() + } + }; + + const error = 'Error Test'; + + it('should return inital state', () => { + const state = mockedStore.getState().inquiry; + expect(state).toEqual(initialState); + }); + + it('should return correct inquiry state after dispatch inquirySetEnabled action', () => { + mockedStore.dispatch(inquirySetEnabled(enabledObj.enabled)); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual({ ...initialState, ...enabledObj }); + }); + + it('after inquiry state is modified, should return inquiry state as initial state after dispatch inquiryReset action', () => { + mockedStore.dispatch(inquiryReset()); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual(initialState); + }); + + it('should return correct inquiry state after dispatch inquiryQueueAdd action', () => { + mockedStore.dispatch(inquiryQueueAdd(queued)); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual({ ...initialState, queued: [queued] }); + }); + + it('should update correct inquiry state after dispatch inquiryQueueUpdate action', () => { + const modifiedQueued: IOmnichannelRoom = { ...queued, message: 'inquiryQueueUpdate' }; + mockedStore.dispatch(inquiryQueueUpdate(modifiedQueued)); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual({ ...initialState, queued: [modifiedQueued] }); + }); + + it('should remove correct from queue in inquiry state after dispatch inquiryQueueRemove action', () => { + mockedStore.dispatch(inquiryQueueRemove(queued._id)); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual(initialState); + }); + + it('should return correct inquiry state after dispatch inquirySuccess action', () => { + mockedStore.dispatch(inquirySuccess([queued])); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual({ ...initialState, queued: [queued] }); + }); + + it('after inquiry state is modified, should return inquiry state as initial state after dispatch inquiryReset action', () => { + mockedStore.dispatch(inquiryReset()); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual(initialState); + }); + + it('should return correct inquiry state after dispatch inquiryFailure action', () => { + mockedStore.dispatch(inquiryFailure(error)); + const { inquiry } = mockedStore.getState(); + expect(inquiry).toEqual({ ...initialState, error }); + }); +}); diff --git a/app/ee/omnichannel/reducers/inquiry.js b/app/ee/omnichannel/reducers/inquiry.ts similarity index 74% rename from app/ee/omnichannel/reducers/inquiry.js rename to app/ee/omnichannel/reducers/inquiry.ts index 8c031d55d..b9026a5a9 100644 --- a/app/ee/omnichannel/reducers/inquiry.js +++ b/app/ee/omnichannel/reducers/inquiry.ts @@ -1,12 +1,19 @@ +import { IOmnichannelRoom, TApplicationActions } from '../../../definitions'; import { INQUIRY } from '../../../actions/actionsTypes'; -const initialState = { +export interface IInquiry { + enabled: boolean; + queued: IOmnichannelRoom[]; + error: any; +} + +export const initialState: IInquiry = { enabled: false, queued: [], error: {} }; -export default function inquiry(state = initialState, action) { +export default function inquiry(state = initialState, action: TApplicationActions): IInquiry { switch (action.type) { case INQUIRY.SUCCESS: return { diff --git a/app/ee/omnichannel/selectors/inquiry.js b/app/ee/omnichannel/selectors/inquiry.ts similarity index 50% rename from app/ee/omnichannel/selectors/inquiry.js rename to app/ee/omnichannel/selectors/inquiry.ts index ada81fd9e..f3a7ac2a1 100644 --- a/app/ee/omnichannel/selectors/inquiry.js +++ b/app/ee/omnichannel/selectors/inquiry.ts @@ -1,5 +1,7 @@ import { createSelector } from 'reselect'; -const getInquiryQueue = state => state.inquiry.queued; +import { IApplicationState } from '../../../definitions'; + +const getInquiryQueue = (state: IApplicationState) => state.inquiry.queued; export const getInquiryQueueSelector = createSelector([getInquiryQueue], queue => queue); diff --git a/app/ee/omnichannel/views/QueueListView.js b/app/ee/omnichannel/views/QueueListView.tsx similarity index 63% rename from app/ee/omnichannel/views/QueueListView.js rename to app/ee/omnichannel/views/QueueListView.tsx index 5d537ceef..bb898e13f 100644 --- a/app/ee/omnichannel/views/QueueListView.js +++ b/app/ee/omnichannel/views/QueueListView.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { FlatList } from 'react-native'; +import { CompositeNavigationProp } from '@react-navigation/native'; +import { StackNavigationOptions, StackNavigationProp } from '@react-navigation/stack'; +import { FlatList, ListRenderItem } from 'react-native'; import { connect } from 'react-redux'; import { dequal } from 'dequal'; @@ -19,18 +20,50 @@ import * as HeaderButton from '../../../containers/HeaderButton'; import RocketChat from '../../../lib/rocketchat'; import { events, logEvent } from '../../../utils/log'; import { getInquiryQueueSelector } from '../selectors/inquiry'; +import { IOmnichannelRoom, IApplicationState } from '../../../definitions'; +import { DisplayMode } from '../../../constants/constantDisplayMode'; +import { ChatsStackParamList } from '../../../stacks/types'; +import { MasterDetailInsideStackParamList } from '../../../stacks/MasterDetailStack/types'; +import { TSettingsValues } from '../../../reducers/settings'; + +interface INavigationOptions { + isMasterDetail: boolean; + navigation: CompositeNavigationProp< + StackNavigationProp, + StackNavigationProp + >; +} + +interface IQueueListView extends INavigationOptions { + user: { + id: string; + username: string; + token: string; + }; + width: number; + queued: IOmnichannelRoom[]; + server: string; + useRealName?: TSettingsValues; + theme: string; + showAvatar: any; + displayMode: DisplayMode; +} const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12; -const getItemLayout = (data, index) => ({ +const getItemLayout = (data: IOmnichannelRoom[] | null | undefined, index: number) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index }); -const keyExtractor = item => item.rid; +const keyExtractor = (item: IOmnichannelRoom) => item.rid; -class QueueListView extends React.Component { - static navigationOptions = ({ navigation, isMasterDetail }) => { - const options = { +class QueueListView extends React.Component { + private getScrollRef?: React.Ref>; + + private onEndReached: ((info: { distanceFromEnd: number }) => void) | null | undefined; + + static navigationOptions = ({ navigation, isMasterDetail }: INavigationOptions) => { + const options: StackNavigationOptions = { title: I18n.t('Queued_chats') }; if (isMasterDetail) { @@ -39,24 +72,7 @@ class QueueListView extends React.Component { return options; }; - static propTypes = { - user: PropTypes.shape({ - id: PropTypes.string, - username: PropTypes.string, - token: PropTypes.string - }), - isMasterDetail: PropTypes.bool, - width: PropTypes.number, - queued: PropTypes.array, - server: PropTypes.string, - useRealName: PropTypes.bool, - navigation: PropTypes.object, - theme: PropTypes.string, - showAvatar: PropTypes.bool, - displayMode: PropTypes.string - }; - - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps: IQueueListView) { const { queued } = this.props; if (!dequal(nextProps.queued, queued)) { return true; @@ -65,7 +81,7 @@ class QueueListView extends React.Component { return false; } - onPressItem = (item = {}) => { + onPressItem = (item = {} as IOmnichannelRoom) => { logEvent(events.QL_GO_ROOM); const { navigation, isMasterDetail } = this.props; if (isMasterDetail) { @@ -84,13 +100,13 @@ class QueueListView extends React.Component { }); }; - getRoomTitle = item => RocketChat.getRoomTitle(item); + getRoomTitle = (item: IOmnichannelRoom) => RocketChat.getRoomTitle(item); - getRoomAvatar = item => RocketChat.getRoomAvatar(item); + getRoomAvatar = (item: IOmnichannelRoom) => RocketChat.getRoomAvatar(item); - getUidDirectMessage = room => RocketChat.getUidDirectMessage(room); + getUidDirectMessage = (room: IOmnichannelRoom) => RocketChat.getUidDirectMessage(room); - renderItem = ({ item }) => { + renderItem: ListRenderItem = ({ item }) => { const { user: { id: userId, username, token }, server, @@ -152,7 +168,7 @@ class QueueListView extends React.Component { } } -const mapStateToProps = state => ({ +const mapStateToProps = (state: IApplicationState) => ({ user: getUserSelector(state), isMasterDetail: state.app.isMasterDetail, server: state.server.server, diff --git a/app/utils/goRoom.ts b/app/utils/goRoom.ts index f295dfafd..64881abc1 100644 --- a/app/utils/goRoom.ts +++ b/app/utils/goRoom.ts @@ -1,7 +1,7 @@ import { ChatsStackParamList } from '../stacks/types'; import Navigation from '../lib/Navigation'; import RocketChat from '../lib/rocketchat'; -import { ISubscription, IVisitor, SubscriptionType, TSubscriptionModel } from '../definitions/ISubscription'; +import { IOmnichannelRoom, SubscriptionType, IVisitor, TSubscriptionModel, ISubscription } from '../definitions'; interface IGoRoomItem { search?: boolean; // comes from spotlight @@ -13,7 +13,7 @@ interface IGoRoomItem { visitor?: IVisitor; } -export type TGoRoomItem = IGoRoomItem | TSubscriptionModel | ISubscription; +export type TGoRoomItem = IGoRoomItem | TSubscriptionModel | ISubscription | IOmnichannelRoomVisitor; const navigate = ({ item, @@ -42,6 +42,11 @@ const navigate = ({ }); }; +interface IOmnichannelRoomVisitor extends IOmnichannelRoom { + // this visitor came from ee/omnichannel/views/QueueListView + visitor: IVisitor; +} + export const goRoom = async ({ item, isMasterDetail = false, diff --git a/app/views/definition/ILivechatDepartment.ts b/app/views/definition/ILivechatDepartment.ts deleted file mode 100644 index 22c352007..000000000 --- a/app/views/definition/ILivechatDepartment.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface ILivechatDepartment { - _id: string; - name: string; - enabled: boolean; - description: string; - showOnRegistration: boolean; - showOnOfflineForm: boolean; - requestTagBeforeClosingChat: boolean; - email: string; - chatClosingTags: string[]; - offlineMessageChannelName: string; - numAgents: number; - _updatedAt?: Date; - businessHourId?: string; -}