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/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') { diff --git a/apps/meteor/app/lib/server/startup/settings.ts b/apps/meteor/app/lib/server/startup/settings.ts index b9033bf2a594..bf3a97cfa14d 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/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/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']> { 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/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/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/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/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/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/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/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 5d93d9a2760a..56ca3ad2c525 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,22 @@ 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 voipRetryCount = useSetting('VoIP_Retry_Count'); 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; @@ -56,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); @@ -76,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/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/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/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/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/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 && ( 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, 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 9622b4245d7d..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/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/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index c5a95367c9de..f9af8d554928 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,7 +3344,14 @@ "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", "Out_of_seats": "Out of Seats", "Outgoing": "Outgoing", @@ -4005,6 +4013,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", @@ -4767,6 +4776,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/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 @@ + + + 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 { diff --git a/apps/meteor/server/startup/migrations/v260.ts b/apps/meteor/server/startup/migrations/v260.ts index 5e375b0b4eba..233334617aa1 100644 --- a/apps/meteor/server/startup/migrations/v260.ts +++ b/apps/meteor/server/startup/migrations/v260.ts @@ -1,17 +1,43 @@ +import { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { BulkWriteOperation, Cursor } from 'mongodb'; + import { addMigration } from '../../lib/migrations'; -import { Settings } from '../../../app/models/server/raw'; -import { Users } from '../../../app/models/server'; +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() { - Users.update( - { 'settings.preferences.enableLegacyMessages': { $exists: 1 } }, - { - $unset: { 'settings.preferences.enableLegacyMessages': 1 }, - }, - { multi: true }, - ); - return Settings.removeById('Accounts_Default_User_Preferences_enableLegacyMessages'); + 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 }); }, }); 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"; 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 }; + }; };