From d3db5477e0a9359d36a9538ce6b2d7fbc6d44fe4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 20 Apr 2022 09:11:22 -0600 Subject: [PATCH 01/10] [IMPROVE] Add support for filters on omnichannel/extensions endpoint --- .../app/api/server/v1/voip/omnichannel.ts | 29 +++- apps/meteor/app/models/server/raw/Users.js | 8 +- .../AutoCompleteAgentWithoutExtension.tsx | 7 +- .../settings/groups/voip/AssignAgentModal.tsx | 5 +- packages/rest-typings/src/v1/voip.ts | 155 +++++++----------- 5 files changed, 94 insertions(+), 110 deletions(-) diff --git a/apps/meteor/app/api/server/v1/voip/omnichannel.ts b/apps/meteor/app/api/server/v1/voip/omnichannel.ts index f74be2171438..0a1d614e6855 100644 --- a/apps/meteor/app/api/server/v1/voip/omnichannel.ts +++ b/apps/meteor/app/api/server/v1/voip/omnichannel.ts @@ -1,5 +1,5 @@ import { Match, check } from 'meteor/check'; -import type { IUser } from '@rocket.chat/core-typings'; +import { IUser, IVoipExtensionWithAgentInfo } from '@rocket.chat/core-typings'; import { API } from '../../api'; import { Users } from '../../../../models/server/raw/index'; @@ -7,6 +7,21 @@ import { hasPermission } from '../../../../authorization/server/index'; import { LivechatVoip } from '../../../../../server/sdk'; import { logger } from './logger'; +function filter( + array: IVoipExtensionWithAgentInfo[], + { queues, extension, agentId, status }: { queues?: string[]; extension?: string; agentId?: string; status?: string }, +): IVoipExtensionWithAgentInfo[] { + const defaultFunc = (): boolean => true; + return array.filter((item) => { + const queuesCond = queues && Array.isArray(queues) ? (): boolean => item.queues?.some((q) => queues.includes(q)) || false : defaultFunc; + const extensionCond = extension?.trim() ? (): boolean => item?.extension === extension : defaultFunc; + const agentIdCond = agentId?.trim() ? (): boolean => item?.userId === agentId : defaultFunc; + const statusCond = status?.trim() ? (): boolean => item?.state === status : defaultFunc; + + return queuesCond() && extensionCond() && agentIdCond() && statusCond(); + }); +} + function paginate(array: T[], count = 10, offset = 0): T[] { return array.slice(offset, offset + count); } @@ -204,14 +219,22 @@ API.v1.addRoute( { async get() { const { offset, count } = this.getPaginationItems(); + const { status, agentId, queues, extension } = this.requestParams(); + + check(status, Match.Maybe(String)); + check(agentId, Match.Maybe(String)); + check(queues, Match.Maybe([String])); + check(extension, Match.Maybe(String)); + const extensions = await LivechatVoip.getExtensionListWithAgentData(); + const filteredExts = filter(extensions, { status, agentId, queues, extension }); // paginating in memory as Asterisk doesn't provide pagination for commands return API.v1.success({ - extensions: paginate(extensions, count, offset), + extensions: paginate(filteredExts, count, offset), offset, count, - total: extensions.length, + total: filteredExts.length, }); }, }, diff --git a/apps/meteor/app/models/server/raw/Users.js b/apps/meteor/app/models/server/raw/Users.js index 5418fe07b413..29dfd1c3a49f 100644 --- a/apps/meteor/app/models/server/raw/Users.js +++ b/apps/meteor/app/models/server/raw/Users.js @@ -990,14 +990,10 @@ export class UsersRaw extends BaseRaw { const query = { roles: { $in: ['livechat-agent', 'livechat-manager', 'livechat-monitor'] }, $and: [ - { $or: [...(includeExt ? [{ extension: includeExt }] : []), { extension: { $exists: false } }] }, ...(text && text.trim() - ? [ - { - $or: [{ username: escapeRegExp(text) }, { name: escapeRegExp(text) }], - }, - ] + ? [{ $or: [{ username: new RegExp(escapeRegExp(text), 'i') }, { name: new RegExp(escapeRegExp(text), 'i') }] }] : []), + { $or: [{ extension: { $exists: false } }, ...(includeExt ? [{ extension: includeExt }] : [])] }, ], }; diff --git a/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx b/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx index 7d181cefe315..dac851d653b1 100644 --- a/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx +++ b/apps/meteor/client/components/AutoCompleteAgentWithoutExtension.tsx @@ -1,4 +1,4 @@ -import type { ILivechatAgent } from '@rocket.chat/core-typings'; +import { ILivechatAgent } from '@rocket.chat/core-typings'; import { PaginatedSelectFiltered } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import React, { FC, memo, useMemo, useState } from 'react'; @@ -9,7 +9,6 @@ import { useAvailableAgentsList } from './Omnichannel/hooks/useAvailableAgentsLi type AutoCompleteAgentProps = { onChange: (value: string) => void; - empty: boolean; haveAll?: boolean; value?: string; currentExtension?: string; @@ -55,9 +54,7 @@ const AutoCompleteAgentWithoutExtension: FC = (props) => onChange={onChange} flexShrink={0} filter={agentsFilter as string | undefined} - setFilter={(value?: string | number): void => { - setAgentsFilter(value); - }} + setFilter={setAgentsFilter} options={sortedByName} endReached={ agentsPhase === AsyncStatePhase.LOADING ? (): void => undefined : (start): void => loadMoreAgents(start, Math.min(50, agentsTotal)) diff --git a/apps/meteor/client/views/admin/settings/groups/voip/AssignAgentModal.tsx b/apps/meteor/client/views/admin/settings/groups/voip/AssignAgentModal.tsx index a74425b2e266..0afdb77abe0a 100644 --- a/apps/meteor/client/views/admin/settings/groups/voip/AssignAgentModal.tsx +++ b/apps/meteor/client/views/admin/settings/groups/voip/AssignAgentModal.tsx @@ -28,11 +28,12 @@ const AssignAgentModal: FC = ({ existingExtension, close try { await assignAgent({ username: agent, extension }); } catch (error) { - dispatchToastMessage({ type: 'error', message: error.message }); + dispatchToastMessage({ type: 'error', message: (error as Error).message }); } reload(); closeModal(); }); + const handleAgentChange = useMutableCallback((e) => setAgent(e)); const { value: availableExtensions, phase: state } = useEndpointData('omnichannel/extension', query); @@ -47,7 +48,7 @@ const AssignAgentModal: FC = ({ existingExtension, close {t('Agent_Without_Extensions')} - + diff --git a/packages/rest-typings/src/v1/voip.ts b/packages/rest-typings/src/v1/voip.ts index 436c80f5c3cb..9116befeadae 100644 --- a/packages/rest-typings/src/v1/voip.ts +++ b/packages/rest-typings/src/v1/voip.ts @@ -14,98 +14,65 @@ import type { PaginatedRequest } from "../helpers/PaginatedRequest"; import type { PaginatedResult } from "../helpers/PaginatedResult"; export type VoipEndpoints = { - "connector.extension.getRegistrationInfoByUserId": { - GET: (params: { id: string }) => IRegistrationInfo | { result: string }; - }; - "voip/queues.getSummary": { - GET: () => { summary: IQueueSummary[] }; - }; - "voip/queues.getQueuedCallsForThisExtension": { - GET: (params: { extension: string }) => IQueueMembershipDetails; - }; - "voip/queues.getMembershipSubscription": { - GET: (params: { extension: string }) => IQueueMembershipSubscription; - }; - "omnichannel/extensions": { - GET: ( - params: PaginatedRequest - ) => PaginatedResult & { extensions: IVoipExtensionWithAgentInfo[] }; - }; - "omnichannel/extension": { - GET: ( - params: - | { userId: string; type: "free" | "allocated" | "available" } - | { username: string; type: "free" | "allocated" | "available" } - ) => { - extensions: string[]; - }; - }; - "omnichannel/agent/extension": { - GET: (params: { username: string }) => { - extension: Pick; - }; - POST: ( - params: - | { userId: string; extension: string } - | { username: string; extension: string } - ) => void; - DELETE: (params: { username: string }) => void; - }; - "omnichannel/agents/available": { - GET: ( - params: PaginatedRequest<{ text?: string; includeExtension?: string }> - ) => PaginatedResult<{ agents: ILivechatAgent[] }>; - }; - "voip/events": { - POST: (params: { - event: VoipClientEvents; - rid: string; - comment?: string; - }) => void; - }; - "voip/room": { - GET: ( - params: - | { token: string; agentId: ILivechatAgent["_id"] } - | { rid: string; token: string } - ) => { - room: IVoipRoom; - newRoom: boolean; - }; - }; - "voip/managementServer/checkConnection": { - GET: (params: { - host: string; - port: string; - username: string; - password: string; - }) => IManagementServerConnectionStatus; - }; - "voip/callServer/checkConnection": { - GET: (params: { - websocketUrl: string; - host: string; - port: string; - path: string; - }) => IManagementServerConnectionStatus; - }; - "voip/rooms": { - GET: (params: { - agents?: string[]; - open?: boolean; - createdAt?: string; - closedAt?: string; - tags?: string[]; - queue?: string; - visitorId?: string; - }) => PaginatedResult<{ rooms: IVoipRoom[] }>; - }; - "voip/room.close": { - POST: (params: { - rid: string; - token: string; - comment: string; - tags?: string[]; - }) => { rid: string }; - }; + 'connector.extension.getRegistrationInfoByUserId': { + GET: (params: { id: string }) => IRegistrationInfo | { result: string }; + }; + 'voip/queues.getSummary': { + GET: () => { summary: IQueueSummary[] }; + }; + 'voip/queues.getQueuedCallsForThisExtension': { + GET: (params: { extension: string }) => IQueueMembershipDetails; + }; + 'voip/queues.getMembershipSubscription': { + GET: (params: { extension: string }) => IQueueMembershipSubscription; + }; + 'omnichannel/extensions': { + GET: ( + params: PaginatedRequest<{ status?: string; agentId?: string; queues?: string[]; extension?: string }>, + ) => PaginatedResult<{ extensions: IVoipExtensionWithAgentInfo[] }>; + }; + 'omnichannel/extension': { + GET: ( + params: { userId: string; type: 'free' | 'allocated' | 'available' } | { username: string; type: 'free' | 'allocated' | 'available' }, + ) => { + extensions: string[]; + }; + }; + 'omnichannel/agent/extension': { + GET: (params: { username: string }) => { extension: Pick }; + POST: (params: { userId: string; extension: string } | { username: string; extension: string }) => void; + DELETE: (params: { username: string }) => void; + }; + 'omnichannel/agents/available': { + GET: (params: PaginatedRequest<{ text?: string; includeExtension?: string }>) => PaginatedResult<{ agents: ILivechatAgent[] }>; + }; + 'voip/events': { + POST: (params: { event: VoipClientEvents; rid: string; comment?: string }) => void; + }; + 'voip/room': { + GET: (params: { token: string; agentId: ILivechatAgent['_id'] } | { rid: string; token: string }) => { + room: IVoipRoom; + newRoom: boolean; + }; + }; + 'voip/managementServer/checkConnection': { + GET: (params: { host: string; port: string; username: string; password: string }) => IManagementServerConnectionStatus; + }; + 'voip/callServer/checkConnection': { + GET: (params: { websocketUrl: string; host: string; port: string; path: string }) => IManagementServerConnectionStatus; + }; + 'voip/rooms': { + GET: (params: { + agents?: string[]; + open?: boolean; + createdAt?: string; + closedAt?: string; + tags?: string[]; + queue?: string; + visitorId?: string; + }) => PaginatedResult<{ rooms: IVoipRoom[] }>; + }; + 'voip/room.close': { + POST: (params: { rid: string; token: string; comment: string; tags?: string[] }) => { rid: string }; + }; }; From 3357dc93e68b82ec8288d37fa426d3e4f9fc100d Mon Sep 17 00:00:00 2001 From: Gabriel Freitas Date: Wed, 20 Apr 2022 12:29:46 -0300 Subject: [PATCH 02/10] Chore: update OTR icon (#24521) * Replace shredder icon by stopwatch icon * stopwatch icon for otr msg * remove old otr css * remove some unnecessary files Co-authored-by: yash-rajpal Co-authored-by: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Co-authored-by: Tasso Evangelista --- apps/meteor/app/otr/client/index.js | 1 - apps/meteor/app/otr/client/stylesheets/otr.css | 16 ---------------- apps/meteor/app/otr/client/tabBar.ts | 2 +- apps/meteor/app/ui-master/public/icons.svg | 3 +++ .../app/ui-master/public/icons/stopwatch.svg | 3 +++ apps/meteor/app/ui-message/client/message.html | 3 +++ apps/meteor/app/ui-message/client/message.js | 4 ++++ .../client/views/room/contextualBar/OTR/OTR.js | 2 +- .../packages/rocketchat-i18n/i18n/en.i18n.json | 1 + apps/meteor/private/public/icons.svg | 3 +++ 10 files changed, 19 insertions(+), 19 deletions(-) delete mode 100644 apps/meteor/app/otr/client/stylesheets/otr.css create mode 100644 apps/meteor/app/ui-master/public/icons/stopwatch.svg diff --git a/apps/meteor/app/otr/client/index.js b/apps/meteor/app/otr/client/index.js index 2f503d293511..5a0f82552fba 100644 --- a/apps/meteor/app/otr/client/index.js +++ b/apps/meteor/app/otr/client/index.js @@ -1,4 +1,3 @@ -import './stylesheets/otr.css'; import './rocketchat.otr.room'; import './rocketchat.otr'; import './tabBar'; diff --git a/apps/meteor/app/otr/client/stylesheets/otr.css b/apps/meteor/app/otr/client/stylesheets/otr.css deleted file mode 100644 index 066075e5ec16..000000000000 --- a/apps/meteor/app/otr/client/stylesheets/otr.css +++ /dev/null @@ -1,16 +0,0 @@ -.message { - &.otr-ack { - .info { - color: lightgreen; - - &::before { - display: inline-block; - visibility: visible; - - content: "\e952"; - - font-family: 'fontello'; - } - } - } -} diff --git a/apps/meteor/app/otr/client/tabBar.ts b/apps/meteor/app/otr/client/tabBar.ts index abf8bf4bbcaf..213aaf78980d 100644 --- a/apps/meteor/app/otr/client/tabBar.ts +++ b/apps/meteor/app/otr/client/tabBar.ts @@ -27,7 +27,7 @@ addAction('otr', () => { groups: ['direct'], id: 'otr', title: 'OTR', - icon: 'shredder', + icon: 'stopwatch', template, order: 13, full: true, diff --git a/apps/meteor/app/ui-master/public/icons.svg b/apps/meteor/app/ui-master/public/icons.svg index 337960432a6a..4ffaa8296f64 100644 --- a/apps/meteor/app/ui-master/public/icons.svg +++ b/apps/meteor/app/ui-master/public/icons.svg @@ -339,6 +339,9 @@ + + + diff --git a/apps/meteor/app/ui-master/public/icons/stopwatch.svg b/apps/meteor/app/ui-master/public/icons/stopwatch.svg new file mode 100644 index 000000000000..39436bf10952 --- /dev/null +++ b/apps/meteor/app/ui-master/public/icons/stopwatch.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/meteor/app/ui-message/client/message.html b/apps/meteor/app/ui-message/client/message.html index 945e85615693..2dec6969133c 100644 --- a/apps/meteor/app/ui-message/client/message.html +++ b/apps/meteor/app/ui-message/client/message.html @@ -74,6 +74,9 @@ {{#if showStar}} {{/if}} + {{#if showOtrAck}} + {{> icon icon="stopwatch" }} + {{/if}} {{#if following }} {{#unless msg.tcount}} diff --git a/apps/meteor/app/ui-message/client/message.js b/apps/meteor/app/ui-message/client/message.js index a68353f6f8a4..94960311da90 100644 --- a/apps/meteor/app/ui-message/client/message.js +++ b/apps/meteor/app/ui-message/client/message.js @@ -260,6 +260,10 @@ Template.message.helpers({ ); } }, + showOtrAck() { + const { msg } = this; + return msg.t === 'otr-ack'; + }, translationProvider() { const instance = Template.instance(); const { translationProvider } = instance.data.msg; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.js b/apps/meteor/client/views/room/contextualBar/OTR/OTR.js index 9622b4245d7d..fc276aa4e26e 100644 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTR.js +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTR.js @@ -10,7 +10,7 @@ const OTR = ({ isEstablishing, isEstablished, isOnline, onClickClose, onClickSta return ( <> - + {t('OTR')} {onClickClose && } diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index bd882f31d60f..fa36bdea6a58 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3344,6 +3344,7 @@ "Others": "Others", "OTR": "OTR", "OTR_Enable_Description": "Enable option to use off-the-record (OTR) messages in direct messages between 2 users. OTR messages are not recorded on the server and exchanged directly and encrypted between the 2 users.", + "OTR_message": "OTR Message", "OTR_is_only_available_when_both_users_are_online": "OTR is only available when both users are online", "Out_of_seats": "Out of Seats", "Outgoing": "Outgoing", diff --git a/apps/meteor/private/public/icons.svg b/apps/meteor/private/public/icons.svg index 337960432a6a..4ffaa8296f64 100644 --- a/apps/meteor/private/public/icons.svg +++ b/apps/meteor/private/public/icons.svg @@ -339,6 +339,9 @@ + + + From 7a13a88c689861ca465c8c65bd92dccc5048a2bd Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Wed, 20 Apr 2022 11:48:47 -0500 Subject: [PATCH 03/10] [NEW] Add expire index to integration history (#25087) --- apps/meteor/app/models/server/raw/IntegrationHistory.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/models/server/raw/IntegrationHistory.ts b/apps/meteor/app/models/server/raw/IntegrationHistory.ts index 5963fafec63b..1fcf4230586a 100644 --- a/apps/meteor/app/models/server/raw/IntegrationHistory.ts +++ b/apps/meteor/app/models/server/raw/IntegrationHistory.ts @@ -4,7 +4,10 @@ import { BaseRaw, IndexSpecification } from './BaseRaw'; export class IntegrationHistoryRaw extends BaseRaw { protected modelIndexes(): IndexSpecification[] { - return [{ key: { 'integration._id': 1, 'integration._createdBy._id': 1 } }]; + return [ + { key: { 'integration._id': 1, 'integration._createdBy._id': 1 } }, + { key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 60 }, + ]; } removeByIntegrationId(integrationId: string): ReturnType['deleteMany']> { From 3cea3f39c5e7a6e554e830cc10621ec215a16e10 Mon Sep 17 00:00:00 2001 From: amolghode1981 <86001342+amolghode1981@users.noreply.github.com> Date: Wed, 20 Apr 2022 22:37:45 +0530 Subject: [PATCH 04/10] [FIX] VoIP disabled/enabled sequence puts voip agent in error state (#25230) * Clickup Task: https://app.clickup.com/t/22rjza5 Description: The issue occurs because when client turns on VoIP, server starts the connector in apps/meteor/app/voip/server/startup.ts. While the AMI connection is not established, client makes a call to API api/v1/connector.extension.getRegistrationInfoByUserId?id=. This requires the connection to be up. But because the connection establishment is not yet complete, apps/meteor/server/services/voip/connector/asterisk/CommandHandler.ts thrown an error. Solution : In apps/meteor/app/voip/server/startup.ts add await for Voip.init(). Once the init is done (Which internally waits for socket establishment), broadcast connector.statuschanged with the setting value (Enabled = true/false) In apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts make voipEnabled as a setting. And change it in the event listener for voip.statuschanged * change place where event was fired Co-authored-by: Kevin Aleman --- .../CallProvider/hooks/useVoipClient.ts | 12 +++++++-- .../modules/listeners/listeners.module.ts | 4 +++ apps/meteor/server/sdk/lib/Events.ts | 1 + apps/meteor/server/services/voip/service.ts | 27 +++++++++++++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts b/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts index 5d93d9a2760a..0bece6a40af8 100644 --- a/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts +++ b/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts @@ -3,7 +3,7 @@ import { useSafely } from '@rocket.chat/fuselage-hooks'; import { KJUR } from 'jsrsasign'; import { useEffect, useState } from 'react'; -import { useEndpoint } from '../../../contexts/ServerContext'; +import { useEndpoint, useStream } from '../../../contexts/ServerContext'; import { useSetting } from '../../../contexts/SettingsContext'; import { useUser } from '../../../contexts/UserContext'; import { SimpleVoipUser } from '../../../lib/voip/SimpleVoipUser'; @@ -21,13 +21,21 @@ const empty = {}; const isSignedResponse = (data: any): data is { result: string } => typeof data?.result === 'string'; export const useVoipClient = (): UseVoipClientResult => { - const voipEnabled = useSetting('VoIP_Enabled'); + const [voipEnabled, setVoipEnabled] = useSafely(useState(useSetting('VoIP_Enabled'))); const registrationInfo = useEndpoint('GET', 'connector.extension.getRegistrationInfoByUserId'); const membership = useEndpoint('GET', 'voip/queues.getMembershipSubscription'); const user = useUser(); + const subscribeToNotifyLoggedIn = useStream('notify-logged'); const iceServers = useWebRtcServers(); const [result, setResult] = useSafely(useState({})); + useEffect(() => { + const voipEnableEventHandler = (enabled: boolean): void => { + setVoipEnabled(enabled); + }; + return subscribeToNotifyLoggedIn(`voip.statuschanged`, voipEnableEventHandler); + }, [setResult, setVoipEnabled, subscribeToNotifyLoggedIn]); + useEffect(() => { const uid = user?._id; const userExtension = user?.extension; diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index 3d1413e6cbbb..a9a1c7579379 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -325,5 +325,9 @@ export class ListenersModule { service.onEvent('notify.updateCustomSound', (data): void => { notifications.notifyAllInThisInstance('updateCustomSound', data); }); + + service.onEvent('connector.statuschanged', (enabled): void => { + notifications.notifyLoggedInThisInstance('voip.statuschanged', enabled); + }); } } diff --git a/apps/meteor/server/sdk/lib/Events.ts b/apps/meteor/server/sdk/lib/Events.ts index b52ceac0e095..2d22281c241f 100644 --- a/apps/meteor/server/sdk/lib/Events.ts +++ b/apps/meteor/server/sdk/lib/Events.ts @@ -130,4 +130,5 @@ export type EventSignatures = { 'queue.queuememberremoved'(userid: string, queuename: string, queuedcalls: string): void; 'queue.callabandoned'(userid: string, queuename: string, queuedcallafterabandon: string): void; 'watch.pbxevents'(data: { clientAction: ClientAction; data: Partial; id: string }): void; + 'connector.statuschanged'(enabled: boolean): void; }; diff --git a/apps/meteor/server/services/voip/service.ts b/apps/meteor/server/services/voip/service.ts index 3897bc343267..ba7d24baa0c9 100644 --- a/apps/meteor/server/services/voip/service.ts +++ b/apps/meteor/server/services/voip/service.ts @@ -19,6 +19,7 @@ import { CommandHandler } from './connector/asterisk/CommandHandler'; import { CommandType } from './connector/asterisk/Command'; import { Commands } from './connector/asterisk/Commands'; import { getServerConfigDataFromSettings, voipEnabled } from './lib/Helper'; +import { api } from '../../sdk/api'; export class VoipService extends ServiceClassInternal implements IVoipService { protected name = 'voip'; @@ -27,6 +28,8 @@ export class VoipService extends ServiceClassInternal implements IVoipService { commandHandler: CommandHandler; + private active = false; + constructor(db: Db) { super(); @@ -43,8 +46,15 @@ export class VoipService extends ServiceClassInternal implements IVoipService { async init(): Promise { this.logger.info('Starting VoIP service'); + if (this.active) { + this.logger.warn({ msg: 'VoIP service already started' }); + return; + } + try { await this.commandHandler.initConnection(CommandType.AMI); + this.active = true; + api.broadcast('connector.statuschanged', true); this.logger.info('VoIP service started'); } catch (err) { this.logger.error({ msg: 'Error initializing VOIP service', err }); @@ -53,8 +63,15 @@ export class VoipService extends ServiceClassInternal implements IVoipService { async stop(): Promise { this.logger.info('Stopping VoIP service'); + if (!this.active) { + this.logger.warn({ msg: 'VoIP service already stopped' }); + return; + } + try { this.commandHandler.stop(); + this.active = false; + api.broadcast('connector.statuschanged', false); this.logger.info('VoIP service stopped'); } catch (err) { this.logger.error({ msg: 'Error stopping VoIP service', err }); @@ -63,8 +80,14 @@ export class VoipService extends ServiceClassInternal implements IVoipService { async refresh(): Promise { this.logger.info('Restarting VoIP service due to settings changes'); - await this.stop(); - await this.init(); + try { + // Disable voip service + await this.stop(); + // To then restart it + await this.init(); + } catch (err) { + this.logger.error({ msg: 'Error refreshing VoIP service', err }); + } } getServerConfigData(type: ServerType): IVoipCallServerConfig | IVoipManagementServerConfig { From f5b18a0cab7dd151cfd0fa9b4fee732c7799d387 Mon Sep 17 00:00:00 2001 From: Filipe Marins Date: Wed, 20 Apr 2022 14:52:08 -0300 Subject: [PATCH 05/10] [FIX] Read receipts show with color gray when not read yet (#25244) * fix: show read receipts with color gray instead of hidden * fix: rename function * review Co-authored-by: gabriellsh --- .../views/room/MessageList/components/MessageContent.tsx | 6 +++--- .../room/MessageList/components/MessageReadReceipt.tsx | 4 ++-- .../room/MessageList/contexts/MessageListContext.tsx | 8 ++++---- .../room/MessageList/providers/MessageListProvider.tsx | 3 +-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/meteor/client/views/room/MessageList/components/MessageContent.tsx b/apps/meteor/client/views/room/MessageList/components/MessageContent.tsx index 4c012a5e6fc4..df599da9a921 100644 --- a/apps/meteor/client/views/room/MessageList/components/MessageContent.tsx +++ b/apps/meteor/client/views/room/MessageList/components/MessageContent.tsx @@ -17,7 +17,7 @@ import { UserPresence } from '../../../../lib/presence'; import MessageBlock from '../../../blocks/MessageBlock'; import MessageLocation from '../../../location/MessageLocation'; import { useMessageActions, useMessageOembedIsEnabled, useMessageRunActionLink } from '../../contexts/MessageContext'; -import { useMessageShowReadReceipt } from '../contexts/MessageListContext'; +import { useMessageListShowReadReceipt } from '../contexts/MessageListContext'; import { isOwnUserMessage } from '../lib/isOwnUserMessage'; import EncryptedMessageRender from './EncryptedMessageRender'; import ReactionsList from './MessageReactionsList'; @@ -36,7 +36,7 @@ const MessageContent: FC<{ message: IMessage; sequential: boolean; subscription? const runActionLink = useMessageRunActionLink(); const oembedIsEnabled = useMessageOembedIsEnabled(); - const shouldShowReadReceipt = useMessageShowReadReceipt({ message }); + const shouldShowReadReceipt = useMessageListShowReadReceipt(); const user: UserPresence = { ...message.u, roles: [], ...useUserData(message.u._id) }; const isEncryptedMessage = isE2EEMessage(message); @@ -111,7 +111,7 @@ const MessageContent: FC<{ message: IMessage; sequential: boolean; subscription? {oembedIsEnabled && message.urls && } - {shouldShowReadReceipt && } + {shouldShowReadReceipt && } ); }; diff --git a/apps/meteor/client/views/room/MessageList/components/MessageReadReceipt.tsx b/apps/meteor/client/views/room/MessageList/components/MessageReadReceipt.tsx index 0f2c24ea658c..a6e004a299e4 100644 --- a/apps/meteor/client/views/room/MessageList/components/MessageReadReceipt.tsx +++ b/apps/meteor/client/views/room/MessageList/components/MessageReadReceipt.tsx @@ -2,7 +2,7 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Icon } from '@rocket.chat/fuselage'; import React, { ReactElement } from 'react'; -const MessageReadReceipt = (): ReactElement | null => ( +const MessageReadReceipt = ({ unread }: { unread?: boolean }): ReactElement | null => ( ( right: 0.5rem; `} > - + ); diff --git a/apps/meteor/client/views/room/MessageList/contexts/MessageListContext.tsx b/apps/meteor/client/views/room/MessageList/contexts/MessageListContext.tsx index 69cd0a21a3b1..006a6bdeaf37 100644 --- a/apps/meteor/client/views/room/MessageList/contexts/MessageListContext.tsx +++ b/apps/meteor/client/views/room/MessageList/contexts/MessageListContext.tsx @@ -5,12 +5,12 @@ export type MessageListContextValue = { useShowTranslated: ({ message }: { message: IMessage }) => boolean; useShowStarred: ({ message }: { message: IMessage }) => boolean; useShowFollowing: ({ message }: { message: IMessage }) => boolean; - useShowReadReceipt: ({ message }: { message: IMessage }) => boolean; useMessageDateFormatter: () => (date: Date) => string; useUserHasReacted: (message: IMessage) => (reaction: string) => boolean; useReactToMessage: (message: IMessage) => (reaction: string) => void; useReactionsFilter: (message: IMessage) => (reaction: string) => string[]; useOpenEmojiPicker: (message: IMessage) => (event: React.MouseEvent) => void; + showReadReceipt: boolean; showRoles: boolean; showRealName: boolean; showUsername: boolean; @@ -26,7 +26,6 @@ export const MessageListContext = createContext({ useShowTranslated: () => false, useShowStarred: () => false, useShowFollowing: () => false, - useShowReadReceipt: () => false, useUserHasReacted: () => (): boolean => false, useMessageDateFormatter: () => @@ -38,6 +37,7 @@ export const MessageListContext = createContext({ (message) => (reaction: string): string[] => message.reactions ? message.reactions[reaction]?.usernames || [] : [], + showReadReceipt: false, showRoles: false, showRealName: false, showUsername: false, @@ -51,8 +51,8 @@ export const useShowFollowing: MessageListContextValue['useShowFollowing'] = (.. useContext(MessageListContext).useShowFollowing(...args); export const useMessageDateFormatter: MessageListContextValue['useMessageDateFormatter'] = (...args) => useContext(MessageListContext).useMessageDateFormatter(...args); -export const useMessageShowReadReceipt: MessageListContextValue['useShowReadReceipt'] = (...args) => - useContext(MessageListContext).useShowReadReceipt(...args); +export const useMessageListShowReadReceipt = (): MessageListContextValue['showReadReceipt'] => + useContext(MessageListContext).showReadReceipt; export const useMessageListShowRoles = (): MessageListContextValue['showRoles'] => useContext(MessageListContext).showRoles; export const useMessageListShowRealName = (): MessageListContextValue['showRealName'] => useContext(MessageListContext).showRealName; export const useMessageListShowUsername = (): MessageListContextValue['showUsername'] => useContext(MessageListContext).showUsername; diff --git a/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx b/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx index d1df12b9ba13..8066cf875cfa 100644 --- a/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx +++ b/apps/meteor/client/views/room/MessageList/providers/MessageListProvider.tsx @@ -75,12 +75,11 @@ export const MessageListProvider: FC<{ useShowStarred: hasSubscription ? ({ message }): boolean => Boolean(Array.isArray(message.starred) && message.starred.find((star) => star._id === uid)) : (): boolean => false, - useShowReadReceipt: ({ message }): boolean => showReadReceipt && !message.unread, useMessageDateFormatter: () => (date: Date): string => date.toLocaleString(), - + showReadReceipt, showRoles, showRealName, showUsername, From d1318e272ff4b4f36c66cc85dc6bf539838b8464 Mon Sep 17 00:00:00 2001 From: amolghode1981 <86001342+amolghode1981@users.noreply.github.com> Date: Thu, 21 Apr 2022 00:21:49 +0530 Subject: [PATCH 06/10] [FIX] Client disconnection on network loss (#25170) * Clickup Task: https://app.clickup.com/t/245c0d8 Description: Agent gets disconnected (or Unregistered) from asterisk in multiple ways. The goal is that agent should remain online unless agent explicitly logs off. Agent can stop receiving calls in multiple ways due to network loss. Network loss can happen in following ways. 1. User tries to switch the network. User experiences a glitch of disconnectivity. This can be simulated by turning the network off in the network tab of chrome's dev tool. This can disconnect the UA if the disconnection happens just before the registration refresh. 2. Second reason is when computer goes in sleep mode. 3. Third reason is that when asterisk is crashed/in maintenance mode/explicitly stopped. Solution: The idea is to detect the network disconnection and start the start the attempts to reconnect. The detection of the disconnection does not happen in case#1. The SIPUA's UserAgent transport does not call onDisconnected when network loss of such kind happens. To tackle this problem, window's online and offline event handlers are used. The number of retries is configurable but ideally it is to be kept at -1. Whenever disconnection happens, it should keep on trying to reconnect with increasing backoff time. This behaviour is useful when the asterisk is stopped. When the server is disconnected, it should be indicated on the phone button. * Clickup Task: https://app.clickup.com/t/245c0d8 Description: 1. Replaced complex state handling in OmnichannelCallToggleReady.tsx. This was creating lot of erros. Added simple network status check and the phone button displays the latest state of things. 2. Some files were overwriting the changes in develop branch. Replaced that with correct changes. * Clickup Task: https://app.clickup.com/t/245c0d8 Description: Adding semi-colon to fix error. * Clickup Task : https://app.clickup.com/t/245c0d8 Description : Fixing review commnts. * removing unused files * Clickup Task: https://app.clickup.com/t/245c0d8 Description: Renaming file to correct the spelling. Making necessary changes in index.ts * Clickup Task: https://app.clickup.com/t/245c0d8 Description: Fixing incorrect spelling. * Add description to setting Co-authored-by: Kevin Aleman --- .../meteor/app/lib/server/startup/settings.ts | 8 + apps/meteor/client/lib/voip/SimpleVoipUser.ts | 3 +- apps/meteor/client/lib/voip/VoIPUser.ts | 413 +++++++++++++----- .../CallProvider/hooks/useVoipClient.ts | 5 +- .../components/OmnichannelCallToggleReady.tsx | 37 +- .../rocketchat-i18n/i18n/en.i18n.json | 3 + packages/core-typings/src/voip/CallStates.ts | 2 - .../core-typings/src/voip/ConnectionState.ts | 13 + packages/core-typings/src/voip/Operations.ts | 1 + .../src/voip/SignalingSocketEvents.ts | 8 + .../src/voip/VoIPUserConfiguration.ts | 5 + packages/core-typings/src/voip/index.ts | 2 + 12 files changed, 388 insertions(+), 112 deletions(-) create mode 100644 packages/core-typings/src/voip/ConnectionState.ts create mode 100644 packages/core-typings/src/voip/SignalingSocketEvents.ts diff --git a/apps/meteor/app/lib/server/startup/settings.ts b/apps/meteor/app/lib/server/startup/settings.ts index 34ed8a6df12a..30080f0bbe1d 100644 --- a/apps/meteor/app/lib/server/startup/settings.ts +++ b/apps/meteor/app/lib/server/startup/settings.ts @@ -3209,6 +3209,14 @@ settingsRegistry.addGroup('Call_Center', function () { value: true, }, }); + this.add('VoIP_Retry_Count', -1, { + type: 'int', + public: true, + enableQuery: { + _id: 'VoIP_Enabled', + value: true, + }, + }); }); this.section('Management_Server', function () { diff --git a/apps/meteor/client/lib/voip/SimpleVoipUser.ts b/apps/meteor/client/lib/voip/SimpleVoipUser.ts index ef5619ee2dd8..818255b14dab 100644 --- a/apps/meteor/client/lib/voip/SimpleVoipUser.ts +++ b/apps/meteor/client/lib/voip/SimpleVoipUser.ts @@ -9,6 +9,7 @@ export class SimpleVoipUser { registrar: string, webSocketPath: string, iceServers: Array, + voipRetryCount: number, callType?: 'audio' | 'video', mediaStreamRendered?: IMediaStreamRenderer, ): Promise { @@ -17,9 +18,9 @@ export class SimpleVoipUser { authPassword: password, sipRegistrarHostnameOrIP: registrar, webSocketURI: webSocketPath, - enableVideo: callType === 'video', iceServers, + connectionRetryCount: voipRetryCount, }; return VoIPUser.create(config, mediaStreamRendered); diff --git a/apps/meteor/client/lib/voip/VoIPUser.ts b/apps/meteor/client/lib/voip/VoIPUser.ts index d0cfcc396986..e72293dca16c 100644 --- a/apps/meteor/client/lib/voip/VoIPUser.ts +++ b/apps/meteor/client/lib/voip/VoIPUser.ts @@ -6,12 +6,14 @@ * This class thus abstracts user from Browser specific media details as well as * SIP specific protol details. */ - -import type { IQueueMembershipSubscription } from '@rocket.chat/core-typings'; import { CallStates, + ConnectionState, ICallerInfo, + IQueueMembershipSubscription, Operation, + SignalingSocketEvents, + SocketEventKeys, UserState, IMediaStreamRenderer, VoIPUserConfiguration, @@ -24,7 +26,6 @@ import { Emitter } from '@rocket.chat/emitter'; import { UserAgent, UserAgentOptions, - // UserAgentDelegate, Invitation, InvitationAcceptOptions, Session, @@ -33,14 +34,14 @@ import { SessionInviteOptions, RequestPendingError, } from 'sip.js'; -import { OutgoingByeRequest, OutgoingRequestDelegate, URI } from 'sip.js/lib/core'; +import { OutgoingByeRequest, URI } from 'sip.js/lib/core'; import { SessionDescriptionHandler, SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web'; import { toggleMediaStreamTracks } from './Helper'; import { QueueAggregator } from './QueueAggregator'; import Stream from './Stream'; -export class VoIPUser extends Emitter implements OutgoingRequestDelegate { +export class VoIPUser extends Emitter { state: IState = { isReady: false, enableVideo: false, @@ -58,22 +59,157 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele mediaStreamRendered?: IMediaStreamRenderer; - private _callState: CallStates = 'IDLE'; + private _callState: CallStates = 'INITIAL'; private _callerInfo: ICallerInfo | undefined; private _userState: UserState = UserState.IDLE; + private _connectionState: ConnectionState = 'INITIAL'; + private _held = false; private mode: WorkflowTypes; private queueInfo: QueueAggregator; + private connectionRetryCount; + + private stop; + + private networkEmitter: Emitter; + + private offlineNetworkHandler: () => void; + + private onlineNetworkHandler: () => void; + + constructor(private readonly config: VoIPUserConfiguration, mediaRenderer?: IMediaStreamRenderer) { + super(); + this.mediaStreamRendered = mediaRenderer; + this.networkEmitter = new Emitter(); + this.connectionRetryCount = this.config.connectionRetryCount; + this.stop = false; + this.onlineNetworkHandler = this.onNetworkRestored.bind(this); + this.offlineNetworkHandler = this.onNetworkLost.bind(this); + } + + /** + * Configures and initializes sip.js UserAgent + * call gets established. + * @remarks + * This class configures transport properties such as websocket url, passed down in config, + * sets up ICE servers, + * SIP UserAgent options such as userName, Password, URI. + * Once initialized, it starts the userAgent. + */ + + async init(): Promise { + const sipUri = `sip:${this.config.authUserName}@${this.config.sipRegistrarHostnameOrIP}`; + const transportOptions = { + server: this.config.webSocketURI, + connectionTimeout: 100, // Replace this with config + keepAliveInterval: 20, + // traceSip: true + }; + const sdpFactoryOptions = { + iceGatheringTimeout: 10, + peerConnectionConfiguration: { + iceServers: this.config.iceServers, + }, + }; + this.userAgentOptions = { + delegate: { + onInvite: async (invitation: Invitation): Promise => { + await this.handleIncomingCall(invitation); + }, + }, + authorizationPassword: this.config.authPassword, + authorizationUsername: this.config.authUserName, + uri: UserAgent.makeURI(sipUri), + transportOptions, + sessionDescriptionHandlerFactoryOptions: sdpFactoryOptions, + logConfiguration: false, + logLevel: 'error', + }; + + this.userAgent = new UserAgent(this.userAgentOptions); + this.userAgent.transport.isConnected(); + this._opInProgress = Operation.OP_CONNECT; + try { + this.registerer = new Registerer(this.userAgent); + this.userAgent.transport.onConnect = this.onConnected.bind(this); + this.userAgent.transport.onDisconnect = this.onDisconnected.bind(this); + window.addEventListener('online', this.onlineNetworkHandler); + window.addEventListener('offline', this.offlineNetworkHandler); + await this.userAgent.start(); + } catch (error) { + this._connectionState = 'ERROR'; + throw error; + } + } + + async onConnected(): Promise { + this._connectionState = 'SERVER_CONNECTED'; + this.state.isReady = true; + this.sendOptions(); + this.networkEmitter.emit('connected'); + /** + * Re-registration post network recovery should be attempted + * if it was previously registered or incall/onhold + * */ + + if (this.registerer && this.callState !== 'INITIAL') { + this.attemptRegistrationPostRecovery(); + } + } + + onDisconnected(error: any): void { + this._connectionState = 'SERVER_DISCONNECTED'; + this._opInProgress = Operation.OP_NONE; + this.networkEmitter.emit('disconnected'); + if (error) { + this.networkEmitter.emit('connectionerror', error); + this.state.isReady = false; + /** + * Signalling socket reconnection should be attempted assuming + * that the disconnect happened from the remote side or due to sleep + * In case of remote side disconnection, if config.connectionRetryCount is -1, + * attemptReconnection attempts continuously. Else stops after |config.connectionRetryCount| + * + * */ + this.attemptReconnection(); + } + } + + onNetworkRestored(): void { + this.networkEmitter.emit('localnetworkonline'); + if (this._connectionState === 'WAITING_FOR_NETWORK') { + /** + * Signalling socket reconnection should be attempted when online event handler + * gets notified. + * Important thing to note is that the second parameter |checkRegistration| = true passed here + * because after the network recovery and after reconnecting to the server, + * the transport layer of SIPUA does not call onConnected. So by passing |checkRegistration = true | + * the code will check if the endpoint was previously registered before the disconnection. + * If such is the case, it will first unregister and then reregister. + * */ + this.attemptReconnection(1, true); + } + } + + onNetworkLost(): void { + this.networkEmitter.emit('localnetworkoffline'); + this._connectionState = 'WAITING_FOR_NETWORK'; + } + get callState(): CallStates { return this._callState; } + get connectionState(): ConnectionState { + return this._connectionState; + } + get callerInfo(): VoIpCallerInfo { if (this.callState === 'IN_CALL' || this.callState === 'OFFER_RECEIVED' || this.callState === 'ON_HOLD') { if (!this._callerInfo) { @@ -115,21 +251,8 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele } /* Media Stream functions end */ - constructor(private readonly config: VoIPUserConfiguration, mediaRenderer?: IMediaStreamRenderer) { - super(); - this.mediaStreamRendered = mediaRenderer; - this.on('connected', () => { - this.state.isReady = true; - }); - - this.on('connectionerror', () => { - this.state.isReady = false; - }); - } - - /* UserAgentDelegate methods end */ /* OutgoingRequestDelegate methods begin */ - onAccept(): void { + onRegistrationRequestAccept(): void { if (this._opInProgress === Operation.OP_REGISTER) { this._callState = 'REGISTERED'; this.emit('registered'); @@ -142,7 +265,7 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele } } - onReject(error: any): void { + onRegistrationRequestReject(error: any): void { if (this._opInProgress === Operation.OP_REGISTER) { this.emit('registrationerror', error); } @@ -382,90 +505,36 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele }); } - /** - * Configures and initializes sip.js UserAgent - * call gets established. - * @remarks - * This class configures transport properties such as websocket url, passed down in config, - * sets up ICE servers, - * SIP UserAgent options such as userName, Password, URI. - * Once initialized, it starts the userAgent. - */ - - async init(): Promise { - const sipUri = `sip:${this.config.authUserName}@${this.config.sipRegistrarHostnameOrIP}`; - const transportOptions = { - server: this.config.webSocketURI, - connectionTimeout: 100, // Replace this with config - keepAliveInterval: 20, - // traceSip: true - }; - const sdpFactoryOptions = { - iceGatheringTimeout: 10, - peerConnectionConfiguration: { - iceServers: this.config.iceServers, - }, - }; - this.userAgentOptions = { - delegate: { - /* UserAgentDelegate methods begin */ - onConnect: (): void => { - this._callState = 'SERVER_CONNECTED'; - - this.emit('connected'); - /** - * There is an interesting problem that happens with Asterisk. - * After websocket connection succeeds and if there is no SIP - * message goes in 30 seconds, asterisk disconnects the socket. - * - * If any SIP message goes before 30 seconds, asterisk holds the connection. - * This problem could be solved in multiple ways. One is that - * whenever disconnect happens make sure that the socket is connected back using - * this.userAgent.reconnect() method. But this is expensive as it does connect-disconnect - * every 30 seconds till we send register message. - * - * Another approach is to send SIP OPTIONS just to tell server that - * there is a UA using this socket. This is implemented below - **/ - - const uri = new URI('sip', this.config.authUserName, this.config.sipRegistrarHostnameOrIP); - const outgoingMessage = this.userAgent?.userAgentCore.makeOutgoingRequestMessage('OPTIONS', uri, uri, uri, {}); - if (outgoingMessage) { - this.userAgent?.userAgentCore.request(outgoingMessage); - } - if (this.userAgent) { - this.registerer = new Registerer(this.userAgent); - } - }, - onDisconnect: (error: any): void => { - if (error) { - this.emit('connectionerror', error); - } - }, - onInvite: async (invitation: Invitation): Promise => { - await this.handleIncomingCall(invitation); - }, - }, - authorizationPassword: this.config.authPassword, - authorizationUsername: this.config.authUserName, - uri: UserAgent.makeURI(sipUri), - transportOptions, - sessionDescriptionHandlerFactoryOptions: sdpFactoryOptions, - logConfiguration: false, - logLevel: 'error', - }; - - this.userAgent = new UserAgent(this.userAgentOptions); - this._opInProgress = Operation.OP_CONNECT; - await this.userAgent.start(); - } - static async create(config: VoIPUserConfiguration, mediaRenderer?: IMediaStreamRenderer): Promise { const voip = new VoIPUser(config, mediaRenderer); await voip.init(); return voip; } + /** + * Sends SIP OPTIONS message to asterisk + * + * There is an interesting problem that happens with Asterisk. + * After websocket connection succeeds and if there is no SIP + * message goes in 30 seconds, asterisk disconnects the socket. + * + * If any SIP message goes before 30 seconds, asterisk holds the connection. + * This problem could be solved in multiple ways. One is that + * whenever disconnect happens make sure that the socket is connected back using + * this.userAgent.reconnect() method. But this is expensive as it does connect-disconnect + * every 30 seconds till we send register message. + * + * Another approach is to send SIP OPTIONS just to tell server that + * there is a UA using this socket. This is implemented below + */ + + sendOptions(): void { + const uri = new URI('sip', this.config.authUserName, this.config.sipRegistrarHostnameOrIP); + const outgoingMessage = this.userAgent?.userAgentCore.makeOutgoingRequestMessage('OPTIONS', uri, uri, uri, {}); + if (outgoingMessage) { + this.userAgent?.userAgentCore.request(outgoingMessage); + } + } /** * Public method called from outside to register the SIP UA with call server. * @remarks @@ -474,7 +543,10 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele register(): void { this._opInProgress = Operation.OP_REGISTER; this.registerer?.register({ - requestDelegate: this, + requestDelegate: { + onAccept: this.onRegistrationRequestAccept.bind(this), + onReject: this.onRegistrationRequestReject.bind(this), + }, }); } @@ -487,7 +559,10 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele this._opInProgress = Operation.OP_UNREGISTER; this.registerer?.unregister({ all: true, - requestDelegate: this, + requestDelegate: { + onAccept: this.onRegistrationRequestAccept.bind(this), + onReject: this.onRegistrationRequestReject.bind(this), + }, }); } /** @@ -665,6 +740,142 @@ export class VoIPUser extends Emitter implements OutgoingRequestDele } clear(): void { + this._opInProgress = Operation.OP_CLEANUP; + /** Socket reconnection is attempted when the socket is disconnected with some error. + * While disconnecting, if there is any socket error, there should be no reconnection attempt. + * So when userAgent.stop() is called which closes the sockets, it should be made sure that + * if the socket is disconnected with error, connection attempts are not started or + * if there are any previously ongoing attempts, they should be terminated. + * flag attemptReconnect is used for ensuring this. + */ + this.stop = true; this.userAgent?.stop(); + this.registerer?.dispose(); + this._connectionState = 'STOP'; + + if (this.userAgent) { + this.userAgent.transport.onConnect = undefined; + this.userAgent.transport.onDisconnect = undefined; + window.removeEventListener('online', this.onlineNetworkHandler); + window.removeEventListener('offline', this.offlineNetworkHandler); + } + } + + onNetworkEvent(event: SocketEventKeys, handler: () => void): void { + this.networkEmitter.on(event, handler); + } + + offNetworkEvent(event: SocketEventKeys, handler: () => void): void { + this.networkEmitter.off(event, handler); + } + + /** + * Connection is lost in 3 ways + * 1. When local network is lost (Router is disconeected, switching networks, devtools->network->offline) + * In this case, the SIP.js's transport layer does not detect the disconnection. Hence, it does not + * call |onDisconnect|. To detect this kind of disconnection, window event listeners have been added. + * These event listeners would be get called when the browser detects that network is offline or online. + * When the network is restored, the code tries to reconnect. The useragent.transport "does not" generate the + * onconnected event in this case as well. so onlineNetworkHandler calls attemptReconnection. + * Which calls attemptRegistrationPostRecovery based on correct state. attemptRegistrationPostRecovery firts tries to + * unregister and then reregister. + * Important note : We use the event listeners using bind function object offlineNetworkHandler and onlineNetworkHandler + * It is done so because the same event handlers need to be used for removeEventListener, which becomes impossible + * if done inline. + * + * 2. Computer goes to sleep. In this case onDisconnect is triggerred. The code tries to reconnect but cant go ahead + * as it goes to sleep. On waking up, The attemptReconnection gets executed, connection is completed. + * In this case, it generates onConnected event. In this onConnected event it calls attemptRegistrationPostRecovery + * + * 3. When Asterisk disconnects all the endpoints either because it crashes or restarted, + * As soon as the agent successfully connects to asterisk, it should re-register + * + * Retry count : + * connectionRetryCount is the parameter called |Retry Count| in + * Adminstration -> Call Center -> Server configuration -> Retry count. + * The retry is implemented with backoff, maxbackoff = 8 seconds. + * For continuous retries (In case Asterisk restart happens) Set this parameter to -1. + * + * Important to note is how attemptRegistrationPostRecovery is called. In case of + * the router connection loss or while switching the networks, + * there is no disconnect and connect event from the transport layer of the userAgent. + * So in this case, when the connection is successful after reconnect, the code should try to re-register by calling + * attemptRegistrationPostRecovery. + * In case of computer waking from sleep or asterisk getting restored, connect and disconnect events are generated. + * In this case, re-registration should be triggered (by calling) only when onConnected gets called and not otherwise. + */ + attemptReconnection(reconnectionAttempt = 0, checkRegistration = false): void { + const reconnectionAttempts = this.connectionRetryCount; + this._connectionState = 'SERVER_RECONNECTING'; + if (!this.userAgent) { + return; + } + if (this.stop) { + return; + } + // reconnectionAttempts == -1 then keep continuously trying + if (reconnectionAttempts !== -1 && reconnectionAttempt > reconnectionAttempts) { + this._connectionState = 'ERROR'; + return; + } + + const reconnectionDelay = Math.pow(2, reconnectionAttempt % 4); + console.error(`Attempting to reconnect with backoff due to network loss. Backoff time [${reconnectionDelay}]`); + setTimeout(() => { + if (this.stop) { + return; + } + if (this._connectionState === 'SERVER_CONNECTED') { + return; + } + this.userAgent + ?.reconnect() + .then(() => { + this._connectionState = 'SERVER_CONNECTED'; + if (!checkRegistration || !this.registerer || this.callState === 'INITIAL') { + return; + } + this.attemptRegistrationPostRecovery(); + }) + .catch(() => { + this.attemptReconnection(++reconnectionAttempt, checkRegistration); + }); + }, reconnectionDelay * 1000); + } + + async attemptRegistrationPostRecovery(): Promise { + /** + * It might happen that the whole network loss can happen + * while there is ongoing call. In that case, we want to maintain + * the call. + * + * So after re-registration, it should remain in the same state. + * */ + + const promise = new Promise((_resolve, _reject) => { + this.registerer?.unregister({ + all: true, + requestDelegate: { + onAccept: (): void => { + _resolve(); + }, + onReject: (error): void => { + console.error(`[${error}] While unregistering after recovery`); + this.emit('unregistrationerror', error); + _reject('Error in Unregistering'); + }, + }, + }); + }); + await promise; + this.registerer?.register({ + requestDelegate: { + onReject: (error): void => { + this._callState = 'UNREGISTERED'; + this.emit('registrationerror', error); + this.emit('stateChanged'); + }, + }, + }); } } diff --git a/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts b/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts index 0bece6a40af8..56ca3ad2c525 100644 --- a/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts +++ b/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts @@ -22,6 +22,7 @@ const isSignedResponse = (data: any): data is { result: string } => typeof data? export const useVoipClient = (): UseVoipClientResult => { const [voipEnabled, setVoipEnabled] = useSafely(useState(useSetting('VoIP_Enabled'))); + const voipRetryCount = useSetting('VoIP_Retry_Count'); const registrationInfo = useEndpoint('GET', 'connector.extension.getRegistrationInfoByUserId'); const membership = useEndpoint('GET', 'voip/queues.getMembershipSubscription'); const user = useUser(); @@ -64,7 +65,7 @@ export const useVoipClient = (): UseVoipClientResult => { (async (): Promise => { try { const subscription = await membership({ extension }); - client = await SimpleVoipUser.create(extension, password, host, websocketPath, iceServers, 'video'); + client = await SimpleVoipUser.create(extension, password, host, websocketPath, iceServers, Number(voipRetryCount), 'video'); // Today we are hardcoding workflow mode. // In future, this should be ready from configuration client.setWorkflowMode(WorkflowTypes.CONTACT_CENTER_USER); @@ -84,7 +85,7 @@ export const useVoipClient = (): UseVoipClientResult => { client.clear(); } }; - }, [iceServers, registrationInfo, setResult, membership, voipEnabled, user?._id, user?.extension]); + }, [iceServers, registrationInfo, setResult, membership, voipEnabled, user?._id, user?.extension, voipRetryCount]); return result; }; diff --git a/apps/meteor/client/sidebar/sections/components/OmnichannelCallToggleReady.tsx b/apps/meteor/client/sidebar/sections/components/OmnichannelCallToggleReady.tsx index f975f39c036c..43d335214a0c 100644 --- a/apps/meteor/client/sidebar/sections/components/OmnichannelCallToggleReady.tsx +++ b/apps/meteor/client/sidebar/sections/components/OmnichannelCallToggleReady.tsx @@ -5,20 +5,25 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { useCallClient, useCallerInfo, useCallActions } from '../../../contexts/CallContext'; import { useTranslation } from '../../../contexts/TranslationContext'; +type NetworkState = 'online' | 'offline'; export const OmnichannelCallToggleReady = (): ReactElement => { const [agentEnabled, setAgentEnabled] = useState(false); // TODO: get from AgentInfo const t = useTranslation(); const [registered, setRegistered] = useState(false); const voipClient = useCallClient(); - const [onCall, setOnCall] = useState(false); + const [disableButtonClick, setDisableButtonClick] = useState(false); + const [networkStatus, setNetworkStatus] = useState('online'); const callerInfo = useCallerInfo(); const callActions = useCallActions(); const getTooltip = (): string => { + if (networkStatus === 'offline') { + return t('Signaling_connection_disconnected'); + } if (!registered) { return t('Enable'); } - if (!onCall) { + if (!disableButtonClick) { // Color for this state still not defined return t('Disable'); } @@ -34,7 +39,7 @@ export const OmnichannelCallToggleReady = (): ReactElement => { useEffect(() => { // Any of the 2 states means the user is already talking - setOnCall(['IN_CALL', 'ON_HOLD'].includes(callerInfo.state)); + setDisableButtonClick(['IN_CALL', 'ON_HOLD'].includes(callerInfo.state)); }, [callerInfo]); useEffect(() => { @@ -49,7 +54,7 @@ export const OmnichannelCallToggleReady = (): ReactElement => { // TODO: move registration flow to context provider const handleVoipCallStatusChange = useMutableCallback((): void => { - if (onCall) { + if (disableButtonClick) { return; } // TODO: backend set voip call status @@ -82,6 +87,16 @@ export const OmnichannelCallToggleReady = (): ReactElement => { setRegistered(!registered); }); + const onNetworkConnected = useMutableCallback((): void => { + setDisableButtonClick(['IN_CALL', 'ON_HOLD'].includes(callerInfo.state)); + setNetworkStatus('online'); + }); + + const onNetworkDisconnected = useMutableCallback((): void => { + setDisableButtonClick(true); + setNetworkStatus('offline'); + }); + useEffect(() => { if (!voipClient) { return; @@ -90,14 +105,24 @@ export const OmnichannelCallToggleReady = (): ReactElement => { voipClient.on('registrationerror', onRegistrationError); voipClient.on('unregistered', onUnregistered); voipClient.on('unregistrationerror', onUnregistrationError); + voipClient.onNetworkEvent('connected', onNetworkConnected); + voipClient.onNetworkEvent('disconnected', onNetworkDisconnected); + voipClient.onNetworkEvent('connectionerror', onNetworkDisconnected); + voipClient.onNetworkEvent('localnetworkonline', onNetworkConnected); + voipClient.onNetworkEvent('localnetworkoffline', onNetworkDisconnected); return (): void => { voipClient.off('registered', onRegistered); voipClient.off('registrationerror', onRegistrationError); voipClient.off('unregistered', onUnregistered); voipClient.off('unregistrationerror', onUnregistrationError); + voipClient.offNetworkEvent('connected', onNetworkConnected); + voipClient.offNetworkEvent('disconnected', onNetworkDisconnected); + voipClient.offNetworkEvent('connectionerror', onNetworkDisconnected); + voipClient.offNetworkEvent('localnetworkonline', onNetworkConnected); + voipClient.offNetworkEvent('localnetworkoffline', onNetworkDisconnected); }; - }, [onRegistered, onRegistrationError, onUnregistered, onUnregistrationError, voipClient]); + }, [onRegistered, onRegistrationError, onUnregistered, onUnregistrationError, voipClient, onNetworkConnected, onNetworkDisconnected]); - return ; + return ; }; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index fa36bdea6a58..74b8c56fd3ed 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -4006,6 +4006,7 @@ "Sidebar": "Sidebar", "Sidebar_list_mode": "Sidebar Channel List Mode", "Sign_in_to_start_talking": "Sign in to start talking", + "Signaling_connection_disconnected": "Signaling connection disconnected", "since_creation": "since %s", "Site_Name": "Site Name", "Site_Url": "Site URL", @@ -4768,6 +4769,8 @@ "VoIP_Server_Websocket_Port": "Websocket Port", "VoIP_Server_Name": "Server Name", "VoIP_Server_Websocket_Path": "Websocket Path", + "VoIP_Retry_Count": "Retry Count", + "VoIP_Retry_Count_Description": "Defines the number of times the client will try to reconnect to the VoIP server if the connection is lost.", "VoIP_Management_Server": "VoIP Management Server", "VoIP_Management_Server_Host": "Server Host", "VoIP_Management_Server_Port": "Server Port", diff --git a/packages/core-typings/src/voip/CallStates.ts b/packages/core-typings/src/voip/CallStates.ts index fdad7c8cf18f..3aab8bc3770e 100644 --- a/packages/core-typings/src/voip/CallStates.ts +++ b/packages/core-typings/src/voip/CallStates.ts @@ -5,7 +5,6 @@ export type CallStates = | 'INITIAL' - | 'SERVER_CONNECTED' | 'REGISTERED' | 'OFFER_RECEIVED' | 'IDLE' @@ -13,5 +12,4 @@ export type CallStates = | 'IN_CALL' | 'ON_HOLD' | 'UNREGISTERED' - | 'SERVER_DISCONNECTED' | 'ERROR'; diff --git a/packages/core-typings/src/voip/ConnectionState.ts b/packages/core-typings/src/voip/ConnectionState.ts new file mode 100644 index 000000000000..d8b1fc3c59aa --- /dev/null +++ b/packages/core-typings/src/voip/ConnectionState.ts @@ -0,0 +1,13 @@ +/** + * Type representing connectionstate + * @remarks + */ + +export type ConnectionState = + | 'INITIAL' + | 'SERVER_CONNECTED' + | 'SERVER_DISCONNECTED' + | 'SERVER_RECONNECTING' + | 'WAITING_FOR_NETWORK' + | 'STOP' + | 'ERROR'; diff --git a/packages/core-typings/src/voip/Operations.ts b/packages/core-typings/src/voip/Operations.ts index 0ecca5299437..b9ae5e72adb4 100644 --- a/packages/core-typings/src/voip/Operations.ts +++ b/packages/core-typings/src/voip/Operations.ts @@ -10,4 +10,5 @@ export enum Operation { OP_REGISTER, OP_UNREGISTER, OP_PROCESS_INVITE, + OP_CLEANUP, } diff --git a/packages/core-typings/src/voip/SignalingSocketEvents.ts b/packages/core-typings/src/voip/SignalingSocketEvents.ts new file mode 100644 index 000000000000..1800037d56d8 --- /dev/null +++ b/packages/core-typings/src/voip/SignalingSocketEvents.ts @@ -0,0 +1,8 @@ +export type SignalingSocketEvents = { + connected: undefined; + disconnected: undefined; + connectionerror: unknown; + localnetworkonline: undefined; + localnetworkoffline: undefined; +}; +export type SocketEventKeys = keyof SignalingSocketEvents; diff --git a/packages/core-typings/src/voip/VoIPUserConfiguration.ts b/packages/core-typings/src/voip/VoIPUserConfiguration.ts index d43172d48207..9ca379b4546d 100644 --- a/packages/core-typings/src/voip/VoIPUserConfiguration.ts +++ b/packages/core-typings/src/voip/VoIPUserConfiguration.ts @@ -35,6 +35,11 @@ export interface VoIPUserConfiguration { * @defaultValue undefined */ iceServers: Array; + /** + * Voip Retru count + * @defaultValue undefined + */ + connectionRetryCount: number; } export interface IMediaStreamRenderer { diff --git a/packages/core-typings/src/voip/index.ts b/packages/core-typings/src/voip/index.ts index 3a3626b9af31..9d229d58d71a 100644 --- a/packages/core-typings/src/voip/index.ts +++ b/packages/core-typings/src/voip/index.ts @@ -1,4 +1,5 @@ export * from "./CallStates"; +export * from "./ConnectionState"; export * from "./ICallerInfo"; export * from "./IConnectionDelegate"; export * from "./IEvents"; @@ -6,6 +7,7 @@ export * from "./IQueueInfo"; export * from "./IRegisterHandlerDelegate"; export * from "./IRegistrationInfo"; export * from "./Operations"; +export * from "./SignalingSocketEvents"; export * from "./UserState"; export * from "./VoipClientEvents"; export * from "./VoipEvents"; From 9e712ea8ccd0e18c008697280e28d1d9ea57686d Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Thu, 21 Apr 2022 01:08:58 +0530 Subject: [PATCH 07/10] [IMPROVE] Add OTR Room States (#24565) * WIP: OTR Room States * lint * remove logs * new OTR components, remove modals * updating stories * convert js files to ts * correct a type * add missing translation * fix review * chore: remove OTRModal * fix: review Co-authored-by: dougfabris --- apps/meteor/app/otr/client/OtrRoomState.ts | 9 ++ apps/meteor/app/otr/client/rocketchat.otr.js | 13 ++- .../app/otr/client/rocketchat.otr.room.js | 60 +++++++------ .../views/room/contextualBar/OTR/OTR.js | 53 ----------- .../room/contextualBar/OTR/OTR.stories.tsx | 20 ++++- .../views/room/contextualBar/OTR/OTR.tsx | 89 +++++++++++++++++++ .../views/room/contextualBar/OTR/OTRModal.js | 30 ------- .../room/contextualBar/OTR/OTRWithData.js | 67 -------------- .../room/contextualBar/OTR/OTRWithData.tsx | 60 +++++++++++++ .../OTR/components/OTREstablished.tsx | 24 +++++ .../OTR/components/OTRStates.tsx | 28 ++++++ .../rocketchat-i18n/i18n/en.i18n.json | 7 ++ 12 files changed, 280 insertions(+), 180 deletions(-) create mode 100644 apps/meteor/app/otr/client/OtrRoomState.ts delete mode 100644 apps/meteor/client/views/room/contextualBar/OTR/OTR.js create mode 100644 apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js delete mode 100644 apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js create mode 100644 apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx diff --git a/apps/meteor/app/otr/client/OtrRoomState.ts b/apps/meteor/app/otr/client/OtrRoomState.ts new file mode 100644 index 000000000000..41233cef9a5d --- /dev/null +++ b/apps/meteor/app/otr/client/OtrRoomState.ts @@ -0,0 +1,9 @@ +export enum OtrRoomState { + DISABLED = 'DISABLED', + NOT_STARTED = 'NOT_STARTED', + ESTABLISHING = 'ESTABLISHING', + ESTABLISHED = 'ESTABLISHED', + ERROR = 'ERROR', + TIMEOUT = 'TIMEOUT', + DECLINED = 'DECLINED', +} diff --git a/apps/meteor/app/otr/client/rocketchat.otr.js b/apps/meteor/app/otr/client/rocketchat.otr.js index c4eee72e92c6..f8a660d7fcfa 100644 --- a/apps/meteor/app/otr/client/rocketchat.otr.js +++ b/apps/meteor/app/otr/client/rocketchat.otr.js @@ -7,6 +7,7 @@ import { Notifications } from '../../notifications'; import { t } from '../../utils'; import { onClientMessageReceived } from '../../../client/lib/onClientMessageReceived'; import { onClientBeforeSendMessage } from '../../../client/lib/onClientBeforeSendMessage'; +import { OtrRoomState } from './OtrRoomState'; class OTRClass { constructor() { @@ -56,7 +57,11 @@ Meteor.startup(function () { }); onClientBeforeSendMessage.use(function (message) { - if (message.rid && OTR.getInstanceByRoomId(message.rid) && OTR.getInstanceByRoomId(message.rid).established.get()) { + if ( + message.rid && + OTR.getInstanceByRoomId(message.rid) && + OTR.getInstanceByRoomId(message.rid).state.get() === OtrRoomState.ESTABLISHED + ) { return OTR.getInstanceByRoomId(message.rid) .encrypt(message) .then((msg) => { @@ -69,7 +74,11 @@ Meteor.startup(function () { }); onClientMessageReceived.use(function (message) { - if (message.rid && OTR.getInstanceByRoomId(message.rid) && OTR.getInstanceByRoomId(message.rid).established.get()) { + if ( + message.rid && + OTR.getInstanceByRoomId(message.rid) && + OTR.getInstanceByRoomId(message.rid).state.get() === OtrRoomState.ESTABLISHED + ) { if (message.notification) { message.msg = t('Encrypted_message'); return Promise.resolve(message); diff --git a/apps/meteor/app/otr/client/rocketchat.otr.room.js b/apps/meteor/app/otr/client/rocketchat.otr.room.js index 1c943066813c..95b57ad70b78 100644 --- a/apps/meteor/app/otr/client/rocketchat.otr.room.js +++ b/apps/meteor/app/otr/client/rocketchat.otr.room.js @@ -15,6 +15,7 @@ import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; import { imperativeModal } from '../../../client/lib/imperativeModal'; import GenericModal from '../../../client/components/GenericModal'; import { dispatchToastMessage } from '../../../client/lib/toast'; +import { OtrRoomState } from './OtrRoomState'; import { otrSystemMessages } from '../lib/constants'; import { APIClient } from '../../utils/client'; @@ -23,8 +24,8 @@ OTR.Room = class { this.userId = userId; this.roomId = roomId; this.peerId = getUidDirectMessage(roomId); - this.established = new ReactiveVar(false); - this.establishing = new ReactiveVar(false); + this.state = new ReactiveVar(OtrRoomState.NOT_STARTED); + this.isFirstOTR = true; this.userOnlineComputation = null; @@ -34,8 +35,17 @@ OTR.Room = class { this.sessionKey = null; } + setState(nextState) { + const currentState = this.state.get(); + if (currentState === nextState) { + return; + } + + this.state.set(nextState); + } + handshake(refresh) { - this.establishing.set(true); + this.setState(OtrRoomState.ESTABLISHING); this.firstPeer = true; this.generateKeyPair().then(() => { Notifications.notifyUser(this.peerId, 'otr', 'handshake', { @@ -63,6 +73,7 @@ OTR.Room = class { deny() { this.reset(); + this.setState(OtrRoomState.DECLINED); Notifications.notifyUser(this.peerId, 'otr', 'deny', { roomId: this.roomId, userId: this.userId, @@ -72,6 +83,7 @@ OTR.Room = class { end() { this.isFirstOTR = true; this.reset(); + this.setState(OtrRoomState.NOT_STARTED); Notifications.notifyUser(this.peerId, 'otr', 'end', { roomId: this.roomId, userId: this.userId, @@ -79,8 +91,6 @@ OTR.Room = class { } reset() { - this.establishing.set(false); - this.established.set(false); this.keyPair = null; this.exportedPublicKey = null; this.sessionKey = null; @@ -95,7 +105,7 @@ OTR.Room = class { this.userOnlineComputation = Tracker.autorun(() => { const $room = $(`#chat-window-${this.roomId}`); const $title = $('.rc-header__title', $room); - if (this.established.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHED) { if ($room.length && $title.length && !$('.otr-icon', $title).length) { $title.prepend(""); } @@ -125,6 +135,7 @@ OTR.Room = class { Meteor.call('deleteOldOTRMessages', this.roomId); }) .catch((e) => { + this.setState(OtrRoomState.ERROR); dispatchToastMessage({ type: 'error', message: e }); }); } @@ -202,6 +213,7 @@ OTR.Room = class { return EJSON.stringify(output); }) .catch(() => { + this.setState(OtrRoomState.ERROR); throw new Meteor.Error('encryption-error', 'Encryption error.'); }); } @@ -247,6 +259,7 @@ OTR.Room = class { }) .catch((e) => { dispatchToastMessage({ type: 'error', message: e }); + this.setState(OtrRoomState.ERROR); return message; }); } @@ -256,14 +269,14 @@ OTR.Room = class { case 'handshake': let timeout = null; const establishConnection = () => { - this.establishing.set(true); + this.setState(OtrRoomState.ESTABLISHING); Meteor.clearTimeout(timeout); this.generateKeyPair().then(() => { this.importPublicKey(data.publicKey).then(() => { this.firstPeer = false; goToRoomById(data.roomId); Meteor.defer(() => { - this.established.set(true); + this.setState(OtrRoomState.ESTABLISHED); this.acknowledge(); if (data.refresh) { Meteor.call('sendSystemMessages', this.roomId, Meteor.user(), otrSystemMessages.USER_KEY_REFRESHED_SUCCESSFULLY); @@ -275,11 +288,11 @@ OTR.Room = class { (async () => { const { username } = await Presence.get(data.userId); - if (data.refresh && this.established.get()) { + if (data.refresh && this.state.get() === OtrRoomState.ESTABLISHED) { this.reset(); establishConnection(); } else { - if (this.established.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHED) { this.reset(); } @@ -293,7 +306,11 @@ OTR.Room = class { }), confirmText: TAPi18n.__('Yes'), cancelText: TAPi18n.__('No'), - onClose: () => imperativeModal.close, + onClose: () => { + Meteor.clearTimeout(timeout); + this.deny(); + imperativeModal.close(); + }, onCancel: () => { Meteor.clearTimeout(timeout); this.deny(); @@ -308,7 +325,7 @@ OTR.Room = class { } timeout = Meteor.setTimeout(() => { - this.establishing.set(false); + this.setState(OtrRoomState.TIMEOUT); imperativeModal.close(); }, 10000); })(); @@ -316,7 +333,7 @@ OTR.Room = class { case 'acknowledge': this.importPublicKey(data.publicKey).then(() => { - this.established.set(true); + this.setState(OtrRoomState.ESTABLISHED); }); if (this.isFirstOTR) { Meteor.call('sendSystemMessages', this.roomId, Meteor.user(), otrSystemMessages.USER_JOINED_OTR); @@ -326,19 +343,9 @@ OTR.Room = class { case 'deny': (async () => { - const { username } = await Presence.get(this.peerId); - if (this.establishing.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHING) { this.reset(); - imperativeModal.open({ - component: GenericModal, - props: { - variant: 'warning', - title: TAPi18n.__('OTR'), - children: TAPi18n.__('Username_denied_the_OTR_session', { username }), - onClose: imperativeModal.close, - onConfirm: imperativeModal.close, - }, - }); + this.setState(OtrRoomState.DECLINED); } })(); break; @@ -347,8 +354,9 @@ OTR.Room = class { (async () => { const { username } = await Presence.get(this.peerId); - if (this.established.get()) { + if (this.state.get() === OtrRoomState.ESTABLISHED) { this.reset(); + this.setState(OtrRoomState.NOT_STARTED); imperativeModal.open({ component: GenericModal, props: { diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.js b/apps/meteor/client/views/room/contextualBar/OTR/OTR.js deleted file mode 100644 index fc276aa4e26e..000000000000 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTR.js +++ /dev/null @@ -1,53 +0,0 @@ -import { Box, Button, ButtonGroup, Throbber } from '@rocket.chat/fuselage'; -import React from 'react'; - -import VerticalBar from '../../../../components/VerticalBar'; -import { useTranslation } from '../../../../contexts/TranslationContext'; - -const OTR = ({ isEstablishing, isEstablished, isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh }) => { - const t = useTranslation(); - - return ( - <> - - - {t('OTR')} - {onClickClose && } - - - - {t('Off_the_record_conversation')} - - {!isEstablishing && !isEstablished && isOnline && ( - - )} - {isEstablishing && !isEstablished && isOnline && ( - <> - {' '} - {t('Please_wait_while_OTR_is_being_established')} {' '} - - )} - {isEstablished && isOnline && ( - - {onClickRefresh && ( - - )} - {onClickEnd && ( - - )} - - )} - - {!isOnline && {t('OTR_is_only_available_when_both_users_are_online')}} - - - ); -}; - -export default OTR; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx b/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx index cb6d2a66c85e..cde3786fb35d 100644 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTR.stories.tsx @@ -1,6 +1,7 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; import React from 'react'; +import { OtrRoomState } from '../../../../../app/otr/client/OtrRoomState'; import VerticalBar from '../../../../components/VerticalBar'; import OTR from './OTR'; @@ -19,21 +20,36 @@ const Template: ComponentStory = (args) => ; export const Default = Template.bind({}); Default.args = { isOnline: true, + otrState: OtrRoomState.NOT_STARTED, }; export const Establishing = Template.bind({}); Establishing.args = { isOnline: true, - isEstablishing: true, + otrState: OtrRoomState.ESTABLISHING, }; export const Established = Template.bind({}); Established.args = { isOnline: true, - isEstablished: true, + otrState: OtrRoomState.ESTABLISHED, }; export const Unavailable = Template.bind({}); Unavailable.args = { isOnline: false, }; + +export const Timeout = Template.bind({}); +Timeout.args = { + isOnline: true, + otrState: OtrRoomState.TIMEOUT, + peerUsername: 'testUser', +}; + +export const Declined = Template.bind({}); +Declined.args = { + isOnline: true, + otrState: OtrRoomState.DECLINED, + peerUsername: 'testUser', +}; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx b/apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx new file mode 100644 index 000000000000..010397eefcb9 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTR.tsx @@ -0,0 +1,89 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Box, Button, Throbber } from '@rocket.chat/fuselage'; +import React, { MouseEventHandler, ReactElement } from 'react'; + +import { OtrRoomState } from '../../../../../app/otr/client/OtrRoomState'; +import VerticalBar from '../../../../components/VerticalBar'; +import { useTranslation } from '../../../../contexts/TranslationContext'; +import OTREstablished from './components/OTREstablished'; +import OTRStates from './components/OTRStates'; + +type OTRProps = { + isOnline: boolean; + onClickClose: MouseEventHandler; + onClickStart: () => void; + onClickEnd: () => void; + onClickRefresh: () => void; + otrState: string; + peerUsername: IUser['username']; +}; + +const OTR = ({ isOnline, onClickClose, onClickStart, onClickEnd, onClickRefresh, otrState, peerUsername }: OTRProps): ReactElement => { + const t = useTranslation(); + + const renderOTRState = (): ReactElement => { + switch (otrState) { + case OtrRoomState.NOT_STARTED: + return ( + + ); + case OtrRoomState.ESTABLISHING: + return ( + + {t('Please_wait_while_OTR_is_being_established')} + + + + + ); + case OtrRoomState.ESTABLISHED: + return ; + case OtrRoomState.DECLINED: + return ( + + ); + case OtrRoomState.TIMEOUT: + return ( + + ); + default: + return ( + + ); + } + }; + + return ( + <> + + + {t('OTR')} + {onClickClose && } + + + + {t('Off_the_record_conversation')} + {isOnline ? renderOTRState() : {t('OTR_is_only_available_when_both_users_are_online')}} + + + ); +}; + +export default OTR; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js b/apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js deleted file mode 100644 index 6042326c0f2c..000000000000 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTRModal.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Button, Box, ButtonGroup, Icon, Modal } from '@rocket.chat/fuselage'; -import React from 'react'; - -import { useTranslation } from '../../../../contexts/TranslationContext'; - -const OTRModal = ({ onCancel, onConfirm, confirmLabel = 'Ok', ...props }) => { - const t = useTranslation(); - return ( - - - {t('Timeout')} - - - - - - - - - - - - - - ); -}; - -export default OTRModal; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js b/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js deleted file mode 100644 index 0cb4bef8d4f9..000000000000 --- a/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.js +++ /dev/null @@ -1,67 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useEffect, useMemo, useCallback } from 'react'; - -import { OTR as ORTInstance } from '../../../../../app/otr/client/rocketchat.otr'; -import { useSetModal } from '../../../../contexts/ModalContext'; -import { usePresence } from '../../../../hooks/usePresence'; -import { useReactiveValue } from '../../../../hooks/useReactiveValue'; -import OTR from './OTR'; -import OTRModal from './OTRModal'; - -const OTRWithData = ({ rid, tabBar }) => { - const onClickClose = useMutableCallback(() => tabBar && tabBar.close()); - - const setModal = useSetModal(); - const closeModal = useMutableCallback(() => setModal()); - const otr = useMemo(() => ORTInstance.getInstanceByRoomId(rid), [rid]); - - const [isEstablished, isEstablishing] = useReactiveValue( - useCallback(() => (otr ? [otr.established.get(), otr.establishing.get()] : [false, false]), [otr]), - ); - - const userStatus = usePresence(otr.peerId)?.status; - - const isOnline = !['offline', 'loading'].includes(userStatus); - - const handleStart = () => { - otr.handshake(); - }; - - const handleEnd = () => otr?.end(); - - const handleReset = () => { - otr.reset(); - otr.handshake(true); - }; - - useEffect(() => { - if (isEstablished) { - return closeModal(); - } - - if (!isEstablishing) { - return; - } - - const timeout = setTimeout(() => { - otr.establishing.set(false); - setModal(); - }, 10000); - - return () => clearTimeout(timeout); - }, [closeModal, isEstablished, isEstablishing, setModal, otr]); - - return ( - - ); -}; - -export default OTRWithData; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx b/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx new file mode 100644 index 000000000000..b19debfeb881 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/OTRWithData.tsx @@ -0,0 +1,60 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import React, { useEffect, useMemo, useCallback, ReactElement } from 'react'; + +import { OtrRoomState } from '../../../../../app/otr/client/OtrRoomState'; +import { OTR as ORTInstance } from '../../../../../app/otr/client/rocketchat.otr'; +import { usePresence } from '../../../../hooks/usePresence'; +import { useReactiveValue } from '../../../../hooks/useReactiveValue'; +import { useTabBarClose } from '../../providers/ToolboxProvider'; +import OTR from './OTR'; + +const OTRWithData = ({ rid }: { rid: IRoom['_id'] }): ReactElement => { + const closeTabBar = useTabBarClose(); + const otr = useMemo(() => ORTInstance.getInstanceByRoomId(rid), [rid]); + const otrState = useReactiveValue(useCallback(() => (otr ? otr.state.get() : OtrRoomState.ERROR), [otr])); + const peerUserPresence = usePresence(otr.peerId); + const userStatus = peerUserPresence?.status; + const peerUsername = peerUserPresence?.username; + const isOnline = !['offline', 'loading'].includes(userStatus || ''); + + const handleStart = (): void => { + otr.handshake(); + }; + + const handleEnd = (): void => { + otr?.end(); + }; + + const handleReset = (): void => { + otr.reset(); + otr.handshake(true); + }; + + useEffect(() => { + if (otrState !== OtrRoomState.ESTABLISHING) { + return; + } + + const timeout = setTimeout(() => { + otr.state.set(OtrRoomState.TIMEOUT); + }, 10000); + + return (): void => { + clearTimeout(timeout); + }; + }, [otr, otrState]); + + return ( + + ); +}; + +export default OTRWithData; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx b/apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx new file mode 100644 index 000000000000..03dbf68273fd --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/components/OTREstablished.tsx @@ -0,0 +1,24 @@ +import { Button, ButtonGroup } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import { useTranslation } from '../../../../../contexts/TranslationContext'; + +type OTREstablishedProps = { + onClickRefresh: () => void; + onClickEnd: () => void; +}; + +const OTREstablished = ({ onClickRefresh, onClickEnd }: OTREstablishedProps): ReactElement => { + const t = useTranslation(); + + return ( + + + + + ); +}; + +export default OTREstablished; diff --git a/apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx b/apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx new file mode 100644 index 000000000000..28bf858726f4 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/OTR/components/OTRStates.tsx @@ -0,0 +1,28 @@ +import { Icon, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; +import React, { ReactElement, ComponentProps } from 'react'; + +import { useTranslation } from '../../../../../contexts/TranslationContext'; + +type OTRStatesProps = { + title: string; + description: string; + icon: ComponentProps['name']; + onClickStart: () => void; +}; + +const OTRStates = ({ title, description, icon, onClickStart }: OTRStatesProps): ReactElement => { + const t = useTranslation(); + + return ( + + + {title} + {description} + + {t('New_OTR_Chat')} + + + ); +}; + +export default OTRStates; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 74b8c56fd3ed..1a255d598cad 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3149,6 +3149,7 @@ "New_logs": "New logs", "New_Message_Notification": "New Message Notification", "New_messages": "New messages", + "New_OTR_Chat": "New OTR Chat", "New_password": "New Password", "New_Password_Placeholder": "Please enter new password...", "New_Priority": "New Priority", @@ -3343,6 +3344,12 @@ "others": "others", "Others": "Others", "OTR": "OTR", + "OTR_Chat_Declined_Title": "OTR Chat invite Declined", + "OTR_Chat_Declined_Description": "%s declined OTR chat invite. For privacy protection local cache was deleted, including all related system messages.", + "OTR_Chat_Error_Title":"Chat ended due to failed key refresh", + "OTR_Chat_Error_Description":"For privacy protection local cache was deleted, including all related system messages.", + "OTR_Chat_Timeout_Title": "OTR chat invite expired", + "OTR_Chat_Timeout_Description": "%s failed to accept OTR chat invite in time. For privacy protection local cache was deleted, including all related system messages.", "OTR_Enable_Description": "Enable option to use off-the-record (OTR) messages in direct messages between 2 users. OTR messages are not recorded on the server and exchanged directly and encrypted between the 2 users.", "OTR_message": "OTR Message", "OTR_is_only_available_when_both_users_are_online": "OTR is only available when both users are online", From c63575ad52ac2339cb6ee73f6c67453e196b4e27 Mon Sep 17 00:00:00 2001 From: Debdut Chakraborty Date: Thu, 21 Apr 2022 02:25:29 +0530 Subject: [PATCH 08/10] [FIX] room creation fails if app framework is disabled (#25200) --- apps/meteor/app/lib/server/functions/createRoom.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 39877eba833e..903d942dba19 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -102,12 +102,12 @@ export const createRoom = function ( throw new Meteor.Error('error-app-prevented', 'A Rocket.Chat App prevented the room creation.'); } - const { _USERNAMES, ...result } = Promise.await( + const eventResult = Promise.await( Apps.triggerEvent('IPreRoomCreateModify', Promise.await(Apps.triggerEvent('IPreRoomCreateExtend', tmp))), ); - if (typeof result === 'object') { - Object.assign(roomProps, result); + if (eventResult && typeof eventResult === 'object' && delete eventResult._USERNAMES) { + Object.assign(roomProps, eventResult); } if (type === 'c') { From b06802b2eef78f5f29517cb1b31c56cf4b10e4c9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 20 Apr 2022 15:28:49 -0600 Subject: [PATCH 09/10] [IMPROVE] Performance for some Omnichannel features (#25217) --- .../app/livechat/server/api/lib/queue.js | 8 +++- .../app/livechat/server/api/v1/config.js | 5 ++- .../app/livechat/server/lib/Contacts.js | 8 ++-- .../app/livechat/server/lib/Livechat.js | 2 +- .../server/models/LivechatCustomField.js | 2 + .../server/models/LivechatDepartment.js | 2 + .../models/server/models/LivechatInquiry.js | 1 + .../app/models/server/models/LivechatRooms.js | 1 + .../models/server/models/LivechatVisitors.js | 6 ++- apps/meteor/app/models/server/models/Users.js | 1 - .../app/models/server/raw/LivechatRooms.js | 16 +++++-- .../app/models/server/raw/LivechatVisitors.ts | 2 +- .../livechat-enterprise/server/lib/units.js | 16 ------- .../livechat-enterprise/server/lib/units.ts | 20 +++++++++ .../server/methods/getUnitsFromUserRoles.ts | 21 ++++++--- .../meteor/server/startup/migrations/index.ts | 1 + apps/meteor/server/startup/migrations/v260.ts | 43 +++++++++++++++++++ 17 files changed, 118 insertions(+), 37 deletions(-) delete mode 100644 apps/meteor/ee/app/livechat-enterprise/server/lib/units.js create mode 100644 apps/meteor/ee/app/livechat-enterprise/server/lib/units.ts create mode 100644 apps/meteor/server/startup/migrations/v260.ts diff --git a/apps/meteor/app/livechat/server/api/lib/queue.js b/apps/meteor/app/livechat/server/api/lib/queue.js index db93ea186a17..730a8e99cc8c 100644 --- a/apps/meteor/app/livechat/server/api/lib/queue.js +++ b/apps/meteor/app/livechat/server/api/lib/queue.js @@ -6,7 +6,7 @@ export async function findQueueMetrics({ userId, agentId, includeOfflineAgents, throw new Error('error-not-authorized'); } - const queue = await LivechatRooms.getQueueMetrics({ + const result = await LivechatRooms.getQueueMetrics({ departmentId, agentId, includeOfflineAgents, @@ -16,7 +16,11 @@ export async function findQueueMetrics({ userId, agentId, includeOfflineAgents, count, }, }); - const total = (await LivechatRooms.getQueueMetrics({ departmentId, agentId, includeOfflineAgents })).length; + + const { + sortedResults: queue, + totalCount: [{ total } = { total: 0 }], + } = result[0]; return { queue, diff --git a/apps/meteor/app/livechat/server/api/v1/config.js b/apps/meteor/app/livechat/server/api/v1/config.js index 678b4f0b55f9..c97a5b2a3b56 100644 --- a/apps/meteor/app/livechat/server/api/v1/config.js +++ b/apps/meteor/app/livechat/server/api/v1/config.js @@ -1,9 +1,12 @@ import { Match, check } from 'meteor/check'; +import mem from 'mem'; import { API } from '../../../../api/server'; import { Livechat } from '../../lib/Livechat'; import { settings, findOpenRoom, getExtraConfigInfo, findAgent } from '../lib/livechat'; +const cachedSettings = mem(settings, { maxAge: 1000, cacheKey: JSON.stringify }); + API.v1.addRoute('livechat/config', { async get() { try { @@ -20,7 +23,7 @@ API.v1.addRoute('livechat/config', { const { token, department, businessUnit } = this.queryParams; - const config = await settings({ businessUnit }); + const config = await cachedSettings({ businessUnit }); const status = Livechat.online(department); const guest = token && Livechat.findGuest(token); diff --git a/apps/meteor/app/livechat/server/lib/Contacts.js b/apps/meteor/app/livechat/server/lib/Contacts.js index 5321c8d73e75..980828f0a360 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.js +++ b/apps/meteor/app/livechat/server/lib/Contacts.js @@ -7,6 +7,8 @@ export const Contacts = { registerContact({ token, name, email, phone, username, customFields = {}, contactManager = {} } = {}) { check(token, String); + const visitorEmail = s.trim(email).toLowerCase(); + let contactId; const updateUser = { $set: { @@ -25,7 +27,7 @@ export const Contacts = { let existingUser = null; - if (s.trim(email) !== '' && (existingUser = LivechatVisitors.findOneGuestByEmailAddress(email))) { + if (visitorEmail !== '' && (existingUser = LivechatVisitors.findOneGuestByEmailAddress(visitorEmail))) { contactId = existingUser._id; } else { const userData = { @@ -39,9 +41,9 @@ export const Contacts = { updateUser.$set.name = name; updateUser.$set.phone = (phone && [{ phoneNumber: phone }]) || null; - updateUser.$set.visitorEmails = (email && [{ address: email }]) || null; + updateUser.$set.visitorEmails = (visitorEmail && [{ address: visitorEmail }]) || null; - const allowedCF = LivechatCustomField.find({ scope: 'visitor' }).map(({ _id }) => _id); + const allowedCF = LivechatCustomField.find({ scope: 'visitor' }, { fields: { _id: 1 } }).map(({ _id }) => _id); const livechatData = Object.keys(customFields) .filter((key) => allowedCF.includes(key) && customFields[key] !== '' && customFields[key] !== undefined) diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 84cb444c968c..3c387b73f532 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -289,7 +289,7 @@ export const Livechat = { }; if (email) { - email = email.trim(); + email = email.trim().toLowerCase(); validateEmail(email); updateUser.$set.visitorEmails = [{ address: email }]; } diff --git a/apps/meteor/app/models/server/models/LivechatCustomField.js b/apps/meteor/app/models/server/models/LivechatCustomField.js index 10414f1aa61d..3875fc2b959d 100644 --- a/apps/meteor/app/models/server/models/LivechatCustomField.js +++ b/apps/meteor/app/models/server/models/LivechatCustomField.js @@ -8,6 +8,8 @@ import { Base } from './_Base'; export class LivechatCustomField extends Base { constructor() { super('livechat_custom_field'); + + this.tryEnsureIndex({ scope: 1 }); } // FIND diff --git a/apps/meteor/app/models/server/models/LivechatDepartment.js b/apps/meteor/app/models/server/models/LivechatDepartment.js index 858397462b5a..8621ebcd4553 100644 --- a/apps/meteor/app/models/server/models/LivechatDepartment.js +++ b/apps/meteor/app/models/server/models/LivechatDepartment.js @@ -16,6 +16,8 @@ export class LivechatDepartment extends Base { numAgents: 1, enabled: 1, }); + this.tryEnsureIndex({ parentId: 1 }, { sparse: true }); + this.tryEnsureIndex({ ancestors: 1 }, { sparse: true }); } // FIND diff --git a/apps/meteor/app/models/server/models/LivechatInquiry.js b/apps/meteor/app/models/server/models/LivechatInquiry.js index a3da82329d9d..c85169cc15a7 100644 --- a/apps/meteor/app/models/server/models/LivechatInquiry.js +++ b/apps/meteor/app/models/server/models/LivechatInquiry.js @@ -11,6 +11,7 @@ export class LivechatInquiry extends Base { this.tryEnsureIndex({ department: 1 }); this.tryEnsureIndex({ status: 1 }); // 'ready', 'queued', 'taken' this.tryEnsureIndex({ queueOrder: 1, estimatedWaitingTimeQueue: 1, estimatedServiceTimeAt: 1 }); + this.tryEnsureIndex({ 'v.token': 1, 'status': 1 }); // visitor token and status } findOneById(inquiryId) { diff --git a/apps/meteor/app/models/server/models/LivechatRooms.js b/apps/meteor/app/models/server/models/LivechatRooms.js index f6ceb96c9ef9..4ce7aeb74ec2 100644 --- a/apps/meteor/app/models/server/models/LivechatRooms.js +++ b/apps/meteor/app/models/server/models/LivechatRooms.js @@ -22,6 +22,7 @@ export class LivechatRooms extends Base { this.tryEnsureIndex({ 'v._id': 1 }, { sparse: true }); this.tryEnsureIndex({ t: 1, departmentId: 1, closedAt: 1 }, { partialFilterExpression: { closedAt: { $exists: true } } }); this.tryEnsureIndex({ source: 1 }, { sparse: true }); + this.tryEnsureIndex({ departmentAncestors: 1 }, { sparse: true }); } findLivechat(filter = {}, offset = 0, limit = 20) { diff --git a/apps/meteor/app/models/server/models/LivechatVisitors.js b/apps/meteor/app/models/server/models/LivechatVisitors.js index e88ec2432203..239e30b5c630 100644 --- a/apps/meteor/app/models/server/models/LivechatVisitors.js +++ b/apps/meteor/app/models/server/models/LivechatVisitors.js @@ -1,6 +1,5 @@ import _ from 'underscore'; import s from 'underscore.string'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Base } from './_Base'; import Settings from './Settings'; @@ -11,6 +10,9 @@ export class LivechatVisitors extends Base { this.tryEnsureIndex({ token: 1 }); this.tryEnsureIndex({ 'phone.phoneNumber': 1 }, { sparse: true }); + this.tryEnsureIndex({ 'visitorEmails.address': 1 }, { sparse: true }); + this.tryEnsureIndex({ name: 1 }, { sparse: true }); + this.tryEnsureIndex({ username: 1 }); } /** @@ -200,7 +202,7 @@ export class LivechatVisitors extends Base { findOneGuestByEmailAddress(emailAddress) { const query = { - 'visitorEmails.address': new RegExp(`^${escapeRegExp(emailAddress)}$`, 'i'), + 'visitorEmails.address': String(emailAddress).toLowerCase(), }; return this.findOne(query); diff --git a/apps/meteor/app/models/server/models/Users.js b/apps/meteor/app/models/server/models/Users.js index c061cb5491a7..633fe3188450 100644 --- a/apps/meteor/app/models/server/models/Users.js +++ b/apps/meteor/app/models/server/models/Users.js @@ -52,7 +52,6 @@ export class Users extends Base { this.tryEnsureIndex({ statusConnection: 1 }, { sparse: 1 }); this.tryEnsureIndex({ appId: 1 }, { sparse: 1 }); this.tryEnsureIndex({ type: 1 }); - this.tryEnsureIndex({ 'visitorEmails.address': 1 }); this.tryEnsureIndex({ federation: 1 }, { sparse: true }); this.tryEnsureIndex({ isRemote: 1 }, { sparse: true }); this.tryEnsureIndex({ 'services.saml.inResponseTo': 1 }); diff --git a/apps/meteor/app/models/server/raw/LivechatRooms.js b/apps/meteor/app/models/server/raw/LivechatRooms.js index d76ed420668b..7b1d9b6b74d6 100644 --- a/apps/meteor/app/models/server/raw/LivechatRooms.js +++ b/apps/meteor/app/models/server/raw/LivechatRooms.js @@ -84,13 +84,23 @@ export class LivechatRoomsRaw extends BaseRaw { firstParams.push(matchUsers); } const sort = { $sort: options.sort || { chats: -1 } }; - const params = [...firstParams, usersGroup, project, sort]; + const pagination = [sort]; + if (options.offset) { - params.push({ $skip: options.offset }); + pagination.push({ $skip: options.offset }); } if (options.count) { - params.push({ $limit: options.count }); + pagination.push({ $limit: options.count }); } + + const facet = { + $facet: { + sortedResults: pagination, + totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }], + }, + }; + + const params = [...firstParams, usersGroup, project, facet]; return this.col.aggregate(params).toArray(); } diff --git a/apps/meteor/app/models/server/raw/LivechatVisitors.ts b/apps/meteor/app/models/server/raw/LivechatVisitors.ts index fcccbb8d42f6..8cb7ebbfb75f 100644 --- a/apps/meteor/app/models/server/raw/LivechatVisitors.ts +++ b/apps/meteor/app/models/server/raw/LivechatVisitors.ts @@ -89,7 +89,7 @@ export class LivechatVisitorsRaw extends BaseRaw { const query = { $or: [ { - 'visitorEmails.address': filter, + 'visitorEmails.address': _emailOrPhoneOrNameOrUsername, }, { 'phone.phoneNumber': _emailOrPhoneOrNameOrUsername, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/units.js b/apps/meteor/ee/app/livechat-enterprise/server/lib/units.js deleted file mode 100644 index c06e24813d4e..000000000000 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/units.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import LivechatUnit from '../../../models/server/models/LivechatUnit'; - -export function hasUnits() { - return LivechatUnit.unfilteredFind({ type: 'u' }).count() > 0; -} - -export function getUnitsFromUser() { - if (!hasUnits()) { - return; - } - - // TODO remove this Meteor.call as this is used undirectly by models - return Meteor.call('livechat:getUnitsFromUserRoles'); -} diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/units.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/units.ts new file mode 100644 index 000000000000..26c5917a3efe --- /dev/null +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/units.ts @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; +import mem from 'mem'; + +import LivechatUnit from '../../../models/server/models/LivechatUnit'; + +export function hasUnits(): boolean { + // @ts-expect-error - this prop is injected dynamically on ee license + return LivechatUnit.unfilteredFind({ type: 'u' }).count() > 0; +} + +// Units should't change really often, so we can cache the result +const memoizedHasUnits = mem(hasUnits, { maxAge: 5000 }); + +export function getUnitsFromUser(): { [k: string]: any }[] | undefined { + if (!memoizedHasUnits()) { + return; + } + + return Meteor.call('livechat:getUnitsFromUser'); +} diff --git a/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts b/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts index 2f0d460ecef5..0d9c2b7e0e01 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts @@ -1,15 +1,22 @@ import { Meteor } from 'meteor/meteor'; +import mem from 'mem'; import { hasAnyRoleAsync } from '../../../../../app/authorization/server/functions/hasRole'; import LivechatUnit from '../../../models/server/models/LivechatUnit'; -Meteor.methods({ - async 'livechat:getUnitsFromUserRoles'() { - const user = Meteor.user(); - if (!user || (await hasAnyRoleAsync(user._id, ['admin', 'livechat-manager']))) { - return; - } +export async function getUnitsFromUserRoles(user: string | null): Promise<{ [k: string]: any }[] | undefined> { + if (!user || (await hasAnyRoleAsync(user, ['admin', 'livechat-manager']))) { + return; + } + + return (await hasAnyRoleAsync(user, ['livechat-monitor'])) && LivechatUnit.findByMonitorId(user); +} - return (await hasAnyRoleAsync(user._id, ['livechat-monitor'])) && LivechatUnit.findByMonitorId(user._id); +const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: 5000 }); + +Meteor.methods({ + 'livechat:getUnitsFromUser'(): Promise<{ [k: string]: any }[] | undefined> { + const user = Meteor.userId(); + return memoizedGetUnitFromUserRoles(user); }, }); diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts index 6461b59d9433..1e8a6c5bcf82 100644 --- a/apps/meteor/server/startup/migrations/index.ts +++ b/apps/meteor/server/startup/migrations/index.ts @@ -83,4 +83,5 @@ import './v256'; import './v257'; import './v258'; import './v259'; +import './v260'; import './xrun'; diff --git a/apps/meteor/server/startup/migrations/v260.ts b/apps/meteor/server/startup/migrations/v260.ts new file mode 100644 index 000000000000..233334617aa1 --- /dev/null +++ b/apps/meteor/server/startup/migrations/v260.ts @@ -0,0 +1,43 @@ +import { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { BulkWriteOperation, Cursor } from 'mongodb'; + +import { addMigration } from '../../lib/migrations'; +import { LivechatVisitors, Users } from '../../../app/models/server'; +import { LivechatVisitors as VisitorsRaw } from '../../../app/models/server/raw'; + +const getNextPageCursor = (skip: number, limit: number): Cursor => { + return LivechatVisitors.find({ 'visitorEmails.address': /[A-Z]/ }, { skip, limit, sort: { _id: 1 } }); +}; + +// Convert all visitor emails to lowercase +addMigration({ + version: 260, + up() { + const updates: BulkWriteOperation[] = []; + const count = LivechatVisitors.find({ 'visitorEmails.address': /[A-Z]/ }).count(); + const limit = 5000; + let skip = 0; + + const incrementSkip = (by: number): void => { + skip += by; + updates.length = 0; + }; + while (skip <= count) { + getNextPageCursor(skip, limit).forEach((user: ILivechatVisitor) => { + const visitorEmails = user.visitorEmails?.map((e) => { + e.address = e.address.toLowerCase(); + return e; + }); + updates.push({ updateOne: { filter: { _id: user._id }, update: { $set: { visitorEmails } } } }); + }); + + if (updates.length) { + Promise.await(VisitorsRaw.col.bulkWrite(updates)); + } + + incrementSkip(limit); + } + + Users.tryDropIndex({ 'visitorEmails.address': 1 }); + }, +}); From fcbb58cbee2dffdc22b6d6d32b5ee51343a4f9cd Mon Sep 17 00:00:00 2001 From: Filipe Marins Date: Wed, 20 Apr 2022 18:31:26 -0300 Subject: [PATCH 10/10] Regression: Show username and real name on the message system (#25254) --- .../MessageList/components/MessageHeader.tsx | 7 ++++- .../MessageList/components/MessageSystem.tsx | 29 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/views/room/MessageList/components/MessageHeader.tsx b/apps/meteor/client/views/room/MessageList/components/MessageHeader.tsx index ba981d486591..d286edfe1805 100644 --- a/apps/meteor/client/views/room/MessageList/components/MessageHeader.tsx +++ b/apps/meteor/client/views/room/MessageList/components/MessageHeader.tsx @@ -40,11 +40,16 @@ const MessageHeader: FC<{ message: IMessage }> = ({ message }) => { title={!showUsername && !usernameAndRealNameAreSame ? `@${user.username}` : undefined} data-username={user.username} onClick={user.username !== undefined ? openUserCard(user.username) : undefined} + style={{ cursor: 'pointer' }} > {getUserDisplayName(user.name, user.username, showRealName)} {showUsername && ( - + @{user.username} )} diff --git a/apps/meteor/client/views/room/MessageList/components/MessageSystem.tsx b/apps/meteor/client/views/room/MessageList/components/MessageSystem.tsx index 86066be3c0b8..2a1089556181 100644 --- a/apps/meteor/client/views/room/MessageList/components/MessageSystem.tsx +++ b/apps/meteor/client/views/room/MessageList/components/MessageSystem.tsx @@ -7,6 +7,7 @@ import { MessageSystemName, MessageSystemTimestamp, MessageSystemBlock, + MessageUsername, } from '@rocket.chat/fuselage'; import React, { FC, memo } from 'react'; @@ -15,14 +16,23 @@ import Attachments from '../../../../components/Message/Attachments'; import MessageActions from '../../../../components/Message/MessageActions'; import UserAvatar from '../../../../components/avatar/UserAvatar'; import { TranslationKey, useTranslation } from '../../../../contexts/TranslationContext'; +import { useUserData } from '../../../../hooks/useUserData'; +import { getUserDisplayName } from '../../../../lib/getUserDisplayName'; +import { UserPresence } from '../../../../lib/presence'; import { useMessageActions, useMessageRunActionLink } from '../../contexts/MessageContext'; -import { useMessageListShowRealName } from '../contexts/MessageListContext'; +import { useMessageListShowRealName, useMessageListShowUsername } from '../contexts/MessageListContext'; export const MessageSystem: FC<{ message: IMessage }> = ({ message }) => { const t = useTranslation(); - const { formatters } = useMessageActions(); + const { + actions: { openUserCard }, + formatters, + } = useMessageActions(); const runActionLink = useMessageRunActionLink(); - const showUsername = useMessageListShowRealName(); + const showRealName = useMessageListShowRealName(); + const user: UserPresence = { ...message.u, roles: [], ...useUserData(message.u._id) }; + const usernameAndRealNameAreSame = !user.name || user.username === user.name; + const showUsername = useMessageListShowUsername() && showRealName && !usernameAndRealNameAreSame; const messageType = MessageTypes.getType(message); @@ -33,7 +43,18 @@ export const MessageSystem: FC<{ message: IMessage }> = ({ message }) => { - {(showUsername && message.u.name) || message.u.username} + + {getUserDisplayName(user.name, user.username, showRealName)} + + {showUsername && ( + + @{user.username} + + )} {messageType && (