[NEW] VoIP Support for Omnichannel (#23102)

* Initial voip support and services creation

* remove livechat references

* remove livechat references

* [NEW] SIP Integration (#23142)

* Initial commit for SIP code

* Adding SIP library framework for doing VoIP.

* Clickup Tasks : https://app.clickup.com/t/7qdnh4
Description : Adding sip.js node dependency. Hence committing package.json and package-lock.json

* Clicup Task : https://app.clickup.com/t/7qdnh4
Description :
1. Added level based logging class.
2. Added necessary logs in different files.
3. Added comments on important functions.

* Update client/components/voip/RegisterHandlerDelegate.ts

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Clickup Tasks : https://app.clickup.com/t/7qdnh4
Description:
Handling code-review comments.
1. Renamed delegate interface classes and files. Prefixed I before the name.
2. Removed unused files.
3. Renamed User.ts to VoIPUser.ts to avoid confusion.
4. Added interface for voip configuration.
5. Side effect changes in VoIPLayout.ts because of the above changes.

* Clickup Tasks : https://app.clickup.com/t/7qdnh4
Description : Converting javascript files to typescript files.

* Clickup Tasks : https://app.clickup.com/t/7qdnh4
Description : Added missing return type for a function.

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] Voip: Permissions (#23134)

* Create new set of livechat permissions for voip

* Update app/authorization/server/startup.js

Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>

* Move EE permissions to EE

* Apply suggestions from CR

Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>

* [NEW] Sidebar section with VOIP call-available icon (#23203)

* create voip call icon in omnichannel section

* upgrade fuselage version

* remove success prop

* phone-disabled icon

* [NEW] Add new Endpoints to manage VoIP server configs (#23239)

* create voip server config collection in DB + modify interfaces

* Add new endpoints to fetch management and server-config info

* Add new endpoints to add or update voip server configs

* Add translations + move serverName property to IVoipServerConfig interface

* Remove VoipServerConfiguration DB model and rely on DB Raw module to create collection

* archive server configs instead of completely deleting them upon update/insert

* Add comment for future scope

* Apply suggestions from code review

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Apply suggestions from code review

* remove deactive logic from endpoint

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] Framework for connecting to asterisk (#23251)

* Clickup task : https://app.clickup.com/t/7qerzq
Description:
1. Adding connector framework
2. interface for connecting to asterisk manager interface.
3. Added Endpoint handling command which can query list of endpoints and details of a single endpoint.
4. Added REST APIs to access the connecter.
6. Moved logger to ./lib and changed the corresponding paths

* Clickup task : https://app.clickup.com/t/7qerzq
Description:
1. Fixed lint error in Logger.
2. Commented hardcoded server path for timebeing as this code will not be used directly.

* Clickup task : https://app.clickup.com/t/7qerzq
Description:
1. Added new API in asterisk-connector to get the registration information.
2. Used this API for registering the demo endpoint.
3. Added a new interface for the return types of the connector.
4. Modified Command and PJSIPEndpoint to add a declaration for the multiple return types as a result of executeCommand
5. Modified CommandHandler to to add a declaration for the multiple return types as a result of executeCommand

* Clickup task : https://app.clickup.com/t/7qerzq
Description:
Deleting unnecessary code.

* Clickup task: https://app.clickup.com/t/7qerzq
Description:
1. Handled code review comments.
2. Modified client side logging classname and file name. Imported new classname and filename
in the client code.
3. Moved server side logging to Pino based logging. Earlier it was using the same logger
that was used in the client.
4. Removed optional methods from the interfaces and removed unnecessary |undefined| checks from
the method calls.

* Clickup task: https://app.clickup.com/t/7qerzq
Description:
Fixed some old style logging statements. They were missed to be replaced in the previous commit.

* [NEW] Adding Queue management code for fetching ACD queue summary, queue details and calls waiting in the queue (#23371)

* Clickup task : https://app.clickup.com/t/7qex83
Description:
1. This commit, at its base, adds a functionality to fetch the calls waiting in the queue for a given extension.
For this, it adds a new command object in server/services/voip/connector/asterisk/ami called |ACDQueue|.
ACD queue is capable of fetching various queue parameters such as queue summary, details of a particular queue (Members of a given queue)
It also provides a set of new APIs for fetching queue summary |queues.getSummary| and fetching the calls waiting in the queue
|queues.getCallWaitingInQueuesForThisExtension|.

2. Beyond this it also modifies the connector architecture a bit.
The reason for this change is that, it was observed that the AMI library does not have a way to turn off event handling.
Event handling gets turned off only when the connection to Asterisk AMI socket is disconnected. That may not be desirable.
So to avoid this, the architecture is changed as follows :
a. Connection registers for all management events.
b. Each command object registers the callback context for each manager event that it is interested in.
c. Connection (AMIConnection) goes thru the list of registered callbacks for a particular event. If it finds an array
of registered Callback contexts, it goes on calling each callback in the array.
d. Once the expected data is received, the command object unregisters the callback context. It is removed from the handler list for a particular
event.As a result of this design change existing command objects |PJSIPEndpoint| have been changed too to adapt to this arch change.

3. Removed hardcoding from extensions.ts. Now it reads the callserver information from the database, which was
hardcoded earlier.

* Clickup task : https://app.clickup.com/t/7qex83
Description:
1. Fixed review comments.
2. Simplified some nested ifs suggested in review comments.
3. Added new consolidated type IVoipConnectorResult to contain either of the result to avoid
growing function signature as suggested in the comment.
4. Made necessary changes in REST API files which were necessary as above changes
created some side-effects.

* Clickup task : https://app.clickup.com/t/7qex83
Description:
1. Fixed code-review comments.

* Update app/api/server/v1/voip/extensions.ts

Fixed.

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Update app/api/server/v1/voip/extensions.ts

Fixed

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] Making connector as a part of VoipService and removing earlier hardcoding for management server (#23571)

* Clickup Task : https://app.clickup.com/t/7qeq76
Description :
Some background :
Issue#1 : CommandHandler class is an entry point to a connector to Asterisk. This command handler had hardcoded values till the APIs and database for the management API was getting ready.
Once it got ready there was a need to change it and use the database values. This design change is triggered by this need.
The aim for the re-design was that all the API access should happen via voip service. It was realised that |CommandHandler| gets created (Because it is declared globally in REST APIs) before the Voip service gets initialised. And because of this fact, CommandHandler does not get to read the values as the service has not yet started and initialised |VoipServerConfiguration|.

To fix this issue, |CommandHandler| has to be created after service and should be accessible only via service. So it has been moved to Voip service. Few more points to consider here is that
CommandHandler::initConnection may not work always. When Voip is getting used for the first time, the server values (management and callserver) will be empty. One has to add those values using the admin interface. So CommandHandler::initConnection failure should not cause server to crash. So errors from CommandHandler::initConnection should be written in logs and the code should move ahead.

Issue#2 : Some design refinements have been done. The intelligence in the REST APIs have been reduced. While building the code connector was exposed outside. Now connector is contained within the
service. Service contains all necessary implementation. The necessary changes have been done to support this architectural change.

Considering these points, following changes have been done.
1. Voip rest APIs which use CommandHandler (Queue and extension APIs) now query for the CommandHandler instead of creating it.
2. server-config.ts REST API for adding management interface reinitialises the connection after adding a management interface.
3. On the server side, Voip Service interface has been changed, to have new methods. Voip service and CommandHandler is changed to adapt to the design mentioned above.
4. Log level change in AMIConnection file.
5. Modified the IVoipService interface to contain all the necessary methods and changed service.ts to have the implementation.

* Update server/services/voip/service.ts

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Clickup Task : https://app.clickup.com/t/7qeq76
Description :
Fixing review comments.

* Clickup task : https://app.clickup.com/t/7qeq76
Description:
Fixing review comments.

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] : Registering SIP user agent on click event of the phone button on sidebar (#23550)

* Clickup task : https://app.clickup.com/t/du0e8p
Description :
This code manages the registering of SIP endpoint on a button click on the side bar. To achieve this, following things needs to be considered.
1. Voip User Object will be used by multiple UI components. e.g Register and Incoming call component need to use this component.
2. Once initialised, it will remain in the context till the Agent UI is active.
3. Voip user object uses callback mechanism to notify about different events such as incoming call, call establishment and call termination.

Design alternatives and decisions:
1. Placement of Voip User Object : It was earlier thought that it might make sense to add this object in its own Context and be managed by its own context provider. Voip object makes REST API calls. The requirement is also that, it must get initialised before sidebar/sections/Omnichannel.tsx comes to life. Hence (Considering my current knowledge of code), it must have got created before Omnichannel provider. Which means that OmnichannelProvider must be a child for Voip, which is not true as Omnichannel provider is not dependent on the creation of VoIP. So for timebeing, the Voip user object has been placed in |OmnichannelContext| and initialised in |OmnichannelProvider|.
2. Callbacks vs waiting on Promise: Most of the code in the repository is written without use of any callbacks. So there was a thought on if we could write this code without using callback. But considering the nature of voip calls and the way of using sip.js, it was more natural to write it using emitters and callbacks. There are multiple events happen when the call is received or dialed out. e.g Call getting established (This is must to handle becuase the call may fail because of some codec mismatch), call getting terminated. etc. Waiting for each of such promise and managing the state on UI client would have been a tricky job.

Current Design :
1. A simple wrapper |SimpleVoipUser| is written on top of more feature rich class |VoIPUser|. This class should be able to provide what we need in our omnichannel voip.
2. This |SimpleVoipUser| class is a part of |OmnichannelContext| and gets initialised in |OmnichannelProvider|. |OmnichannelContext| also contains |extensionConfig|, which is the necessary information needed for registering the extension.
3. In |OmnichannelProvider|, function initVoipLib is used for fetching necessary values using the REST API. |extensionConfig| and |SimpleVoipUser| objects get initialised there and they are ready to be used when |OmnichannelProvider| is loaded.
4. Media elements for rendering local and remote streams have been pulled out from |VoIPUserConfiguration|. They are converted in to a type |IMediaStreamRenderer|. The reason is that the configuration is passed when the component gets initialised. But because the calling component is going to be different, media elements will not be available during the creation of |SimpleVoipUser| (Which in turns needs these media elements for creating |VoIPUser|). So instead of passing it as a part of |VoIPUserConfiguration|, it can be passed as an argument to the constructor of |VoIPUser| if that information is available during the creation time, or can be passed in acceptCall function. Newly passed |IMediaStreamRenderer| replaces the old value if the old one is passed in the constructor.
5. |VoIPLayout| uses this new way of creating the Voip user objects and demonstrates how it will be used.

* Update client/components/voip/SimpleVoipUser.ts

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Update client/components/voip/SimpleVoipUser.ts

Fixed.

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Clickup task : https://app.clickup.com/t/du0e8p
Description : Fixing code review comments.

* Clickup task : https://app.clickup.com/t/du0e8p
Description: Fixing review comments.

* Clickup task: https://app.clickup.com/t/du0e8p
Description: Remove the hardcoding for ICE servers. Now we pull it from the admin
settings. The setting Id is 'WebRTC_Servers'.

* Clickup task: https://app.clickup.com/t/du0e8p
Description:
Fixing LGTM issue.

* Clickup task : https://app.clickup.com/t/du0e8p
Description : Fixing the issues post merging. Few thing were changed in the parent repo.
This workspace is for taking those changes in account.

* Clickup task : https://app.clickup.com/t/du0e8p
Description :
Fixing review comments.
1. Toggled the logic for the register icon.
when it is registered, icon should be green. i.e success and grayed out when not registered.
2. When it is registered, the icon should be of a phone. On clicking this, it would unregister, which will change the icon to striked out phone.

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] VoIP admin section (#23837)

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Fixing merge related issues with develop branch. (#23893)

* [NEW] : API endpoints for Agent-Extension Association and Database changes (#23736)

* Clickup task : https://app.clickup.com/t/7qee1v
Description : This commit adds the required APIs and permissions to run these APIs for following :
1. Create agent-extension association. (Access to admin)
2. Get extension associated with given agent name. (Access to agent, admin and manager)
3. Delete extension of a given agent. (Access to admin)
4. List all free extensions. (Access to admin)
5. Get the list of agent-extension association. (Access to admin)

It adds necessary functions in the omnichannel-voip service and adds corresponding types.

In the database, it adds a new field |extension| to existing meteor |users| document.

* Update server/services/omnichannel-voip/service.ts

Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>

* Clickup task : https://app.clickup.com/t/7qee1v
Description:
Fixing code review comments.

* Clickup task : https://app.clickup.com/t/7qee1v
Description : Review comments.
Removed the Voip code from traditional model and put it in raw model for user.
Changed the REST layer accordingly.

* Update server/services/omnichannel-voip/service.ts

Fixing review comment

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Update app/api/server/v1/voip/omnichannel.ts

Makes sense. Fixed.

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Clickup task : https://app.clickup.com/t/7qee1v
 Description : Review comments.

* Clickup task : https://app.clickup.com/t/7qee1v
Description : Review comments.

* Clickup task : https://app.clickup.com/t/7qee1v
Description : This commit adds a new api omnichannel/extension?type=available&username=<username>
This API returns the all the available extensions for a given username. Which mean, if the user has
extesion allocated, available list will also contain this associated extension.

* Clickup task : https://app.clickup.com/t/7qee1v
Description: Fixing review comments.

* Clickup task : https://app.clickup.com/t/7qee1v
Description: Fixing review comments.

Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>
Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* fix eslint issues

* fix lgtm alert

* Fix permission style

* create migration to add voip permissions

* fix pipeline

* [NEW] Livechat voip/contexts providers and components(#23801)

* Wip

* WIP on Call Component

* Add disabled state component

* Paused and timer components

* Lint

* Toolbox Button Colors

* Tooltips

* Use Sidebar components

* WIP Refactor

* small refactor

* Refactor voip Layout to use Fuselage Components

* Fix lint/ts

* Bump

* Fix wrong section name

* Lint

* voip endpoint ts

* Created Call Context

* WIO

* fix visual

* Fix after merge develop

* Create an Error handler

* Fix TS

* Fix martin

* Fix stringtoice function

* Fix wrong type

* Reject call button

* Update fuselage

* Use Portal for AudioTag and small improvements

* fix lint

* Lint

* Improvements to audio element and media ref usage

* Code cleanup

Revamp file structure, remove some loggers, remove some test files, and fix linting

* Fix TS and remove more loggers

* Lint

* Fix reviews, remove test code and comments

* wip

* Lint & Prettier

* Lint

* fix error

Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>

* [NEW] Implementation of a call feature Hold-unhold (#24140)

* Clickup task : https://app.clickup.com/t/7qdt7t
Description :
This commit handles hold-unhold call feature. When the call gets hold, the far-end should hear
music on hold. The agent should not hear the agent.
Hold-unhold feature results in the renegotiation of call.
The media direction is changed to sendonly and recvonly.

Following files have been changed
1. client/lib/voip/VoIPUser.ts : This file implements core logic of hold-unhold. Call hold is possible only when the call is currently going on or the answer is sent. Function handleHoldUnhold implements
reinvite, which is sent to the far end for handling the hold-unhold.
2. client/lib/voip/Helper.ts : This is a new file which implements enabling/disabling the media streams when the call is put on hold.
3. definition/voip/CallStates.ts : ON_HOLD state is added to existing call states.
4. definition/voip/VoIpCallerInfo.ts : VoIpCallerInfo type is extended for the ON_HOLD state.
5. definition/voip/VoipEvents.ts : hold, holderror, unhold, unholderror events have been added to communicate the call hold state.
6. packages/rocketchat-i18n/i18n/en.i18n.json : Held_call translation has been added. This will be used when the call is put on hold.
7. client/providers/CallProvider/CallProvider.tsx : pause resume actions have been associated with the correct functions from voipClient.

* adjust some types

* small fix VoiceController

* fix typeguards

* Clickup task : https://app.clickup.com/t/7qdt7t
Description:
Removing unused translation for hold-unhold

Co-authored-by: Tiago Evangelista Pinto <tiago.evangelista@rocket.chat>

* [FIX] Return correct registration state in connector.extension.list API (#24129)

* [FIX] Workaround for use of the default settings collection (#24095)

* [FIX] Cleaning up some hard coding in the Voip code. (#24247)

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Chore: Code quality changes to extension management

* [NEW] VoIP buttons to mute and hold the call (#24421)

* mute and hold

* fix stories

* popover => tooltips

Co-authored-by: Martin <martin.schoeler@rocket.chat>

* [NEW] Composer not available on phone calls component (#23475)

* [NEW] Create voip room on call received (#23897)

Co-authored-by: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com>
Co-authored-by: amolghode <amol.ghode@gmail.com>
Co-authored-by: amolghode1981 <86001342+amolghode1981@users.noreply.github.com>
Co-authored-by: Pierre Lehnen <pierre.lehnen@rocket.chat>

* [NEW] Livechat Voip RoomType (#24484)

* [NEW] VoIP room chat header (#24510)

* voip room

* visitor logic

* fix noo js

* end

* fix build errors

* fix

* room start header

* \n

* [NEW] Livechat voip/queue events (#24180)

* Draft workspace for call server monitor.

* Draft workspace for call server monitor. Using Event notification mechanism to notify the clients.
Implements strategy for notifying source queue (While the agent is riniging) and Calls in queue

* Fix issue with duplicated notifications on client

* Started adding support for 3 more events, QueueMemeberAdded, QueueMemberRemoved and QueueCallerAbandon

* Fixing errors.

* Use user extension to fetch queue details

* Clickup Task : https://app.clickup.com/t/21fekdx
Description: This PR implements continuous monitor in the asterisk connector.
Asterisk continuously generates a stream of events on various activities. Management
server user defined in asterisk's manager.conf determines the events to be sent on this user.

This PR's main focus is to find out calls in the queue and source queue of a call.
To do this, it monitors following events

queuecallerjoin (For finding out the source queue of a call)
agentcalled (for calls in the queue)
agentconnect (for calls in the queue)
queuememberadded (for calls in the queue)
queuememberremoved (for calls in the queue)
queuecallerabandon (for calls in the queue)

The client is going to create an aggregator which will be responsible for the events which cause change in |calls waiting in the queue|
The aggregator will always have the latest 'calls waiting in the queue'

* Clickup Task : https://app.clickup.com/t/21fekdx
Description : Fixing build issues.

* Clickup Task : https://app.clickup.com/t/21fekdx
Description : Adding agentconnect event. Refactored some duplicated code.

* QoL changes

* Remove endpoints for call server management

* remove import to old server mgmnt endpoints

* Clickup Task : https://app.clickup.com/t/21fekdx
Description : Handling review comments.
1. Added database object as compulsory field to Command's constructor.
2. Resetting handlers for new events.

* https://app.clickup.com/t/21fekdx
Description:
1. Added queue wait time to the agentconnect event.
2. Fixed the socket disconnection issue happening after 30 seconds. Asterisk closes the socket after 30 seconds
if it does not receive any message. Added some code in client/lib/voip/VoIPUser.ts which will send
SIP OPTIONS message. After sending this message, there is no disconnection.

* Listen to asterisk events and store them on DB for validation

* Fix type error on storepbxevent

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [IMPROVE] Add pagination and extra info to extensions endpoint (#24473)

Co-authored-by: amolghode <amol.ghode@gmail.com>

* fix available extension fetching when user doesnt have extension associated

* [NEW] Voip rooms endpoint (#24527)

* Add OTR sysmessages to Imessage enum

* Fix issue with date params affecting end results

* [NEW] Detect the abrupt disconnection of agent's client while in call to close the room (#24563)

* Clickup Task : https://app.clickup.com/t/22c968v
Description :
When the agent disconnects abruptly, the room should be closed. But because the agent is sitting on browser, agent will not be
able to do these things gracefully. The reason is that the browser may just crash or the agent just forces the tab close or refresh (Even though we prevent
it on client side)

So the solution is to detect this scenario on server and close the associated room. When such forceful tab close happens, Asterisk sends AMI event
'ContactStatus' where the field contactstatus = 'Removed'

We will handle the ContactStatus event and if the contactstatus is removed, we will gracefully close the room on server.

Changes
1. Added new Event definition for ContactStatus event.
2. Added event handling function for this event in ContinuousMonitor.ts

* Handle agent unexpected disconnection events

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Fix server crashing on sending events before room init

* Listen and broadcast hangup event (#24571)

* [NEW] Voip Wrap Up Modal (#24566)

* [NEW] Connectivity check between RC and asterisk (#24408)

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] Voip settings (#24535)

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>
Co-authored-by: amolghode <amol.ghode@gmail.com>
Co-authored-by: amolghode1981 <86001342+amolghode1981@users.noreply.github.com>
Co-authored-by: Tiago Evangelista Pinto <tiago.evangelista@rocket.chat>

* [NEW] voip contact center (#24561)

* Prep work: Type files needed for the feature

* Auto stash before merge of "new/livechat-voip-contact-center" and "origin/new/livechat-voip"

* Wip

* Wrapping up

* No console logs

* Fix ts

* fix types

* remove unnecessary commented code

* Fix conflicts with v.phone prop

* Fix ts signature

* Update client/views/omnichannel/directory/calls/Call.tsx

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* Fix issues with rooms endpoints

* Fix reviews

* Fix storing and calculation of some timers

* Its late toniight

Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [NEW] SidebarFooter Calls in Queue counter (#24543)

* calls in queue

* Fix lint && ts

Co-authored-by: Martin Schoeler <martin.schoeler@rocket.chat>

* [NEW] Introduce CallInfo component in contextual-bar for VoIP (#24257)

* wip

* voip room

* visitor logic

* fix noo js

* end

* fix build errors

* fix en

* fix

* wip

* events

* Update app/livechat/server/api/v1/visitor.ts

Co-authored-by: Martin Schoeler <martin.schoeler@rocket.chat>

* fixes

* add moment

* fix duplicate type

* almost there

* fix

* Fix events relation to call

* Create VoipRoomType server file

* Remove logs and stale code

Co-authored-by: Martin Schoeler <martin.schoeler@rocket.chat>
Co-authored-by: Kevin Aleman <kevin.aleman@rocket.chat>

* [FIX] Conflicts between develop and new/livechat-voip (#24582)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dougfabris <devfabris@gmail.com>
Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
Co-authored-by: Robot LingoHub <robot@lingohub.com>
Co-authored-by: Diego Sampaio <chinello@gmail.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Douglas Gubert <douglas.gubert@gmail.com>
Co-authored-by: lingohub[bot] <69908207+lingohub[bot]@users.noreply.github.com>
Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>

* well

* Fix todos, create migration, remove stale code and comments

Co-authored-by: amolghode1981 <86001342+amolghode1981@users.noreply.github.com>
Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>
Co-authored-by: Tiago Evangelista Pinto <tiago.evangelista@rocket.chat>
Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>
Co-authored-by: Martin Schoeler <martin.schoeler@rocket.chat>
Co-authored-by: amolghode <amol.ghode@gmail.com>
Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
Co-authored-by: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com>
Co-authored-by: Pierre Lehnen <pierre.lehnen@rocket.chat>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dougfabris <devfabris@gmail.com>
Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
Co-authored-by: Robot LingoHub <robot@lingohub.com>
Co-authored-by: Diego Sampaio <chinello@gmail.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Douglas Gubert <douglas.gubert@gmail.com>
Co-authored-by: lingohub[bot] <69908207+lingohub[bot]@users.noreply.github.com>
pull/24586/head
Kevin Aleman 4 years ago committed by GitHub
parent 0ed26ade9c
commit 550bfb0057
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      app/api/server/api.d.ts
  2. 4
      app/api/server/index.js
  3. 35
      app/api/server/v1/voip/events.ts
  4. 106
      app/api/server/v1/voip/extensions.ts
  5. 5
      app/api/server/v1/voip/index.ts
  6. 3
      app/api/server/v1/voip/logger.ts
  7. 223
      app/api/server/v1/voip/omnichannel.ts
  8. 35
      app/api/server/v1/voip/queues.ts
  9. 244
      app/api/server/v1/voip/rooms.ts
  10. 59
      app/api/server/v1/voip/server-connection.ts
  11. 14
      app/authorization/server/functions/upsertPermissions.ts
  12. 1
      app/lib/server/functions/getFullUserData.js
  13. 92
      app/lib/server/startup/settings.ts
  14. 1
      app/livechat/client/index.js
  15. 13
      app/livechat/client/tabBar.ts
  16. 96
      app/livechat/client/voip.ts
  17. 10
      app/livechat/server/api/v1/visitor.ts
  18. 5
      app/livechat/server/lib/Livechat.js
  19. 1
      app/models/server/models/Users.js
  20. 58
      app/models/server/raw/PbxEvents.ts
  21. 58
      app/models/server/raw/Users.js
  22. 171
      app/models/server/raw/VoipRooms.ts
  23. 5
      app/models/server/raw/index.ts
  24. 10
      app/otr/lib/constants.ts
  25. 4
      app/ui-sidenav/client/sideNav.html
  26. 2
      app/ui-utils/client/index.js
  27. 19
      app/ui-utils/lib/MessageTypes.js
  28. 42
      app/ui-utils/lib/MessageTypes.ts
  29. 6
      app/utils/server/functions/getDefaultUserFields.ts
  30. 5
      client/components/RoomForeword.js
  31. 18
      client/components/voip/composer/Content.tsx
  32. 18
      client/components/voip/composer/NotAvailable.stories.tsx
  33. 10
      client/components/voip/composer/NotAvailable.tsx
  34. 6
      client/components/voip/composer/index.ts
  35. 14
      client/components/voip/composer/template.tsx
  36. 65
      client/components/voip/modal/WrapUpCallModal.tsx
  37. 30
      client/components/voip/room/VoipRoomForeword.tsx
  38. 164
      client/contexts/CallContext.ts
  39. 1
      client/lib/rooms/roomTypes/index.ts
  40. 37
      client/lib/rooms/roomTypes/voip.ts
  41. 27
      client/lib/voip/Helper.ts
  42. 24
      client/lib/voip/PhoneNumberParser.ts
  43. 26
      client/lib/voip/SimpleVoipUser.ts
  44. 110
      client/lib/voip/Stream.ts
  45. 618
      client/lib/voip/VoIPUser.ts
  46. 176
      client/providers/CallProvider/CallProvider.tsx
  47. 5
      client/providers/CallProvider/definitions/IceServer.ts
  48. 66
      client/providers/CallProvider/hooks/useVoipClient.ts
  49. 16
      client/providers/CallProvider/hooks/useWebRtcServers.ts
  50. 1
      client/providers/CallProvider/index.ts
  51. 52
      client/providers/CallProvider/lib/parseStringToIceServers.spec.ts
  52. 21
      client/providers/CallProvider/lib/parseStringToIceServers.ts
  53. 13
      client/providers/MeteorProvider.tsx
  54. 18
      client/providers/OmnichannelProvider.tsx
  55. 4
      client/sidebar/RoomList/Row.js
  56. 31
      client/sidebar/footer/SidebarFooter.tsx
  57. 58
      client/sidebar/footer/voip/VoipFooter.stories.tsx
  58. 142
      client/sidebar/footer/voip/VoipFooter.tsx
  59. 89
      client/sidebar/footer/voip/index.tsx
  60. 30
      client/sidebar/hooks/useAvatarTemplate.js
  61. 39
      client/sidebar/hooks/useAvatarTemplate.tsx
  62. 40
      client/sidebar/sections/OmnichannelSection.tsx
  63. 20
      client/sidebar/sections/components/OmnichannelCallToggle.tsx
  64. 9
      client/sidebar/sections/components/OmnichannelCallToggleError.tsx
  65. 9
      client/sidebar/sections/components/OmnichannelCallToggleLoading.tsx
  66. 71
      client/sidebar/sections/components/OmnichannelCallToggleReady.tsx
  67. 4
      client/templates.ts
  68. 154
      client/views/admin/settings/GroupPage.js
  69. 196
      client/views/admin/settings/GroupPage.tsx
  70. 5
      client/views/admin/settings/GroupSelector.tsx
  71. 54
      client/views/admin/settings/groups/VoipGroupPage.tsx
  72. 76
      client/views/admin/settings/groups/voip/AssignAgentModal.tsx
  73. 58
      client/views/admin/settings/groups/voip/RemoveAgentButton.tsx
  74. 103
      client/views/admin/settings/groups/voip/VoipExtensionsPage.tsx
  75. 68
      client/views/omnichannel/agents/AgentEdit.tsx
  76. 62
      client/views/omnichannel/directory/CallsContextualBar.tsx
  77. 25
      client/views/omnichannel/directory/ChatsContextualBar.tsx
  78. 12
      client/views/omnichannel/directory/ContextualBar.tsx
  79. 15
      client/views/omnichannel/directory/OmnichannelDirectoryPage.tsx
  80. 19
      client/views/omnichannel/directory/calls/Call.tsx
  81. 19
      client/views/omnichannel/directory/calls/CallTab.tsx
  82. 164
      client/views/omnichannel/directory/calls/CallTable.tsx
  83. 14
      client/views/omnichannel/directory/calls/contextualBar/InfoField.tsx
  84. 65
      client/views/omnichannel/directory/calls/contextualBar/VoipInfo.tsx
  85. 8
      client/views/omnichannel/directory/chats/ChatTab.tsx
  86. 51
      client/views/omnichannel/directory/chats/ChatTable.tsx
  87. 2
      client/views/omnichannel/directory/chats/contextualBar/ChatInfoDirectory.js
  88. 14
      client/views/omnichannel/directory/chats/contextualBar/VoipInfo/InfoField.tsx
  89. 61
      client/views/omnichannel/directory/chats/contextualBar/VoipInfo/VoipInfo.tsx
  90. 20
      client/views/omnichannel/directory/chats/contextualBar/VoipInfo/index.tsx
  91. 5
      client/views/room/Header/Header.js
  92. 44
      client/views/room/Header/Omnichannel/VoipRoomHeader.tsx
  93. 4
      client/views/room/Header/RoomHeader.tsx
  94. 7
      client/views/room/Header/ToolBox/ToolBox.tsx
  95. 4
      client/views/room/Header/icons/Favorite.js
  96. 19
      client/views/room/contexts/RoomContext.ts
  97. 1
      client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts
  98. 2
      client/views/room/lib/Toolbox/defaultActions.ts
  99. 2
      client/views/room/lib/Toolbox/index.tsx
  100. 1
      client/views/room/providers/VirtualAction.tsx
  101. Some files were not shown because too many files have changed in this diff Show More

@ -30,11 +30,11 @@ type UnauthorizedResult<T> = {
};
};
type NotFoundResult<T> = {
statusCode: 403;
type NotFoundResult = {
statusCode: 404;
body: {
success: false;
error: T | 'Resource not found';
error: string;
};
};
@ -96,7 +96,8 @@ type ActionThis<TMethod extends Method, TPathPattern extends PathPattern, TOptio
export type ResultFor<TMethod extends Method, TPathPattern extends PathPattern> =
| SuccessResult<OperationResult<TMethod, TPathPattern>>
| FailureResult<unknown, unknown, unknown, unknown>
| UnauthorizedResult<unknown>;
| UnauthorizedResult<unknown>
| NotFoundResult;
type Action<TMethod extends Method, TPathPattern extends PathPattern, TOptions> =
| ((this: ActionThis<TMethod, TPathPattern, TOptions>) => Promise<ResultFor<TMethod, TPathPattern>>)
@ -164,9 +165,9 @@ declare class APIClass<TBasePath extends string = '/'> {
failure(): FailureResult<void>;
unauthorized<T>(msg?: T): UnauthorizedResult<T>;
notFound(msg?: string): NotFoundResult;
notFound<T>(msg?: T): NotFoundResult<T>;
unauthorized<T>(msg?: T): UnauthorizedResult<T>;
defaultFieldsToExclude: {
joinCode: 0;

@ -44,5 +44,9 @@ import './v1/instances';
import './v1/banners';
import './v1/email-inbox';
import './v1/teams';
import './v1/voip/extensions';
import './v1/voip/queues';
import './v1/voip/omnichannel';
import './v1/voip';
export { API, APIClass, defaultRateLimiterOptions } from './api';

@ -0,0 +1,35 @@
import { Match, check } from 'meteor/check';
import { API } from '../../api';
import { LivechatVoip } from '../../../../../server/sdk';
import { canAccessRoom } from '../../../../authorization/server';
import { VoipRoom } from '../../../../models/server/raw';
import { VoipClientEvents } from '../../../../../definition/voip/VoipClientEvents';
API.v1.addRoute(
'voip/events',
{ authRequired: true },
{
async post() {
check(this.requestParams(), {
event: Match.Where((v: string) => {
return Object.values<string>(VoipClientEvents).includes(v);
}),
rid: String,
comment: Match.Maybe(String),
});
const { rid, event, comment } = this.requestParams();
const room = await VoipRoom.findOneVoipRoomById(rid);
if (!room) {
return API.v1.notFound();
}
if (!canAccessRoom(room, this.user)) {
return API.v1.unauthorized();
}
return API.v1.success(await LivechatVoip.handleEvent(event, room, this.user, comment));
},
},
);

@ -0,0 +1,106 @@
import { Match, check } from 'meteor/check';
import { API } from '../../api';
import { hasPermission } from '../../../../authorization/server/index';
import { Users } from '../../../../models/server/raw/index';
import { Voip } from '../../../../../server/sdk';
import { IVoipExtensionBase } from '../../../../../definition/IVoipExtension';
// Get the connector version and type
API.v1.addRoute(
'connector.getVersion',
{ authRequired: true },
{
async get() {
const version = await Voip.getConnectorVersion();
return API.v1.success(version);
},
},
);
// Get the extensions available on the call server
API.v1.addRoute(
'connector.extension.list',
{ authRequired: true },
{
async get() {
const list = await Voip.getExtensionList();
const result = list.result as IVoipExtensionBase[];
return API.v1.success({ extensions: result });
},
},
);
/* Get the details of a single extension.
* Note : This API will either be called by the endpoint
* or will be consumed internally.
*/
API.v1.addRoute(
'connector.extension.getDetails',
{ authRequired: true },
{
async get() {
check(
this.requestParams(),
Match.ObjectIncluding({
extension: String,
}),
);
const endpointDetails = await Voip.getExtensionDetails(this.requestParams());
return API.v1.success({ ...endpointDetails.result });
},
},
);
/* Get the details for registration extension.
*/
API.v1.addRoute(
'connector.extension.getRegistrationInfoByExtension',
{ authRequired: true },
{
async get() {
check(
this.requestParams(),
Match.ObjectIncluding({
extension: String,
}),
);
const endpointDetails = await Voip.getRegistrationInfo(this.requestParams());
return API.v1.success({ ...endpointDetails.result });
},
},
);
API.v1.addRoute(
'connector.extension.getRegistrationInfoByUserId',
{ authRequired: true },
{
async get() {
check(
this.requestParams(),
Match.ObjectIncluding({
id: String,
}),
);
if (!hasPermission(this.userId, 'view-agent-extension-association')) {
return API.v1.unauthorized();
}
const { id } = this.requestParams();
const { extension } =
(await Users.getVoipExtensionByUserId(id, {
projection: {
_id: 1,
username: 1,
extension: 1,
},
})) || {};
if (!extension) {
return API.v1.notFound('Extension not found');
}
const endpointDetails = await Voip.getRegistrationInfo({ extension });
return API.v1.success({ ...endpointDetails.result });
},
},
);

@ -0,0 +1,5 @@
import './extensions';
import './queues';
import './events';
import './rooms';
import './server-connection';

@ -0,0 +1,3 @@
import { Logger } from '../../../../logger/server';
export const logger = new Logger('VoIP');

@ -0,0 +1,223 @@
import { Match, check } from 'meteor/check';
import { API } from '../../api';
import { Users } from '../../../../models/server/raw/index';
import { hasPermission } from '../../../../authorization/server/index';
import { LivechatVoip } from '../../../../../server/sdk';
import { logger } from './logger';
import { IUser } from '../../../../../definition/IUser';
function paginate<T>(array: T[], count = 10, offset = 0): T[] {
return array.slice(offset, offset + count);
}
const isUserAndExtensionParams = (p: any): p is { userId: string; extension: string } => p.userId && p.extension;
const isUserIdndTypeParams = (p: any): p is { userId: string; type: 'free' | 'allocated' | 'available' } => p.userId && p.type;
API.v1.addRoute(
'omnichannel/agent/extension',
{ authRequired: true },
{
// Get the extensions associated with the agent passed as request params.
async get() {
if (!hasPermission(this.userId, 'view-agent-extension-association')) {
return API.v1.unauthorized();
}
check(
this.requestParams(),
Match.ObjectIncluding({
username: String,
}),
);
const { username } = this.requestParams();
const user = await Users.findOneByAgentUsername(username, {
projection: { _id: 1 },
});
if (!user) {
return API.v1.notFound('User not found');
}
const extension = await Users.getVoipExtensionByUserId(user._id, {
projection: {
_id: 1,
username: 1,
extension: 1,
},
});
if (!extension) {
return API.v1.notFound('Extension not found');
}
return API.v1.success({ extension });
},
// Create agent-extension association.
async post() {
if (!hasPermission(this.userId, 'manage-agent-extension-association')) {
return API.v1.unauthorized();
}
check(
this.bodyParams,
Match.OneOf(
Match.ObjectIncluding({
username: String,
extension: String,
}),
Match.ObjectIncluding({
userId: String,
extension: String,
}),
),
);
const { extension } = this.bodyParams;
let user: IUser | null = null;
if (!isUserAndExtensionParams(this.bodyParams)) {
user = await Users.findOneByAgentUsername(this.bodyParams.username, {
projection: {
_id: 1,
username: 1,
},
});
} else {
user = await Users.findOneAgentById(this.bodyParams.userId, {
projection: {
_id: 1,
username: 1,
},
});
}
if (!user) {
return API.v1.notFound();
}
try {
logger.debug(`Setting extension ${extension} for agent with id ${user._id}`);
await Users.setExtension(user._id, extension);
return API.v1.success();
} catch (e) {
logger.error({ msg: 'Extension already in use' });
return API.v1.failure(`extension already in use ${extension}`);
}
},
async delete() {
if (!hasPermission(this.userId, 'manage-agent-extension-association')) {
return API.v1.unauthorized();
}
check(
this.requestParams(),
Match.ObjectIncluding({
username: String,
}),
);
const { username } = this.requestParams();
const user = await Users.findOneByAgentUsername(username, {
projection: {
_id: 1,
username: 1,
extension: 1,
},
});
if (!user) {
return API.v1.notFound();
}
if (!user.extension) {
logger.debug(`User ${user._id} is not associated with any extension. Skipping`);
return API.v1.success();
}
logger.debug(`Removing extension association for user ${user._id} (extension was ${user.extension})`);
await Users.unsetExtension(user._id);
return API.v1.success();
},
},
);
// Get free extensions
API.v1.addRoute(
'omnichannel/extension',
{ authRequired: true, permissionsRequired: ['manage-agent-extension-association'] },
{
async get() {
check(
this.queryParams,
Match.OneOf(
Match.ObjectIncluding({
type: Match.OneOf('free', 'allocated', 'available'),
userId: String,
}),
Match.ObjectIncluding({
type: Match.OneOf('free', 'allocated', 'available'),
username: String,
}),
),
);
const { type } = this.queryParams;
switch ((type as string).toLowerCase()) {
case 'free': {
const extensions = await LivechatVoip.getFreeExtensions();
if (!extensions) {
return API.v1.failure('Error in finding free extensons');
}
return API.v1.success({ extensions });
}
case 'allocated': {
const extensions = await LivechatVoip.getExtensionAllocationDetails();
if (!extensions) {
return API.v1.failure('Error in allocated extensions');
}
return API.v1.success({ extensions });
}
case 'available': {
let user: IUser | null = null;
if (!isUserIdndTypeParams(this.queryParams)) {
user = await Users.findOneByAgentUsername(this.queryParams.username, {
projection: { _id: 1 },
});
} else {
user = await Users.findOneAgentById(this.queryParams.userId, {
projection: { _id: 1 },
});
}
if (!user) {
return API.v1.notFound('User not found');
}
const extension = await Users.getVoipExtensionByUserId(user._id, {
projection: {
_id: 1,
username: 1,
extension: 1,
},
});
const freeExt = await LivechatVoip.getFreeExtensions();
const extensions = extension ? [extension.extension, ...freeExt] : freeExt;
return API.v1.success({ extensions });
}
default:
return API.v1.notFound(`${type} not found `);
}
},
},
);
API.v1.addRoute(
'omnichannel/extensions',
{ authRequired: true, permissionsRequired: ['manage-agent-extension-association'] },
{
async get() {
const { offset, count } = this.getPaginationItems();
const extensions = await LivechatVoip.getExtensionListWithAgentData();
// paginating in memory as Asterisk doesn't provide pagination for commands
return API.v1.success({
extensions: paginate(extensions, count, offset),
offset,
count,
total: extensions.length,
});
},
},
);

@ -0,0 +1,35 @@
import { Match, check } from 'meteor/check';
import { API } from '../../api';
import { Voip } from '../../../../../server/sdk';
import { IVoipConnectorResult } from '../../../../../definition/IVoipConnectorResult';
import { IQueueSummary } from '../../../../../definition/ACDQueues';
import { IQueueMembershipDetails } from '../../../../../definition/IVoipExtension';
API.v1.addRoute(
'voip/queues.getSummary',
{ authRequired: true },
{
async get() {
const queueSummary = await Voip.getQueueSummary();
return API.v1.success({ summary: queueSummary.result as IQueueSummary[] });
},
},
);
API.v1.addRoute(
'voip/queues.getQueuedCallsForThisExtension',
{ authRequired: true },
{
async get() {
check(
this.requestParams(),
Match.ObjectIncluding({
extension: String,
}),
);
const membershipDetails: IVoipConnectorResult = await Voip.getQueuedCallsForThisExtension(this.requestParams());
return API.v1.success(membershipDetails.result as IQueueMembershipDetails);
},
},
);

@ -0,0 +1,244 @@
import { Match, check } from 'meteor/check';
import { Random } from 'meteor/random';
import { API } from '../../api';
import { VoipRoom, LivechatVisitors, Users } from '../../../../models/server/raw';
import { LivechatVoip } from '../../../../../server/sdk';
import { ILivechatAgent } from '../../../../../definition/ILivechatAgent';
import { hasPermission } from '../../../../authorization/server';
import { typedJsonParse } from '../../../../../lib/typedJSONParse';
type DateParam = { start?: string; end?: string };
const parseDateParams = (date?: string): DateParam => {
return date && typeof date === 'string' ? typedJsonParse<DateParam>(date) : {};
};
const validateDateParams = (property: string, date: DateParam = {}): DateParam => {
if (date?.start && isNaN(Date.parse(date.start))) {
throw new Error(`The "${property}.start" query parameter must be a valid date.`);
}
if (date?.end && isNaN(Date.parse(date.end))) {
throw new Error(`The "${property}.end" query parameter must be a valid date.`);
}
return date;
};
const parseAndValidate = (property: string, date?: string): DateParam => {
return validateDateParams(property, parseDateParams(date));
};
/**
* @openapi
* /voip/server/api/v1/voip/room
* get:
* description: Creates a new room if rid is not passed, else gets an existing room
* based on rid and token . This configures the rate limit. An average call volume in a contact
* center is 600 calls a day
* considering 8 hour shift. Which comes to 1.25 calls per minute.
* we will keep the safe limit which is 5 calls a minute.
* security:
* parameters:
* - name: token
* in: query
* description: The visitor token
* required: true
* schema:
* type: string
* example: ByehQjC44FwMeiLbX
* - name: rid
* in: query
* description: The room id
* required: false
* schema:
* type: string
* example: ByehQjC44FwMeiLbX
* - name: agentId
* in: query
* description: Agent Id
* required: false
* schema:
* type: string
* example: ByehQjC44FwMeiLbX
* responses:
* 200:
* description: Room object and flag indicating whether a new room is created.
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiSuccessV1'
* - type: object
* properties:
* room:
* type: object
* items:
* $ref: '#/components/schemas/IRoom'
* newRoom:
* type: boolean
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.addRoute(
'voip/room',
{ authRequired: false, rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 } },
{
async get() {
const defaultCheckParams = {
token: String,
agentId: Match.Maybe(String),
rid: Match.Maybe(String),
};
check(this.queryParams, defaultCheckParams);
const { token, rid, agentId } = this.queryParams;
const guest = await LivechatVisitors.getVisitorByToken(token, {});
if (!guest) {
return API.v1.failure('invalid-token');
}
if (!rid) {
const room = await VoipRoom.findOneOpenByVisitorToken(token, { projection: API.v1.defaultFieldsToExclude });
if (room) {
return API.v1.success({ room, newRoom: false });
}
const agentObj: ILivechatAgent = await Users.findOneAgentById(agentId, {
projection: { username: 1 },
});
if (!agentObj?.username) {
return API.v1.failure('agent-not-found');
}
const { username, _id } = agentObj;
const agent = { agentId: _id, username };
const rid = Random.id();
return API.v1.success(await LivechatVoip.getNewRoom(guest, agent, rid, { projection: API.v1.defaultFieldsToExclude }));
}
const room = await VoipRoom.findOneByIdAndVisitorToken(rid, token, { projection: API.v1.defaultFieldsToExclude });
if (!room) {
return API.v1.failure('invalid-room');
}
return API.v1.success({ room, newRoom: false });
},
},
);
API.v1.addRoute(
'voip/rooms',
{ authRequired: true },
{
async get() {
const { offset, count } = this.getPaginationItems();
const { sort, fields } = this.parseJsonQuery();
const { agents, open, tags, queue, visitorId } = this.requestParams();
const { createdAt: createdAtParam, closedAt: closedAtParam } = this.requestParams();
check(agents, Match.Maybe([String]));
check(open, Match.Maybe(String));
check(tags, Match.Maybe([String]));
check(queue, Match.Maybe(String));
check(visitorId, Match.Maybe(String));
// Reusing same L room permissions for simplicity
const hasAdminAccess = hasPermission(this.userId, 'view-livechat-rooms');
const hasAgentAccess = hasPermission(this.userId, 'view-l-room') && agents?.includes(this.userId) && agents?.length === 1;
if (!hasAdminAccess && !hasAgentAccess) {
return API.v1.unauthorized();
}
const createdAt = parseAndValidate('createdAt', createdAtParam);
const closedAt = parseAndValidate('closedAt', closedAtParam);
return API.v1.success(
await LivechatVoip.findVoipRooms({
agents,
open: open === 'true',
tags,
queue,
visitorId,
createdAt,
closedAt,
options: { sort, offset, count, fields },
}),
);
},
},
);
/**
* @openapi
* /voip/server/api/v1/voip/room.close
* post:
* description: Closes an open room
* based on rid and token. Setting rate limit for this too
* Because room creation happens 5/minute, rate limit for this api
* is also set to 5/minute.
* security:
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* rid:
* type: string
* token:
* type: string
* responses:
* 200:
* description: rid of closed room and a comment for closing room
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiSuccessV1'
* - type: object
* properties:
* rid:
* type: string
* comment:
* type: string
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.addRoute(
'voip/room.close',
{ authRequired: true },
{
async post() {
check(this.bodyParams, {
rid: String,
token: String,
comment: Match.Maybe(String),
tags: Match.Maybe([String]),
});
const { rid, token, comment, tags } = this.bodyParams;
const visitor = await LivechatVisitors.getVisitorByToken(token, {});
if (!visitor) {
return API.v1.failure('invalid-token');
}
const room = await LivechatVoip.findRoom(token, rid);
if (!room) {
return API.v1.failure('invalid-room');
}
if (!room.open) {
return API.v1.failure('room-closed');
}
const closeResult = await LivechatVoip.closeRoom(visitor, room, this.user, comment, tags);
if (!closeResult) {
return API.v1.failure();
}
return API.v1.success({ rid });
},
},
);

@ -0,0 +1,59 @@
import { Match, check } from 'meteor/check';
import { API } from '../../api';
import { Voip } from '../../../../../server/sdk';
API.v1.addRoute(
'voip/managementServer/checkConnection',
{ authRequired: true, permissionsRequired: ['manage-voip-contact-center-settings'] },
{
async get() {
check(
this.requestParams(),
Match.ObjectIncluding({
host: String,
port: String,
username: String,
password: String,
}),
);
const { host, port, username, password } = this.requestParams();
return API.v1.success(await Voip.checkManagementConnection(host, port, username, password));
},
},
);
API.v1.addRoute(
'voip/callServer/checkConnection',
{ authRequired: true, permissionsRequired: ['manage-voip-contact-center-settings'] },
{
async get() {
check(
this.requestParams(),
Match.ObjectIncluding({
websocketUrl: Match.Maybe(String),
host: Match.Maybe(String),
port: Match.Maybe(String),
path: Match.Maybe(String),
}),
);
const { websocketUrl, host, port, path } = this.requestParams();
if (!websocketUrl && !(host && port && path)) {
return API.v1.failure('Incorrect / Insufficient Parameters');
}
let socketUrl = websocketUrl as string;
if (!socketUrl) {
// We will assume that it is always secure.
// This is because you can not have webRTC working with non-secure server.
// It works on non-secure server if it is tested on localhost.
if (parseInt(port as string) !== 443) {
socketUrl = `wss://${host}:${port}/${(path as string).replace('/', '')}`;
} else {
socketUrl = `wss://${host}/${(path as string).replace('/', '')}`;
}
}
return API.v1.success(await Voip.checkCallserverConnection(socketUrl));
},
},
);

@ -188,6 +188,18 @@ export const upsertPermissions = async (): Promise<void> => {
{ _id: 'view-all-team-channels', roles: ['admin', 'owner'] },
{ _id: 'view-all-teams', roles: ['admin'] },
{ _id: 'remove-closed-livechat-room', roles: ['livechat-manager', 'admin'] },
{ _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] },
// VOIP Permissions
// allows to manage voip calls configuration
{ _id: 'manage-voip-call-settings', roles: ['livechat-manager', 'admin'] },
{ _id: 'manage-voip-contact-center-settings', roles: ['livechat-manager', 'admin'] },
// allows agent-extension association.
{ _id: 'manage-agent-extension-association', roles: ['admin'] },
{ _id: 'view-agent-extension-association', roles: ['livechat-manager', 'admin', 'livechat-agent'] },
// allows to receive a voip call
{ _id: 'inbound-voip-calls', roles: ['livechat-agent'] },
{ _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] },
{ _id: 'manage-apps', roles: ['admin'] },
{ _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] },
@ -275,7 +287,7 @@ export const upsertPermissions = async (): Promise<void> => {
try {
await Permissions.update({ _id: permissionId }, { $set: permission }, { upsert: true });
} catch (e) {
if (!e.message.includes('E11000')) {
if (!(e as Error).message.includes('E11000')) {
// E11000 refers to a MongoDB error that can occur when using unique indexes for upserts
// https://docs.mongodb.com/manual/reference/method/db.collection.update/#use-unique-indexes
await Permissions.update({ _id: permissionId }, { $set: permission }, { upsert: true });

@ -30,6 +30,7 @@ const fullFields = {
requirePasswordChange: 1,
requirePasswordChangeReason: 1,
roles: 1,
extension: 1,
};
let publicCustomFields = {};

@ -3138,3 +3138,95 @@ settingsRegistry.addGroup('Troubleshoot', function () {
alert: 'Troubleshoot_Disable_Workspace_Sync_Alert',
});
});
settingsRegistry.addGroup('VoIP', function () {
this.with({ tab: 'Server_Configuration' }, function () {
this.add('VoIP_Enabled', false, {
type: 'boolean',
public: true,
alert: 'Experimental_Feature_Alert',
});
this.section('Server_Configuration', function () {
this.add('VoIP_Server_Host', 'asterisk.dev.com', {
type: 'string',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Server_Websocket_Port', 443, {
type: 'int',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Server_Name', 'Asterisk', {
type: 'int',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Server_Websocket_Path', 'wss://asterisk.dev.com', {
type: 'string',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
});
this.section('Management_Server', function () {
this.add('VoIP_Management_Server_Host', 'asterisk.dev.com', {
type: 'string',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Management_Server_Port', 5038, {
type: 'int',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Management_Server_Name', 'Asterisk', {
type: 'string',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Management_Server_Username', 'manager.username', {
type: 'string',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
this.add('VoIP_Management_Server_Password', 'secure_password', {
type: 'string',
public: true,
enableQuery: {
_id: 'VoIP_Enabled',
value: true,
},
});
});
});
});

@ -1,5 +1,6 @@
import '../lib/messageTypes';
import './route';
import './voip';
import './ui';
import './tabBar';
import './startup/notifyUnreadRooms';

@ -3,7 +3,7 @@ import { lazy } from 'react';
import { addAction } from '../../../client/views/room/lib/Toolbox';
addAction('room-info', {
groups: ['live'],
groups: ['live' /* , 'voip'*/],
id: 'room-info',
title: 'Room_Info',
icon: 'info-circled',
@ -11,8 +11,17 @@ addAction('room-info', {
order: 0,
});
addAction('voip-room-info', {
groups: ['voip'],
id: 'voip-room-info',
title: 'Call_Information',
icon: 'info-circled',
template: lazy(() => import('../../../client/views/omnichannel/directory/chats/contextualBar/VoipInfo')),
order: 0,
});
addAction('contact-chat-history', {
groups: ['live'],
groups: ['live' /* , 'voip'*/],
id: 'contact-chat-history',
title: 'Contact_Chat_History',
icon: 'clock',

@ -0,0 +1,96 @@
import moment from 'moment';
import { MessageTypes, IMessageType } from '../../ui-utils/client';
import { IMessage, isVoipMessage } from '../../../definition/IMessage';
type IMessageFuncReturn = { at: string } | { at: string; time: string } | { comment: string } | { duration: string } | { reason: string };
const messageTypes: IMessageType[] = [
{
id: 'voip-call-started',
system: true,
message: 'Voip_call_started',
data(message: IMessage): IMessageFuncReturn {
if (!isVoipMessage(message)) {
return { at: '', time: '' };
}
const seconds = message.voipData.callWaitingTime || 0;
return {
at: message.voipData.callStarted?.toString() || 'unknown date',
time: moment.duration(seconds, 'seconds').humanize(),
};
},
},
{
id: 'voip-call-duration',
system: true,
message: 'Voip_call_duration',
data(message: IMessage): IMessageFuncReturn {
if (!isVoipMessage(message)) {
return { duration: '' };
}
const seconds = (message.voipData.callDuration || 0) / 1000;
const duration = moment.duration(seconds, 'seconds').humanize();
return {
duration,
};
},
},
{
id: 'voip-call-declined',
system: true,
message: 'Voip_call_declined',
},
{
id: 'voip-call-on-hold',
system: true,
message: 'Voip_call_on_hold',
data(message: IMessage): IMessageFuncReturn {
return {
at: message.ts.toString(),
};
},
},
{
id: 'voip-call-unhold',
system: true,
message: 'Voip_call_unhold',
data(message: IMessage): IMessageFuncReturn {
return {
at: message.ts.toString(),
};
},
},
{
id: 'voip-call-ended',
system: true,
message: 'Voip_call_ended',
data(message: IMessage): IMessageFuncReturn {
return {
at: message.ts.toString(),
};
},
},
{
id: 'voip-call-ended-unexpectedly',
system: true,
message: 'Voip_call_ended_unexpectedly',
data(message: IMessage): IMessageFuncReturn {
return {
reason: message.msg,
};
},
},
{
id: 'voip-call-wrapup',
system: true,
message: 'Voip_call_wrapup',
data(message: IMessage): IMessageFuncReturn {
return {
comment: message.msg,
};
},
},
];
messageTypes.map((e) => MessageTypes.registerType(e));

@ -38,13 +38,15 @@ API.v1.addRoute('livechat/visitor', {
}
guest.connectionData = normalizeHttpHeaderData(this.request.headers);
const visitorId = Livechat.registerGuest(guest);
const visitorId = Livechat.registerGuest(guest as any); // TODO: Rewrite Livechat to TS
let visitor = await VisitorsRaw.getVisitorByToken(token, {});
let visitor = await VisitorsRaw.findOneById(visitorId, {});
// If it's updating an existing visitor, it must also update the roomInfo
const cursor = LivechatRooms.findOpenByVisitorToken(token);
const cursor = LivechatRooms.findOpenByVisitorToken(visitor?.token);
cursor.forEach((room: IRoom) => {
Livechat.saveRoomInfo(room, visitor);
if (visitor) {
Livechat.saveRoomInfo(room, visitor);
}
});
if (customFields && customFields instanceof Array) {

@ -318,6 +318,11 @@ export const Livechat = {
if (user) {
Livechat.logger.debug('Found matching user by token');
userId = user._id;
} else if (phone && (existingUser = LivechatVisitors.findOneVisitorByPhone(phone.number))) {
Livechat.logger.debug('Found matching user by phone number');
userId = existingUser._id;
// Don't change token when matching by phone number, use current visitor token
updateUser.$set.token = existingUser.token;
} else if (email && (existingUser = LivechatVisitors.findOneGuestByEmailAddress(email))) {
Livechat.logger.debug('Found matching user by email');
userId = existingUser._id;

@ -58,6 +58,7 @@ export class Users extends Base {
this.tryEnsureIndex({ 'services.saml.inResponseTo': 1 });
this.tryEnsureIndex({ openBusinessHours: 1 }, { sparse: true });
this.tryEnsureIndex({ statusLivechat: 1 }, { sparse: true });
this.tryEnsureIndex({ extension: 1 }, { sparse: true, unique: true });
this.tryEnsureIndex({ language: 1 }, { sparse: true });
const collectionObj = this.model.rawCollection();

@ -0,0 +1,58 @@
import { Cursor } from 'mongodb';
import { BaseRaw, IndexSpecification } from './BaseRaw';
import { IPbxEvent } from '../../../../definition/IPbxEvent';
export class PbxEventsRaw extends BaseRaw<IPbxEvent> {
protected indexes: IndexSpecification[] = [{ key: { uniqueId: 1 }, unique: true }];
findByEvents(callUniqueId: string, events: string[]): Cursor<IPbxEvent> {
return this.find(
{
$or: [
{
callUniqueId,
},
{
callUniqueIdFallback: callUniqueId,
},
],
event: {
$in: events,
},
},
{
sort: {
ts: 1,
},
},
);
}
findOneByEvent(callUniqueId: string, event: string): Promise<IPbxEvent | null> {
return this.findOne({
$or: [
{
callUniqueId,
},
{
callUniqueIdFallback: callUniqueId,
},
],
event,
});
}
findOneByUniqueId(callUniqueId: string): Promise<IPbxEvent | null> {
return this.findOne({
$or: [
{
callUniqueId,
},
{
callUniqueIdFallback: callUniqueId,
},
],
});
}
}

@ -909,4 +909,62 @@ export class UsersRaw extends BaseRaw {
return this.updateOne(query, update);
}
// Voip functions
findOneByAgentUsername(username, options) {
const query = { username, roles: 'livechat-agent' };
return this.findOne(query, options);
}
findOneByExtension(extension, options) {
const query = {
extension,
};
return this.findOne(query, options);
}
findByExtensions(extensions, options) {
const query = {
extension: {
$in: extensions,
},
};
return this.find(query, options);
}
getVoipExtensionByUserId(userId, options) {
const query = {
_id: userId,
extension: { $exists: true },
};
return this.findOne(query, options);
}
setExtension(userId, extension) {
const query = {
_id: userId,
};
const update = {
$set: {
extension,
},
};
return this.update(query, update);
}
unsetExtension(userId) {
const query = {
_id: userId,
};
const update = {
$unset: {
extension: true,
},
};
return this.update(query, update);
}
}

@ -0,0 +1,171 @@
import { FilterQuery, WithoutProjection, FindOneOptions, WriteOpResult, Cursor } from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { IVoipRoom, IRoomClosingInfo } from '../../../../definition/IRoom';
import { Logger } from '../../../../server/lib/logger/Logger';
export class VoipRoomsRaw extends BaseRaw<IVoipRoom> {
logger = new Logger('VoipRoomsRaw');
async findOneOpenByVisitorToken(visitorToken: string, options: FindOneOptions<IVoipRoom> = {}): Promise<IVoipRoom | null> {
const query: FilterQuery<IVoipRoom> = {
't': 'v',
'open': true,
'v.token': visitorToken,
};
return this.findOne(query, options);
}
findOpenByAgentId(agentId: string): Cursor<IVoipRoom> {
return this.find({
't': 'v',
'open': true,
'servedBy._id': agentId,
});
}
async findOneByAgentId(agentId: string): Promise<IVoipRoom | null> {
return this.findOne({
't': 'v',
'open': true,
'servedBy._id': agentId,
});
}
async findOneVoipRoomById(id: string, options: WithoutProjection<FindOneOptions<IVoipRoom>> = {}): Promise<IVoipRoom | null> {
const query: FilterQuery<IVoipRoom> = {
t: 'v',
_id: id,
};
return this.findOne(query, options);
}
async findOneOpenByRoomIdAndVisitorToken(
roomId: string,
visitorToken: string,
options: FindOneOptions<IVoipRoom> = {},
): Promise<IVoipRoom | null> {
const query: FilterQuery<IVoipRoom> = {
't': 'v',
'_id': roomId,
'open': true,
'v.token': visitorToken,
};
return this.findOne(query, options);
}
async findOneByVisitorToken(visitorToken: string, options: FindOneOptions<IVoipRoom> = {}): Promise<IVoipRoom | null> {
const query: FilterQuery<IVoipRoom> = {
't': 'v',
'v.token': visitorToken,
};
return this.findOne(query, options);
}
async findOneByIdAndVisitorToken(
_id: IVoipRoom['_id'],
visitorToken: string,
options: FindOneOptions<IVoipRoom> = {},
): Promise<IVoipRoom | null> {
const query: FilterQuery<IVoipRoom> = {
't': 'v',
_id,
'v.token': visitorToken,
};
return this.findOne(query, options);
}
closeByRoomId(roomId: IVoipRoom['_id'], closeInfo: IRoomClosingInfo): Promise<WriteOpResult> {
const { closer, closedBy, closedAt, callDuration, serviceTimeDuration, ...extraData } = closeInfo;
return this.update(
{
_id: roomId,
t: 'v',
},
{
$set: {
closer,
closedBy,
closedAt,
callDuration,
'metrics.serviceTimeDuration': serviceTimeDuration,
'v.status': 'offline',
...extraData,
},
$unset: {
open: 1,
},
},
);
}
findRoomsWithCriteria({
agents,
open,
createdAt,
closedAt,
tags,
queue,
visitorId,
options = {},
}: {
agents?: string[];
open?: boolean;
createdAt?: { start?: string; end?: string };
closedAt?: { start?: string; end?: string };
tags?: string[];
queue?: string;
visitorId?: string;
options?: {
sort?: Record<string, unknown>;
count?: number;
fields?: Record<string, unknown>;
offset?: number;
};
}): Cursor<IVoipRoom> {
const query: FilterQuery<IVoipRoom> = {
t: 'v',
};
if (agents) {
query.$or = [{ 'servedBy._id': { $in: agents } }, { 'servedBy.username': { $in: agents } }];
}
if (open !== undefined) {
query.open = { $exists: open };
}
if (visitorId && visitorId !== 'undefined') {
query['v._id'] = visitorId;
}
if (createdAt && Object.keys(createdAt).length) {
query.ts = {};
if (createdAt.start) {
query.ts.$gte = new Date(createdAt.start);
}
if (createdAt.end) {
query.ts.$lte = new Date(createdAt.end);
}
}
if (closedAt && Object.keys(closedAt).length) {
query.closedAt = {};
if (closedAt.start) {
query.closedAt.$gte = new Date(closedAt.start);
}
if (closedAt.end) {
query.closedAt.$lte = new Date(closedAt.end);
}
}
if (tags) {
query.tags = { $in: tags };
}
if (queue) {
query.queue = queue;
}
return this.find(query, {
sort: options.sort || { name: 1 },
skip: options.offset,
limit: options.count,
});
}
}

@ -52,6 +52,7 @@ import { UsersSessionsRaw } from './UsersSessions';
import { UserDataFilesRaw } from './UserDataFiles';
import { UploadsRaw } from './Uploads';
import { WebdavAccountsRaw } from './WebdavAccounts';
import { VoipRoomsRaw } from './VoipRooms';
import ImportDataModel from '../models/ImportData';
import LivechatAgentActivityModel from '../models/LivechatAgentActivity';
import LivechatBusinessHoursModel from '../models/LivechatBusinessHours';
@ -68,6 +69,7 @@ import RoomsModel from '../models/Rooms';
import SettingsModel from '../models/Settings';
import SubscriptionsModel from '../models/Subscriptions';
import UsersModel from '../models/Users';
import { PbxEventsRaw } from './PbxEvents';
import { isRunningMs } from '../../../../server/lib/isRunningMs';
const trashCollection = trash.rawCollection();
@ -144,6 +146,8 @@ export const UsersSessions = new UsersSessionsRaw(db.collection('usersSessions')
export const UserDataFiles = new UserDataFilesRaw(db.collection(`${prefix}user_data_files`), trashCollection);
export const Uploads = new UploadsRaw(db.collection(`${prefix}uploads`), trashCollection);
export const WebdavAccounts = new WebdavAccountsRaw(db.collection(`${prefix}webdav_accounts`), trashCollection);
export const VoipRoom = new VoipRoomsRaw(db.collection(`${prefix}room`), trashCollection);
export const PbxEvent = new PbxEventsRaw(db.collection('pbx_events'), trashCollection);
const map = {
[Messages.col.collectionName]: MessagesModel,
@ -172,6 +176,7 @@ if (!isRunningMs()) {
IntegrationHistory,
Integrations,
EmailInbox,
PbxEvent,
};
initWatchers(models, api.broadcastLocal.bind(api), (model, fn) => {

@ -1,5 +1,5 @@
export const otrSystemMessages = {
USER_JOINED_OTR: 'user_joined_otr',
USER_REQUESTED_OTR_KEY_REFRESH: 'user_requested_otr_key_refresh',
USER_KEY_REFRESHED_SUCCESSFULLY: 'user_key_refreshed_successfully',
};
export enum otrSystemMessages {
USER_JOINED_OTR = 'user_joined_otr',
USER_REQUESTED_OTR_KEY_REFRESH = 'user_requested_otr_key_refresh',
USER_KEY_REFRESHED_SUCCESSFULLY = 'user_key_refreshed_successfully',
}

@ -1,12 +1,12 @@
<template name="sideNav">
<aside class="rcx-sidebar sidebar sidebar--main sidebar--{{sidebarViewMode}} {{#if sidebarHideAvatar}}sidebar--hide-avatar{{/if}}" role="navigation">
<aside class="rcx-sidebar sidebar sidebar--custom-colors sidebar--main sidebar--{{sidebarViewMode}} {{#if sidebarHideAvatar}}sidebar--hide-avatar{{/if}}" role="navigation">
{{> sidebarHeader }}
<div class="wrapper-unread">
<div class="unread-rooms background-primary-action-color color-primary-action-contrast top-unread-rooms hidden">
<i class="icon-up-big"></i> {{_ "More_unreads"}}
</div>
</div>
<div class="rooms-list sidebar--custom-colors" aria-label="{{_ "Channels"}}" role="region">
<div class="rooms-list" aria-label="{{_ "Channels"}}" role="region">
{{> sidebarChats }}
</div>
<div class="wrapper-unread">

@ -12,7 +12,7 @@ export { mainReady } from './lib/mainReady';
export { IframeLogin, iframeLogin } from './lib/IframeLogin';
export { popout } from './lib/popout';
export { messageProperties } from '../lib/MessageProperties';
export { MessageTypes } from '../lib/MessageTypes';
export { MessageTypes, IMessageType } from '../lib/MessageTypes';
export { Message } from '../lib/Message';
export { openRoom } from './lib/openRoom';
export * from './lib/collapseArrow';

@ -1,19 +0,0 @@
export const MessageTypes = new (class {
constructor() {
this.types = {};
}
registerType(options) {
this.types[options.id] = options;
return options;
}
getType(message) {
return this.types[message && message.t];
}
isSystemMessage(message) {
const type = this.types[message && message.t];
return type && type.system;
}
})();

@ -0,0 +1,42 @@
import { IMessage, MessageTypesValues } from '../../../definition/IMessage';
import type keys from '../../../packages/rocketchat-i18n/i18n/en.i18n.json';
export interface IMessageType {
id: MessageTypesValues;
system: boolean;
message: keyof typeof keys;
data?: (message: IMessage) => any;
}
type MessageTypes = {
[k in MessageTypesValues]?: IMessageType;
};
export const MessageTypes = new (class {
private types: MessageTypes = {};
constructor() {
this.types = {};
}
registerType(options: IMessageType): IMessageType {
this.types[options.id] = options;
return options;
}
getType(message: IMessage): IMessageType | undefined {
if (!message?.t) {
return;
}
return this.types[message.t];
}
isSystemMessage(message: IMessage): boolean {
if (!message?.t) {
return false;
}
const type = this.types[message.t];
return type?.system || false;
}
})();

@ -1,4 +1,8 @@
export const getDefaultUserFields = () => ({
type DefaultUserFields = {
[k: string]: number;
};
export const getDefaultUserFields = (): DefaultUserFields => ({
'name': 1,
'username': 1,
'nickname': 1,

@ -6,6 +6,7 @@ import { getUserAvatarURL } from '../../app/utils/client';
import { useTranslation } from '../contexts/TranslationContext';
import { useUser } from '../contexts/UserContext';
import { useReactiveValue } from '../hooks/useReactiveValue';
import { VoipRoomForeword } from './voip/room/VoipRoomForeword';
const RoomForeword = ({ _id: rid }) => {
const t = useTranslation();
@ -13,6 +14,10 @@ const RoomForeword = ({ _id: rid }) => {
const user = useUser();
const room = useReactiveValue(useCallback(() => Rooms.findOne({ _id: rid }), [rid]));
if (room?.t === 'v') {
return <VoipRoomForeword room={room} />;
}
if (room?.t !== 'd') {
return <>{t('Start_of_conversation')}</>;
}

@ -0,0 +1,18 @@
import { Box, Icon } from '@rocket.chat/fuselage';
import React, { ComponentProps, ReactElement } from 'react';
type ContentPropsType = {
icon: ComponentProps<typeof Icon>['name'];
text: string;
};
const Content = ({ icon, text }: ContentPropsType): ReactElement => (
<Box w='full' h='full' display='flex' flexDirection='row' alignItems='center' justifyContent='center'>
<Icon color='info' size='24px' name={icon} />
<Box mis='4px' fontScale='p2' color='info'>
{text}
</Box>
</Box>
);
export default Content;

@ -0,0 +1,18 @@
import { Box } from '@rocket.chat/fuselage';
import { Story } from '@storybook/react';
import React from 'react';
import NotAvailable from '.';
export const NotAvailableOnCall: Story = () => (
<Box h='44px' borderColor='disabled' borderWidth='2px'>
<NotAvailable>
<NotAvailable.Content icon='message' text='Messages are not available on phone calls' />
</NotAvailable>
</Box>
);
export default {
title: 'components/Composer',
component: NotAvailable,
};

@ -0,0 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
type NotAvailablePropsType = {
children: ReactElement;
};
const NotAvailable = (props: NotAvailablePropsType): ReactElement => <Box w='full' h='full' backgroundColor='neutral-100' {...props} />;
export default NotAvailable;

@ -0,0 +1,6 @@
import Content from './Content';
import NotAvailable from './NotAvailable';
export default Object.assign(NotAvailable, {
Content,
});

@ -0,0 +1,14 @@
import { Box } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
import NotAvailable from '.';
const NotAvailableOnCall = (): ReactElement => (
<Box h='44px' w='100%' borderColor='disabled' borderWidth='2px'>
<NotAvailable>
<NotAvailable.Content icon='message' text='Messages are not available on phone calls' />
</NotAvailable>
</Box>
);
export default NotAvailableOnCall;

@ -0,0 +1,65 @@
import { Button, ButtonGroup, Field, Modal, TextAreaInput } from '@rocket.chat/fuselage';
import React, { ReactElement, useEffect } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { useCallCloseRoom } from '../../../contexts/CallContext';
import { useSetModal } from '../../../contexts/ModalContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import Tags from '../../Omnichannel/Tags';
type WrapUpCallPayload = {
comment: string;
tags?: string[];
};
export const WrapUpCallModal = (): ReactElement => {
const setModal = useSetModal();
const closeRoom = useCallCloseRoom();
const closeModal = (): void => setModal(null);
const t = useTranslation();
const { register, handleSubmit, setValue } = useForm<WrapUpCallPayload>();
useEffect(() => {
register('tags');
}, [register]);
const handleTags = (value: string[]): void => {
setValue('tags', value);
};
const onSubmit: SubmitHandler<WrapUpCallPayload> = (data): void => {
closeRoom(data);
closeModal();
};
return (
<Modal is='form' onSubmit={handleSubmit(onSubmit)}>
<Modal.Header>
<Modal.Title>{t('Wrap_Up_the_Call')}</Modal.Title>
<Modal.Close onClick={closeModal} />
</Modal.Header>
<Modal.Content>
<Field mbe='24px'>
<Field.Label>{t('Notes')}</Field.Label>
<Field.Row>
<TextAreaInput {...register('comment')} />
</Field.Row>
<Field.Hint>{t('These_notes_will_be_available_in_the_call_summary')}</Field.Hint>
</Field>
<Tags handler={handleTags as () => void} />
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
<Button ghost onClick={closeModal}>
{t('Cancel')}
</Button>
<Button type='submit' primary>
{t('Save')}
</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>
);
};

@ -0,0 +1,30 @@
import { Avatar, Box, Tag } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
import { getUserAvatarURL } from '../../../../app/utils/client';
import { IRoom } from '../../../../definition/IRoom';
import { useTranslation } from '../../../contexts/TranslationContext';
export const VoipRoomForeword = ({ room }: { room: IRoom }): ReactElement => {
const t = useTranslation();
const avatarUrl = getUserAvatarURL(room.name || room.fname);
return (
<Box is='div' flexGrow={1} display='flex' justifyContent='center' flexDirection='column'>
<Box display='flex' justifyContent='center' mbs='x24'>
<Avatar size='x48' title={room.name || room.fname} url={avatarUrl} />
</Box>
<Box color='default' fontScale='h2' flexGrow={1} mb='x16'>
{t('You_have_joined_a_new_call_with')}
</Box>
<Box is='div' mb='x8' flexGrow={1} display='flex' justifyContent='center'>
<Box mi='x4'>
<Tag style={{ cursor: 'default' }} variant='secondary' medium>
{room.name || room.fname}
</Tag>
</Box>
</Box>
</Box>
);
};

@ -0,0 +1,164 @@
import { createContext, useContext, useMemo } from 'react';
import { useSubscription } from 'use-subscription';
import { IVoipRoom } from '../../definition/IRoom';
import { ICallerInfo } from '../../definition/voip/ICallerInfo';
import { VoIpCallerInfo } from '../../definition/voip/VoIpCallerInfo';
import { VoIPUser } from '../lib/voip/VoIPUser';
export type CallContextValue = CallContextDisabled | CallContextEnabled | CallContextReady | CallContextError;
type CallContextDisabled = {
enabled: false;
ready: false;
};
type CallContextEnabled = {
enabled: true;
ready: unknown;
};
type CallContextReady = {
enabled: true;
ready: true;
voipClient: VoIPUser;
actions: CallActionsType;
queueCounter: number;
openedRoomInfo: { v: { token?: string }; rid: string };
openWrapUpModal: () => void;
openRoom: (caller: ICallerInfo) => IVoipRoom['_id'];
closeRoom: (data: { comment: string; tags?: string[] }) => void;
};
type CallContextError = {
enabled: true;
ready: false;
error: Error;
};
export const isCallContextReady = (context: CallContextValue): context is CallContextReady => (context as CallContextReady).ready;
export const isCallContextError = (context: CallContextValue): context is CallContextError =>
(context as CallContextError).error !== undefined;
export type CallActionsType = {
mute: () => unknown;
unmute: () => unknown;
pause: () => unknown;
resume: () => unknown;
end: () => unknown;
pickUp: () => unknown;
reject: () => unknown;
};
const CallContextValueDefault: CallContextValue = {
enabled: false,
ready: false,
};
export const CallContext = createContext<CallContextValue>(CallContextValueDefault);
export const useIsCallEnabled = (): boolean => {
const { enabled } = useContext(CallContext);
return enabled;
};
export const useIsCallReady = (): boolean => {
const { ready } = useContext(CallContext);
return Boolean(ready);
};
export const useIsCallError = (): boolean => {
const context = useContext(CallContext);
return Boolean(isCallContextError(context));
};
export const useCallActions = (): CallActionsType => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useCallActions only if Calls are enabled and ready');
}
return context.actions;
};
export const useCallerInfo = (): VoIpCallerInfo => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useCallerInfo only if Calls are enabled and ready');
}
const { voipClient } = context;
const subscription = useMemo(
() => ({
getCurrentValue: (): VoIpCallerInfo => voipClient.callerInfo,
subscribe: (callback: () => void): (() => void) => {
voipClient.on('stateChanged', callback);
return (): void => {
voipClient.off('stateChanged', callback);
};
},
}),
[voipClient],
);
return useSubscription(subscription);
};
export const useCallOpenRoom = (): CallContextReady['openRoom'] => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useCallerInfo only if Calls are enabled and ready');
}
return context.openRoom;
};
export const useCallCloseRoom = (): CallContextReady['closeRoom'] => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useCallerInfo only if Calls are enabled and ready');
}
return context.closeRoom;
};
export const useCallClient = (): VoIPUser => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useClient only if Calls are enabled and ready');
}
return context.voipClient;
};
export const useQueueCounter = (): CallContextReady['queueCounter'] => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useQueueInfo only if Calls are enabled and ready');
}
return context.queueCounter;
};
export const useWrapUpModal = (): CallContextReady['openWrapUpModal'] => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useClient only if Calls are enabled and ready');
}
return context.openWrapUpModal;
};
export const useOpenedRoomInfo = (): CallContextReady['openedRoomInfo'] => {
const context = useContext(CallContext);
if (!isCallContextReady(context)) {
throw new Error('useClient only if Calls are enabled and ready');
}
return context.openedRoomInfo;
};

@ -5,3 +5,4 @@ import './livechat';
import './private';
import './public';
import './unread';
import './voip';

@ -0,0 +1,37 @@
import { hasPermission } from '../../../../app/authorization/client';
import { ChatRoom } from '../../../../app/models/client';
import { settings } from '../../../../app/settings/client';
import { getAvatarURL } from '../../../../app/utils/lib/getAvatarURL';
import type { IRoom } from '../../../../definition/IRoom';
import type { IRoomTypeClientDirectives } from '../../../../definition/IRoomTypeConfig';
import type { AtLeast } from '../../../../definition/utils';
import { getVoipRoomType } from '../../../../lib/rooms/roomTypes/voip';
import { roomCoordinator } from '../roomCoordinator';
export const VoipRoomType = getVoipRoomType(roomCoordinator);
roomCoordinator.add(VoipRoomType, {
roomName(room: any): string | undefined {
return room.name || room.fname || room.label;
},
condition(): boolean {
return settings.get('Livechat_enabled') && hasPermission('view-l-room');
},
getAvatarPath(room): string {
return getAvatarURL({ username: `@${this.roomName(room)}` }) || '';
},
findRoom(identifier: string): IRoom | undefined {
return ChatRoom.findOne({ _id: identifier });
},
canSendMessage(_rid: string): boolean {
return false;
},
readOnly(_rid: string, _user): boolean {
return true;
},
} as AtLeast<IRoomTypeClientDirectives, 'roomName'>);

@ -0,0 +1,27 @@
import { Session } from 'sip.js';
import { SessionDescriptionHandler } from 'sip.js/lib/platform/web';
/** Helper function to enable/disable media tracks. */
export async function toggleMediaStreamTracks(enable: boolean, session: Session, direction: 'sender' | 'receiver'): Promise<void> {
const { sessionDescriptionHandler } = session;
if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) {
throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
}
const { peerConnection } = sessionDescriptionHandler;
if (!peerConnection) {
throw new Error('Peer connection closed.');
}
let mediaStreams = null;
if (direction === 'sender') {
mediaStreams = peerConnection.getSenders();
} else if (direction === 'receiver') {
mediaStreams = peerConnection.getReceivers();
}
if (mediaStreams) {
mediaStreams.forEach((stream) => {
if (stream.track) {
stream.track.enabled = enable;
}
});
}
}

@ -0,0 +1,24 @@
// TODO: This file is not yet used since we need to test more the way voip is returning unknown contatcts.
import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber';
const phoneNumberParser = (
phoneNumber: string,
// currentCountryCode: string,
): { numberRegionCode?: string; parsedNumber?: string; error?: string } => {
const phoneNumberUtil = PhoneNumberUtil.getInstance();
let numberRegionCode;
let parsedNumber;
let error;
try {
const number = phoneNumberUtil.parse(phoneNumber);
numberRegionCode = phoneNumberUtil.getRegionCodeForNumber(number);
parsedNumber = phoneNumberUtil.format(number, PhoneNumberFormat.INTERNATIONAL);
} catch (e) {
error = e as string | undefined;
}
return { numberRegionCode, parsedNumber, error };
};
export default phoneNumberParser;

@ -0,0 +1,26 @@
import { IMediaStreamRenderer } from '../../../definition/voip/VoIPUserConfiguration';
import { VoIPUser } from './VoIPUser';
export class SimpleVoipUser {
static async create(
userName: string,
password: string,
registrar: string,
webSocketPath: string,
iceServers: Array<object>,
callType?: 'audio' | 'video',
mediaStreamRendered?: IMediaStreamRenderer,
): Promise<VoIPUser> {
const config = {
authUserName: userName,
authPassword: password,
sipRegistrarHostnameOrIP: registrar,
webSocketURI: webSocketPath,
enableVideo: callType === 'video',
iceServers,
};
return VoIPUser.create(config, mediaStreamRendered);
}
}

@ -0,0 +1,110 @@
/**
* This class is used for stream manipulation.
* @remarks
* This class wraps up browser media stream and HTMLMedia element
* and takes care of rendering the media on a given element.
* This provides enough abstraction so that the higher level
* classes do not need to know about the browser specificities for
* media.
* This will also provide stream related functionalities such as
* mixing of 2 streams in to 2, adding/removing tracks, getting a track information
* detecting voice energy etc. Which will be implemented as when needed
*/
export default class Stream {
private mediaStream: MediaStream | undefined;
private renderingMediaElement: HTMLMediaElement | undefined;
constructor(mediaStream: MediaStream) {
this.mediaStream = mediaStream;
}
/**
* Called for stopping the tracks in a given stream.
* @remarks
* All the tracks from a given stream will be stopped.
*/
private stopTracks(): void {
const tracks = this.mediaStream?.getTracks();
if (tracks) {
for (let i = 0; i < tracks?.length; i++) {
tracks[i].stop();
}
}
}
/**
* Called for setting the callback when the track gets added
* @remarks
*/
onTrackAdded(callBack: any): void {
this.mediaStream?.onaddtrack?.(callBack);
}
/**
* Called for setting the callback when the track gets removed
* @remarks
*/
onTrackRemoved(callBack: any): void {
this.mediaStream?.onremovetrack?.(callBack);
}
/**
* Called for initializing the class
* @remarks
*/
init(rmElement: HTMLMediaElement): void {
this.renderingMediaElement = rmElement;
}
/**
* Called for playing the stream
* @remarks
* Plays the stream on media element. Stream will be autoplayed and muted based on the settings.
* throws and error if the play fails.
*/
play(autoPlay = true, muteAudio = false): void {
if (this.renderingMediaElement && this.mediaStream) {
this.renderingMediaElement.autoplay = autoPlay;
this.renderingMediaElement.srcObject = this.mediaStream;
if (autoPlay) {
this.renderingMediaElement.play().catch((error: Error) => {
throw error;
});
}
if (muteAudio) {
this.renderingMediaElement.volume = 0;
}
}
}
/**
* Called for pausing the stream
* @remarks
*/
pause(): void {
this.renderingMediaElement?.pause();
}
/**
* Called for clearing the streams and media element.
* @remarks
* This function stops the media element play, clears the srcObject
* stops all the tracks in the stream and sets media stream to undefined.
* This function ususally gets called when call ends or to clear the previous stream
* when the stream is switched to another stream.
*/
clear(): void {
if (this.renderingMediaElement && this.mediaStream) {
this.renderingMediaElement.pause();
this.renderingMediaElement.srcObject = null;
this.stopTracks();
this.mediaStream = undefined;
}
}
}

@ -0,0 +1,618 @@
/**
* Class representing SIP UserAgent
* @remarks
* This class encapsulates all the details of sip.js and exposes
* a very simple functions and callback handlers to the outside world.
* This class thus abstracts user from Browser specific media details as well as
* SIP specific protol details.
*/
import { Emitter } from '@rocket.chat/emitter';
import {
UserAgent,
UserAgentOptions,
// UserAgentDelegate,
Invitation,
InvitationAcceptOptions,
Session,
SessionState,
Registerer,
SessionInviteOptions,
RequestPendingError,
} from 'sip.js';
import { OutgoingByeRequest, OutgoingRequestDelegate, URI } from 'sip.js/lib/core';
import { SessionDescriptionHandler, SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web';
import { CallStates } from '../../../definition/voip/CallStates';
import { ICallerInfo } from '../../../definition/voip/ICallerInfo';
import { Operation } from '../../../definition/voip/Operations';
import { UserState } from '../../../definition/voip/UserState';
import { IMediaStreamRenderer, VoIPUserConfiguration } from '../../../definition/voip/VoIPUserConfiguration';
import { VoIpCallerInfo, IState } from '../../../definition/voip/VoIpCallerInfo';
import { VoipEvents } from '../../../definition/voip/VoipEvents';
import { toggleMediaStreamTracks } from './Helper';
import Stream from './Stream';
export class VoIPUser extends Emitter<VoipEvents> implements OutgoingRequestDelegate {
state: IState = {
isReady: false,
enableVideo: false,
};
private session: Session | undefined;
private remoteStream: Stream | undefined;
userAgentOptions: UserAgentOptions = {};
userAgent: UserAgent | undefined;
registerer: Registerer | undefined;
mediaStreamRendered?: IMediaStreamRenderer;
private _callState: CallStates = 'IDLE';
private _callerInfo: ICallerInfo | undefined;
private _userState: UserState = UserState.IDLE;
private _held = false;
get callState(): CallStates {
return this._callState;
}
get callerInfo(): VoIpCallerInfo {
if (this.callState === 'IN_CALL' || this.callState === 'OFFER_RECEIVED' || this.callState === 'ON_HOLD') {
if (!this._callerInfo) {
throw new Error('[VoIPUser callerInfo] invalid state');
}
return {
state: this.callState,
caller: this._callerInfo,
userState: this._userState,
};
}
return {
state: this.callState,
userState: this._userState,
};
}
private _opInProgress: Operation = Operation.OP_NONE;
get operationInProgress(): Operation {
return this._opInProgress;
}
get userState(): UserState | undefined {
return this._userState;
}
/* Media Stream functions begin */
/** The local media stream. Undefined if call not answered. */
get localMediaStream(): MediaStream | undefined {
const sdh = this.session?.sessionDescriptionHandler;
if (!sdh) {
return undefined;
}
if (!(sdh instanceof SessionDescriptionHandler)) {
throw new Error('Session description handler not instance of web SessionDescriptionHandler');
}
return sdh.localMediaStream;
}
/* 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 {
if (this._opInProgress === Operation.OP_REGISTER) {
this._callState = 'REGISTERED';
this.emit('registered');
this.emit('stateChanged');
}
if (this._opInProgress === Operation.OP_UNREGISTER) {
this._callState = 'UNREGISTERED';
this.emit('unregistered');
this.emit('stateChanged');
}
}
onReject(error: any): void {
if (this._opInProgress === Operation.OP_REGISTER) {
this.emit('registrationerror', error);
}
if (this._opInProgress === Operation.OP_UNREGISTER) {
this.emit('unregistrationerror', error);
}
}
/* OutgoingRequestDelegate methods end */
private async handleIncomingCall(invitation: Invitation): Promise<void> {
if (this.callState === 'REGISTERED') {
this._opInProgress = Operation.OP_PROCESS_INVITE;
this._callState = 'OFFER_RECEIVED';
this._userState = UserState.UAS;
this.session = invitation;
this.setupSessionEventHandlers(invitation);
const callerInfo: ICallerInfo = {
callerId: invitation.remoteIdentity.uri.user ? invitation.remoteIdentity.uri.user : '',
callerName: invitation.remoteIdentity.displayName,
host: invitation.remoteIdentity.uri.host,
};
this._callerInfo = callerInfo;
this.emit('incomingcall', callerInfo);
this.emit('stateChanged');
return;
}
await invitation.reject();
}
/**
* Sets up an listener handler for handling session's state change
* @remarks
* Called for setting up various state listeners. These listeners will
* decide the next action to be taken when the session state changes.
* e.g when session.state changes from |Establishing| to |Established|
* one must set up local and remote media rendering.
*
* This class handles such session state changes and takes necessary actions.
*/
private setupSessionEventHandlers(session: Session): void {
this.session?.stateChange.addListener((state: SessionState) => {
if (this.session !== session) {
return; // if our session has changed, just return
}
switch (state) {
case SessionState.Initial:
break;
case SessionState.Establishing:
break;
case SessionState.Established:
this._opInProgress = Operation.OP_NONE;
this._callState = 'IN_CALL';
this.setupRemoteMedia();
this.emit('callestablished');
this.emit('stateChanged');
break;
case SessionState.Terminating:
// fall through
case SessionState.Terminated:
this.session = undefined;
this._callState = 'REGISTERED';
this._opInProgress = Operation.OP_NONE;
this._userState = UserState.IDLE;
this.emit('callterminated');
this.remoteStream?.clear();
this.emit('stateChanged');
break;
default:
throw new Error('Unknown session state.');
}
});
}
onTrackAdded(_event: any): void {
console.log('onTrackAdded');
}
onTrackRemoved(_event: any): void {
console.log('onTrackRemoved');
}
/**
* Carries out necessary steps for rendering remote media whe
* call gets established.
* @remarks
* Sets up Stream class and plays the stream on given Media element/
* Also sets up various event handlers.
*/
private setupRemoteMedia(): any {
if (!this.session) {
throw new Error('Session does not exist.');
}
const sdh = this.session?.sessionDescriptionHandler;
if (!sdh) {
return undefined;
}
if (!(sdh instanceof SessionDescriptionHandler)) {
throw new Error('Session description handler not instance of web SessionDescriptionHandler');
}
const remoteStream = sdh.remoteMediaStream;
if (!remoteStream) {
throw new Error('Remote media stream undefiend.');
}
this.remoteStream = new Stream(remoteStream);
const mediaElement = this.mediaStreamRendered?.remoteMediaElement;
if (mediaElement) {
this.remoteStream.init(mediaElement);
this.remoteStream.onTrackAdded(this.onTrackAdded.bind(this));
this.remoteStream.onTrackRemoved(this.onTrackRemoved.bind(this));
this.remoteStream.play();
}
}
/**
* Handles call mute-unmute
*/
private async handleMuteUnmute(muteState: boolean): Promise<void> {
const { session } = this;
if (this._held === muteState) {
return Promise.resolve();
}
if (!session) {
throw new Error('Session not found');
}
const sessionDescriptionHandler = this.session?.sessionDescriptionHandler;
if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) {
throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
}
const options: SessionInviteOptions = {
requestDelegate: {
onAccept: (): void => {
this._held = muteState;
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
},
onReject: (): void => {
this.emit('muteerror');
},
},
};
const { peerConnection } = sessionDescriptionHandler;
if (!peerConnection) {
throw new Error('Peer connection closed.');
}
return this.session
?.invite(options)
.then(() => {
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
})
.catch((error: Error) => {
if (error instanceof RequestPendingError) {
console.error(`[${this.session?.id}] A mute request is already in progress.`);
}
this.emit('muteerror');
throw error;
});
}
/**
* Handles call hold-unhold
*/
private async handleHoldUnhold(holdState: boolean): Promise<void> {
const { session } = this;
if (this._held === holdState) {
return Promise.resolve();
}
if (!session) {
throw new Error('Session not found');
}
const sessionDescriptionHandler = this.session?.sessionDescriptionHandler;
if (!(sessionDescriptionHandler instanceof SessionDescriptionHandler)) {
throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
}
const options: SessionInviteOptions = {
requestDelegate: {
onAccept: (): void => {
this._held = holdState;
this._callState = holdState ? 'ON_HOLD' : 'IN_CALL';
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
this._callState === 'ON_HOLD' ? this.emit('hold') : this.emit('unhold');
this.emit('stateChanged');
},
onReject: (): void => {
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
this.emit('holderror');
},
},
};
// Session properties used to pass options to the SessionDescriptionHandler:
//
// 1) Session.sessionDescriptionHandlerOptions
// SDH options for the initial INVITE transaction.
// - Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
// - May be set directly at anytime.
// - May optionally be set via constructor option.
// - May optionally be set via options passed to Inviter.invite() or Invitation.accept().
//
// 2) Session.sessionDescriptionHandlerOptionsReInvite
// SDH options for re-INVITE transactions.
// - Used in all cases when handling a re-INVITE transaction as either UAC or UAS.
// - May be set directly at anytime.
// - May optionally be set via constructor option.
// - May optionally be set via options passed to Session.invite().
const sessionDescriptionHandlerOptions = session.sessionDescriptionHandlerOptionsReInvite as SessionDescriptionHandlerOptions;
sessionDescriptionHandlerOptions.hold = holdState;
session.sessionDescriptionHandlerOptionsReInvite = sessionDescriptionHandlerOptions;
const { peerConnection } = sessionDescriptionHandler;
if (!peerConnection) {
throw new Error('Peer connection closed.');
}
return this.session
?.invite(options)
.then(() => {
toggleMediaStreamTracks(!this._held, session, 'receiver');
toggleMediaStreamTracks(!this._held, session, 'sender');
})
.catch((error: Error) => {
if (error instanceof RequestPendingError) {
console.error(`[${this.session?.id}] A hold request is already in progress.`);
}
this.emit('holderror');
throw error;
});
}
/**
* 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<void> {
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<void> => {
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<VoIPUser> {
const voip = new VoIPUser(config, mediaRenderer);
await voip.init();
return voip;
}
/**
* Public method called from outside to register the SIP UA with call server.
* @remarks
*/
register(): void {
this._opInProgress = Operation.OP_REGISTER;
this.registerer?.register({
requestDelegate: this,
});
}
/**
* Public method called from outside to unregister the SIP UA.
* @remarks
*/
unregister(): void {
this._opInProgress = Operation.OP_UNREGISTER;
this.registerer?.unregister({
all: true,
requestDelegate: this,
});
}
/**
* Public method called from outside to accept incoming call.
* @remarks
*/
async acceptCall(mediaRenderer: IMediaStreamRenderer): Promise<void> {
if (mediaRenderer) {
this.mediaStreamRendered = mediaRenderer;
}
// Call state must be in offer_received.
if (this._callState === 'OFFER_RECEIVED' && this._opInProgress === Operation.OP_PROCESS_INVITE) {
this._callState = 'ANSWER_SENT';
// Somethingis wrong, this session is not an instance of INVITE
if (!(this.session instanceof Invitation)) {
throw new Error('Session not instance of Invitation.');
}
/**
* It is important to decide when to add video option to the outgoing offer.
* This would matter when the reinvite goes out (In case of hold/unhold)
* This was added because there were failures in hold-unhold.
* The scenario was that if this client does hold-unhold first, and remote client does
* later, remote client goes in inconsistent state and hold-unhold does not work
* Where as if the remote client does hold-unhold first, this client can do it any number
* of times.
*
* Logic below works as follows
* Local video settings = true, incoming invite has video mline = false -> Any offer = audiovideo/ answer = audioonly
* Local video settings = true, incoming invite has video mline = true -> Any offer = audiovideo/ answer = audiovideo
* Local video settings = false, incoming invite has video mline = false -> Any offer = audioonly/ answer = audioonly
* Local video settings = false, incoming invite has video mline = true -> Any offer = audioonly/ answer = audioonly
*
*/
let videoInvite = !!this.config.enableVideo;
const { body } = this.session;
if (body && body.indexOf('m=video') === -1) {
videoInvite = false;
}
const invitationAcceptOptions: InvitationAcceptOptions = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: true,
video: !!this.config.enableVideo && videoInvite,
},
},
};
return this.session.accept(invitationAcceptOptions);
}
throw new Error('Something went wront');
}
/**
* Public method called from outside to reject a call.
* @remarks
*/
rejectCall(): Promise<void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (this._callState !== 'OFFER_RECEIVED') {
throw new Error(`Incorrect call State = ${this.callState}`);
}
if (!(this.session instanceof Invitation)) {
throw new Error('Session not instance of Invitation.');
}
return this.session.reject();
}
/**
* Public method called from outside to end a call.
* @remarks
*/
async endCall(): Promise<OutgoingByeRequest | void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (this._callState !== 'ANSWER_SENT' && this._callState !== 'IN_CALL' && this._callState !== 'ON_HOLD') {
throw new Error(`Incorrect call State = ${this.callState}`);
}
switch (this.session.state) {
case SessionState.Initial:
if (this.session instanceof Invitation) {
return this.session.reject();
}
throw new Error('Session not instance of Invitation.');
case SessionState.Establishing:
if (this.session instanceof Invitation) {
return this.session.reject();
}
throw new Error('Session not instance of Invitation.');
case SessionState.Established:
return this.session.bye();
case SessionState.Terminating:
break;
case SessionState.Terminated:
break;
default:
throw new Error('Unknown state');
}
}
/**
* Public method called from outside to mute the call.
* @remarks
*/
async muteCall(muteState: boolean): Promise<void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (this._callState !== 'IN_CALL') {
throw new Error(`Incorrect call State = ${this.callState}`);
}
this.handleMuteUnmute(muteState);
}
/**
* Public method called from outside to hold the call.
* @remarks
*/
async holdCall(holdState: boolean): Promise<void> {
if (!this.session) {
throw new Error('Session does not exist.');
}
if (this._callState !== 'ANSWER_SENT' && this._callState !== 'IN_CALL' && this._callState !== 'ON_HOLD') {
throw new Error(`Incorrect call State = ${this.callState}`);
}
this.handleHoldUnhold(holdState);
}
/* CallEventDelegate implementation end */
isReady(): boolean {
return this.state.isReady;
}
}

@ -0,0 +1,176 @@
import { Random } from 'meteor/random';
import React, { useMemo, FC, useRef, useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { OutgoingByeRequest } from 'sip.js/lib/core';
import { Notifications } from '../../../app/notifications/client';
import { IVoipRoom } from '../../../definition/IRoom';
import { WrapUpCallModal } from '../../components/voip/modal/WrapUpCallModal';
import { CallContext, CallContextValue } from '../../contexts/CallContext';
import { useSetModal } from '../../contexts/ModalContext';
import { useRoute } from '../../contexts/RouterContext';
import { useEndpoint } from '../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useUser } from '../../contexts/UserContext';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { isUseVoipClientResultError, isUseVoipClientResultLoading, useVoipClient } from './hooks/useVoipClient';
export const CallProvider: FC = ({ children }) => {
// TODO: Test Settings and return false if its disabled (based on the settings)
const result = useVoipClient();
const user = useUser();
const homeRoute = useRoute('home');
const remoteAudioMediaRef = useRef<HTMLAudioElement>(null); // TODO: Create a dedicated file for the AUDIO and make the controls accessible
const AudioTagPortal: FC = ({ children }) => useMemo(() => createPortal(children, document.body), [children]);
const dispatchToastMessage = useToastMessageDispatch();
const [queueCounter, setQueueCounter] = useState('');
const setModal = useSetModal();
const openWrapUpModal = useCallback((): void => {
setModal(<WrapUpCallModal />);
}, [setModal]);
const handleAgentCalled = useCallback(
(queue: { queuename: string }): void => {
dispatchToastMessage({
type: 'success',
message: `Received call in queue ${queue.queuename}`,
options: {
showDuration: '2000',
hideDuration: '500',
timeOut: '500',
},
});
},
[dispatchToastMessage],
);
const handleAgentConnected = useCallback((queue: { queuename: string; queuedcalls: string; waittimeinqueue: string }): void => {
setQueueCounter(queue.queuedcalls);
}, []);
const handleMemberAdded = useCallback((queue: { queuename: string; queuedcalls: string }): void => {
setQueueCounter(queue.queuedcalls);
}, []);
const handleMemberRemoved = useCallback((queue: { queuename: string; queuedcalls: string }): void => {
setQueueCounter(queue.queuedcalls);
}, []);
const handleCallAbandon = useCallback((queue: { queuename: string; queuedcallafterabandon: string }): void => {
setQueueCounter(queue.queuedcallafterabandon);
}, []);
const handleQueueJoined = useCallback(
async (joiningDetails: { queuename: string; callerid: { id: string }; queuedcalls: string }): Promise<void> => {
setQueueCounter(joiningDetails.queuedcalls);
},
[],
);
const handleCallHangup = useCallback(
(_event: { roomId: string }) => {
openWrapUpModal();
},
[openWrapUpModal],
);
useEffect(() => {
Notifications.onUser('callerjoined', handleQueueJoined);
Notifications.onUser('agentcalled', handleAgentCalled);
Notifications.onUser('agentconnected', handleAgentConnected);
Notifications.onUser('queuememberadded', handleMemberAdded);
Notifications.onUser('queuememberremoved', handleMemberRemoved);
Notifications.onUser('callabandoned', handleCallAbandon);
Notifications.onUser('call.callerhangup', handleCallHangup);
}, [
handleAgentCalled,
handleQueueJoined,
handleMemberAdded,
handleMemberRemoved,
handleCallAbandon,
handleAgentConnected,
handleCallHangup,
]);
const visitorEndpoint = useEndpoint('POST', 'livechat/visitor');
const voipEndpoint = useEndpoint('GET', 'voip/room');
const voipCloseRoomEndpoint = useEndpoint('POST', 'voip/room.close');
const [roomInfo, setRoomInfo] = useState<{ v: { token?: string }; rid: string }>();
const contextValue: CallContextValue = useMemo(() => {
if (isUseVoipClientResultError(result)) {
return {
enabled: true,
ready: false,
error: result.error,
};
}
if (isUseVoipClientResultLoading(result)) {
return {
enabled: true,
ready: false,
};
}
const { registrationInfo, voipClient } = result;
return {
enabled: true,
ready: true,
openedRoomInfo: roomInfo,
registrationInfo,
voipClient,
queueCounter,
actions: {
mute: (): Promise<void> => voipClient.muteCall(true), // voipClient.mute(),
unmute: (): Promise<void> => voipClient.muteCall(false), // voipClient.unmute()
pause: (): Promise<void> => voipClient.holdCall(true), // voipClient.pause()
resume: (): Promise<void> => voipClient.holdCall(false), // voipClient.resume()
end: (): Promise<OutgoingByeRequest | void> => voipClient.endCall(),
pickUp: async (): Promise<void | null> =>
remoteAudioMediaRef.current && voipClient.acceptCall({ remoteMediaElement: remoteAudioMediaRef.current }),
reject: (): Promise<void> => voipClient.rejectCall(),
},
openRoom: async (caller): Promise<IVoipRoom['_id']> => {
if (user) {
const { visitor } = await visitorEndpoint({
visitor: {
token: Random.id(),
phone: caller.callerId,
name: caller.callerName || caller.callerId,
},
});
const voipRoom = visitor && (await voipEndpoint({ token: visitor.token, agentId: user._id }));
voipRoom.room && roomCoordinator.openRouteLink(voipRoom.room.t, { rid: voipRoom.room._id, name: voipRoom.room.name });
voipRoom.room && setRoomInfo({ v: { token: voipRoom.room.v.token }, rid: voipRoom.room._id });
return voipRoom.room._id;
}
return '';
},
closeRoom: async ({ comment, tags }): Promise<void> => {
roomInfo && (await voipCloseRoomEndpoint({ rid: roomInfo.rid, token: roomInfo.v.token || '', comment, tags }));
homeRoute.push({});
},
openWrapUpModal,
};
}, [queueCounter, homeRoute, openWrapUpModal, result, roomInfo, user, visitorEndpoint, voipCloseRoomEndpoint, voipEndpoint]);
return (
<CallContext.Provider value={contextValue}>
{children}
{contextValue.enabled && (
<AudioTagPortal>
<audio ref={remoteAudioMediaRef} />
</AudioTagPortal>
)}
</CallContext.Provider>
);
};

@ -0,0 +1,5 @@
export type IceServer = {
urls: string;
username?: string;
credential?: string;
};

@ -0,0 +1,66 @@
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { useEffect, useState } from 'react';
import { IRegistrationInfo } from '../../../../definition/voip/IRegistrationInfo';
import { useEndpoint } from '../../../contexts/ServerContext';
import { useUser } from '../../../contexts/UserContext';
import { SimpleVoipUser } from '../../../lib/voip/SimpleVoipUser';
import { VoIPUser } from '../../../lib/voip/VoIPUser';
import { useWebRtcServers } from './useWebRtcServers';
type UseVoipClientResult = UseVoipClientResultResolved | UseVoipClientResultError | UseVoipClientResultLoading;
type UseVoipClientResultResolved = {
voipClient: VoIPUser;
registrationInfo: IRegistrationInfo;
};
type UseVoipClientResultError = { error: Error };
type UseVoipClientResultLoading = Record<string, never>;
export const isUseVoipClientResultError = (result: UseVoipClientResult): result is UseVoipClientResultError =>
!!(result as UseVoipClientResultError).error;
export const isUseVoipClientResultLoading = (result: UseVoipClientResult): result is UseVoipClientResultLoading =>
!result || !Object.keys(result).length;
export const useVoipClient = (): UseVoipClientResult => {
const registrationInfo = useEndpoint('GET', 'connector.extension.getRegistrationInfoByUserId');
const user = useUser();
const iceServers = useWebRtcServers();
const [result, setResult] = useSafely(useState<UseVoipClientResult>({}));
useEffect(() => {
if (!user || !user?._id) {
setResult({});
return;
}
registrationInfo({ id: user._id }).then(
(data) => {
const {
extensionDetails: { extension, password },
host,
callServerConfig: { websocketPath },
} = data;
let client: VoIPUser;
(async (): Promise<void> => {
try {
client = await SimpleVoipUser.create(extension, password, host, websocketPath, iceServers, 'video');
setResult({ voipClient: client, registrationInfo: data });
} catch (e) {
setResult({ error: e as Error });
}
})();
},
(error) => {
setResult({ error: error as Error });
},
);
return (): void => {
// client?.disconnect();
// TODO how to close the client? before creating a new one?
};
}, [user, iceServers, registrationInfo, setResult]);
return result;
};

@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { useSetting } from '../../../contexts/SettingsContext';
import { IceServer } from '../definitions/IceServer';
import { parseStringToIceServers } from '../lib/parseStringToIceServers';
export const useWebRtcServers = (): IceServer[] => {
const servers = useSetting('WebRTC_Servers');
return useMemo(() => {
if (typeof servers !== 'string' || !servers.trim()) {
return [];
}
return parseStringToIceServers(servers);
}, [servers]);
};

@ -0,0 +1 @@
export * from './CallProvider';

@ -0,0 +1,52 @@
import { assert } from 'chai';
import { parseStringToIceServers, parseStringToIceServer } from './parseStringToIceServers';
describe('parseStringToIceServers', () => {
describe('parseStringToIceServers', () => {
it('should parse return an empty array if string is empty', () => {
const result = parseStringToIceServers('');
assert.deepEqual(result, []);
});
it('should parse string to servers', () => {
const servers = parseStringToIceServers('stun:stun.l.google.com:19302');
assert.equal(servers.length, 1);
assert.equal(servers[0].urls, 'stun:stun.l.google.com:19302');
assert.equal(servers[0].username, undefined);
assert.equal(servers[0].credential, undefined);
});
it('should parse string to servers with multiple urls', () => {
const servers = parseStringToIceServers('stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302');
assert.equal(servers.length, 2);
assert.equal(servers[0].urls, 'stun:stun.l.google.com:19302');
assert.equal(servers[1].urls, 'stun:stun1.l.google.com:19302');
});
it('should parse string to servers with multiple urls, with password and username', () => {
const servers = parseStringToIceServers(
'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302,team%40rocket.chat:demo@turn:numb.viagenie.ca:3478',
);
assert.equal(servers.length, 3);
assert.equal(servers[0].urls, 'stun:stun.l.google.com:19302');
assert.equal(servers[1].urls, 'stun:stun1.l.google.com:19302');
assert.equal(servers[2].urls, 'turn:numb.viagenie.ca:3478');
assert.equal(servers[2].username, 'team@rocket.chat');
assert.equal(servers[2].credential, 'demo');
});
});
describe('parseStringToIceServer', () => {
it('should parse string to server', () => {
const server = parseStringToIceServer('stun:stun.l.google.com:19302');
assert.equal(server.urls, 'stun:stun.l.google.com:19302');
});
it('should parse string to server with username and password', () => {
const server = parseStringToIceServer('team%40rocket.chat:demo@turn:numb.viagenie.ca:3478');
assert.equal(server.urls, 'turn:numb.viagenie.ca:3478');
assert.equal(server.username, 'team@rocket.chat');
assert.equal(server.credential, 'demo');
});
});
});

@ -0,0 +1,21 @@
import { IceServer } from '../definitions/IceServer';
export const parseStringToIceServer = (server: string): IceServer => {
const credentials = server.trim().split('@');
const urls = credentials.pop() as string;
const [username, credential] = credentials.length === 1 ? credentials[0].split(':') : [];
return {
urls,
...(username &&
credential && {
username: decodeURIComponent(username),
credential: decodeURIComponent(credential),
}),
};
};
export const parseStringToIceServers = (string: string): IceServer[] => {
const lines = string.trim() ? string.split(',') : [];
return lines.map((line) => parseStringToIceServer(line));
};

@ -3,6 +3,7 @@ import React, { FC } from 'react';
import AttachmentProvider from '../components/Message/Attachments/providers/AttachmentProvider';
import AuthorizationProvider from './AuthorizationProvider';
import AvatarUrlProvider from './AvatarUrlProvider';
import { CallProvider } from './CallProvider';
import ConnectionStatusProvider from './ConnectionStatusProvider';
import CustomSoundProvider from './CustomSoundProvider';
import LayoutProvider from './LayoutProvider';
@ -31,11 +32,13 @@ const MeteorProvider: FC = ({ children }) => (
<CustomSoundProvider>
<UserProvider>
<AuthorizationProvider>
<OmnichannelProvider>
<ModalProvider>
<AttachmentProvider>{children}</AttachmentProvider>
</ModalProvider>
</OmnichannelProvider>
<CallProvider>
<OmnichannelProvider>
<ModalProvider>
<AttachmentProvider>{children}</AttachmentProvider>
</ModalProvider>
</OmnichannelProvider>
</CallProvider>
</AuthorizationProvider>
</UserProvider>
</CustomSoundProvider>

@ -1,4 +1,5 @@
import React, { useState, useEffect, FC, useMemo, useCallback, memo } from 'react';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import React, { useState, useEffect, FC, useMemo, useCallback, memo, useRef } from 'react';
import { LivechatInquiry } from '../../app/livechat/client/collections/LivechatInquiry';
import { initializeLivechatInquiryStream } from '../../app/livechat/client/lib/stream/queueManager';
@ -6,6 +7,7 @@ import { Notifications } from '../../app/notifications/client';
import { IOmnichannelAgent } from '../../definition/IOmnichannelAgent';
import { IRoom } from '../../definition/IRoom';
import { OmichannelRoutingConfig } from '../../definition/OmichannelRoutingConfig';
import { ClientLogger } from '../../lib/ClientLogger';
import { usePermission } from '../contexts/AuthorizationContext';
import { OmnichannelContext, OmnichannelContextValue } from '../contexts/OmnichannelContext';
import { useMethod } from '../contexts/ServerContext';
@ -26,18 +28,20 @@ const OmnichannelProvider: FC = ({ children }) => {
const showOmnichannelQueueLink = useSetting('Livechat_show_queue_list_link') as boolean;
const omnichannelPoolMaxIncoming = useSetting('Livechat_guest_pool_max_number_incoming_livechats_displayed') as number;
const loggerRef = useRef(new ClientLogger('OmnichannelProvider'));
const hasAccess = usePermission('view-l-room');
const canViewOmnichannelQueue = usePermission('view-livechat-queue');
const user = useUser() as IOmnichannelAgent;
const agentAvailable = user?.statusLivechat === 'available';
const voipCallAvailable = true; // TODO: use backend check;
const getRoutingConfig = useMethod('livechat:getRoutingConfig');
const [routeConfig, setRouteConfig] = useState<OmichannelRoutingConfig | undefined>(undefined);
const [routeConfig, setRouteConfig] = useSafely(useState<OmichannelRoutingConfig | undefined>(undefined));
const accessible = hasAccess && omniChannelEnabled;
const iceServersSetting: any = useSetting('WebRTC_Servers');
useEffect(() => {
if (!accessible) {
@ -49,14 +53,14 @@ const OmnichannelProvider: FC = ({ children }) => {
const routeConfig = await getRoutingConfig();
setRouteConfig(routeConfig);
} catch (error) {
console.error(error);
loggerRef.current.error(`update() error in routeConfig ${error}`);
}
};
if (omnichannelRouting || !omnichannelRouting) {
update();
}
}, [accessible, getRoutingConfig, omnichannelRouting]);
}, [accessible, getRoutingConfig, iceServersSetting, omnichannelRouting, setRouteConfig, voipCallAvailable]);
const enabled = accessible && !!user && !!routeConfig;
const manuallySelected =
@ -112,6 +116,7 @@ const OmnichannelProvider: FC = ({ children }) => {
...emptyContextValue,
enabled: true,
agentAvailable,
voipCallAvailable,
routeConfig,
};
}
@ -120,6 +125,7 @@ const OmnichannelProvider: FC = ({ children }) => {
...emptyContextValue,
enabled: true,
agentAvailable,
voipCallAvailable,
routeConfig,
inquiries: queue
? {
@ -129,7 +135,7 @@ const OmnichannelProvider: FC = ({ children }) => {
: { enabled: false },
showOmnichannelQueueLink: showOmnichannelQueueLink && !!agentAvailable,
};
}, [agentAvailable, enabled, manuallySelected, queue, routeConfig, showOmnichannelQueueLink]);
}, [agentAvailable, voipCallAvailable, enabled, manuallySelected, queue, routeConfig, showOmnichannelQueueLink]);
return <OmnichannelContext.Provider children={children} value={contextValue} />;
};

@ -1,11 +1,11 @@
import { SidebarSection } from '@rocket.chat/fuselage';
import React, { memo } from 'react';
import Omnichannel from '../sections/Omnichannel';
import OmnichannelSection from '../sections/OmnichannelSection';
import SideBarItemTemplateWithData from './SideBarItemTemplateWithData';
const sections = {
Omnichannel,
Omnichannel: OmnichannelSection,
};
const Row = ({ data, item }) => {

@ -1,9 +1,11 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { Box, SidebarFooter as Footer } from '@rocket.chat/fuselage';
import colors from '@rocket.chat/fuselage-tokens/colors.json';
import React, { ReactElement } from 'react';
import { settings } from '../../../app/settings/client';
import { useIsCallEnabled, useIsCallReady } from '../../contexts/CallContext';
import { VoipFooter } from './voip';
const SidebarFooter = (): ReactElement => {
const sidebarFooterStyle = css`
@ -18,16 +20,25 @@ const SidebarFooter = (): ReactElement => {
}
`;
const isCallEnabled = useIsCallEnabled();
const ready = useIsCallReady();
if (isCallEnabled && ready) {
return <VoipFooter />;
}
return (
<Box
is='footer'
pb='x12'
pi='x16'
height='x48'
width='auto'
className={sidebarFooterStyle}
dangerouslySetInnerHTML={{ __html: String(settings.get('Layout_Sidenav_Footer')).trim() }}
/>
<Footer>
<Box
is='footer'
pb='x12'
pi='x16'
height='x48'
width='auto'
className={sidebarFooterStyle}
dangerouslySetInnerHTML={{ __html: String(settings.get('Layout_Sidenav_Footer')).trim() }}
/>
</Footer>
);
};

@ -0,0 +1,58 @@
import { Box } from '@rocket.chat/fuselage';
import React, { ReactElement, useState } from 'react';
import { VoipFooter } from './VoipFooter';
export default {
title: 'sidebar/footer/voip',
component: VoipFooter,
};
const callActions = {
mute: () => ({}),
unmute: () => ({}),
pause: () => ({}),
resume: () => ({}),
end: () => ({}),
pickUp: () => ({}),
reject: () => ({}),
};
const tooltips = {
mute: 'Mute',
holdCall: 'Hold Call',
acceptCall: 'Accept Call',
endCall: 'End Call',
};
export const Default = (): ReactElement => {
const [muted, toggleMic] = useState(false);
const [paused, togglePause] = useState(false);
const [callsInQueue] = useState('2');
return (
<Box maxWidth='x300' bg='neutral-800' borderRadius='x4'>
<VoipFooter
caller={{
callerName: 'Tiago',
callerId: 'guest-1',
host: '',
}}
callerState='OFFER_RECEIVED'
callActions={callActions}
title='Sales Department'
subtitle='Calling'
muted={muted}
paused={paused}
toggleMic={toggleMic}
togglePause={togglePause}
tooltips={tooltips}
openRoom={() => ''}
callsInQueue={callsInQueue}
openWrapUpCallModal={() => null}
dispatchEvent={() => null}
openedRoomInfo={{ v: { token: '' }, rid: '' }}
/>
</Box>
);
};

@ -0,0 +1,142 @@
import { Box, Button, ButtonGroup, Icon, SidebarFooter } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
import { IVoipRoom } from '../../../../definition/IRoom';
import { ICallerInfo } from '../../../../definition/voip/ICallerInfo';
import { VoIpCallerInfo } from '../../../../definition/voip/VoIpCallerInfo';
import { VoipClientEvents } from '../../../../definition/voip/VoipClientEvents';
import { CallActionsType } from '../../../contexts/CallContext';
type VoipFooterPropsType = {
caller: ICallerInfo;
callerState: VoIpCallerInfo['state'];
callActions: CallActionsType;
title: string;
subtitle: string;
muted: boolean;
paused: boolean;
toggleMic: (state: boolean) => void;
togglePause: (state: boolean) => void;
tooltips: {
mute: string;
holdCall: string;
acceptCall: string;
endCall: string;
};
callsInQueue: string;
openWrapUpCallModal: () => void;
openRoom: (caller: ICallerInfo) => IVoipRoom['_id'];
dispatchEvent: (params: { event: VoipClientEvents; rid: string; comment?: string }) => void;
openedRoomInfo: { v: { token?: string | undefined }; rid: string };
};
export const VoipFooter = ({
caller,
callerState,
callActions,
title,
subtitle,
muted,
paused,
toggleMic,
togglePause,
tooltips,
openRoom,
callsInQueue,
openWrapUpCallModal,
dispatchEvent,
openedRoomInfo,
}: VoipFooterPropsType): ReactElement => (
<SidebarFooter elevated>
<Box display='flex' justifyContent='center' fontScale='c1' color='white' mbe='14px'>
{callsInQueue}
</Box>
<Box display='flex' flexDirection='row' mi='16px' mbs='12px' mbe='8px' justifyContent='space-between' alignItems='center'>
<Box color='neutral-500' fontScale='c2' withTruncatedText>
{title}
</Box>
{(callerState === 'IN_CALL' || callerState === 'ON_HOLD') && (
<ButtonGroup medium>
<Button disabled={paused} title={tooltips.mute} small square nude onClick={(): void => toggleMic(!muted)}>
{muted ? <Icon name='mic' color='neutral-500' size='x24' /> : <Icon name='mic' color='info' size='x24' />}
</Button>
<Button
title={tooltips.holdCall}
small
square
nude
onClick={(): void => {
if (paused) {
dispatchEvent({ event: VoipClientEvents['VOIP-CALL-UNHOLD'], rid: openedRoomInfo.rid });
} else {
dispatchEvent({ event: VoipClientEvents['VOIP-CALL-ON-HOLD'], rid: openedRoomInfo.rid });
}
togglePause(!paused);
}}
>
{paused ? (
<Icon name='pause-unfilled' color='neutral-500' size='x24' />
) : (
<Icon name='pause-unfilled' color='info' size='x24' />
)}
</Button>
</ButtonGroup>
)}
</Box>
<Box display='flex' flexDirection='row' mi='16px' mbe='12px' justifyContent='space-between' alignItems='center'>
<Box>
<Box color='white' fontScale='p2' withTruncatedText>
{caller.callerName}
</Box>
<Box color='hint' fontScale='c1' withTruncatedText>
{subtitle}
</Box>
</Box>
<ButtonGroup medium>
{callerState === 'IN_CALL' && (
<Button
title={tooltips.endCall}
disabled={paused}
small
square
danger
primary
onClick={(): unknown => {
toggleMic(false);
togglePause(false);
openWrapUpCallModal();
dispatchEvent({ event: VoipClientEvents['VOIP-CALL-ENDED'], rid: openedRoomInfo.rid });
return callActions.end();
}}
>
<Icon name='phone-off' size='x16' />
</Button>
)}
{callerState === 'OFFER_RECEIVED' && (
<Button title={tooltips.endCall} small square danger primary onClick={callActions.reject}>
<Icon name='phone-off' size='x16' />
</Button>
)}
{callerState === 'OFFER_RECEIVED' && (
<Button
title={tooltips.acceptCall}
small
square
success
primary
onClick={async (): Promise<void> => {
callActions.pickUp();
const rid = await openRoom(caller);
dispatchEvent({ event: VoipClientEvents['VOIP-CALL-STARTED'], rid });
}}
>
<Icon name='phone' size='x16' />
</Button>
)}
</ButtonGroup>
</Box>
</SidebarFooter>
);

@ -0,0 +1,89 @@
import React, { ReactElement, useCallback, useState } from 'react';
import {
useCallActions,
useCallerInfo,
useCallOpenRoom,
useOpenedRoomInfo,
useQueueCounter,
useWrapUpModal,
} from '../../../contexts/CallContext';
import { useEndpoint } from '../../../contexts/ServerContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import { VoipFooter as VoipFooterComponent } from './VoipFooter';
export const VoipFooter = (): ReactElement | null => {
const t = useTranslation();
const callerInfo = useCallerInfo();
const callActions = useCallActions();
const dispatchEvent = useEndpoint('POST', 'voip/events');
const openRoom = useCallOpenRoom();
const queueCounter = useQueueCounter();
const openWrapUpCallModal = useWrapUpModal();
const openedRoomInfo = useOpenedRoomInfo();
const [muted, setMuted] = useState(false);
const [paused, setPaused] = useState(false);
const toggleMic = useCallback(
(state: boolean) => {
state ? callActions.mute() : callActions.unmute();
setMuted(state);
},
[callActions],
);
const togglePause = useCallback(
(state: boolean) => {
state ? callActions.pause() : callActions.resume();
setMuted(false);
setPaused(state);
},
[callActions],
);
const getSubtitle = (): string => {
switch (callerInfo.state) {
case 'IN_CALL':
return t('In_progress');
case 'OFFER_RECEIVED':
return t('Calling');
case 'ON_HOLD':
return t('On_Hold');
}
return '';
};
const tooltips = {
mute: t('Mute'),
holdCall: t('Hold_Call'),
acceptCall: t('Accept_Call'),
endCall: t('End_Call'),
};
if (!('caller' in callerInfo)) {
return null;
}
return (
<VoipFooterComponent
caller={callerInfo.caller}
callerState={callerInfo.state}
callActions={callActions}
title={t('Phone_call')}
subtitle={getSubtitle()}
muted={muted}
paused={paused}
toggleMic={toggleMic}
togglePause={togglePause}
tooltips={tooltips}
openRoom={openRoom}
callsInQueue={t('Calls_in_queue', { calls: queueCounter })}
openWrapUpCallModal={openWrapUpCallModal}
dispatchEvent={dispatchEvent}
openedRoomInfo={openedRoomInfo}
/>
);
};

@ -1,30 +0,0 @@
import React, { useMemo } from 'react';
import RoomAvatar from '../../components/avatar/RoomAvatar';
import { useUserPreference } from '../../contexts/UserContext';
export const useAvatarTemplate = () => {
const sidebarViewMode = useUserPreference('sidebarViewMode');
const sidebarDisplayAvatar = useUserPreference('sidebarDisplayAvatar');
return useMemo(() => {
if (!sidebarDisplayAvatar) {
return null;
}
const size = (() => {
switch (sidebarViewMode) {
case 'extended':
return 'x36';
case 'medium':
return 'x28';
case 'condensed':
default:
return 'x16';
}
})();
const renderRoomAvatar = (room) => <RoomAvatar size={size} room={{ ...room, _id: room.rid || room._id, type: room.t }} />;
return renderRoomAvatar;
}, [sidebarDisplayAvatar, sidebarViewMode]);
};

@ -0,0 +1,39 @@
import React, { ReactNode, useMemo } from 'react';
import { IRoom } from '../../../definition/IRoom';
import RoomAvatar from '../../components/avatar/RoomAvatar';
import { useUserPreference } from '../../contexts/UserContext';
export const useAvatarTemplate = (
sidebarViewMode?: 'extended' | 'medium' | 'condensed',
sidebarDisplayAvatar?: boolean,
): null | ((room: IRoom & { rid: string }) => ReactNode) => {
const sidebarViewModeFromSettings = useUserPreference<'extended' | 'medium' | 'condensed'>('sidebarViewMode');
const sidebarDisplayAvatarFromSettings = useUserPreference('sidebarDisplayAvatar');
const viewMode = sidebarViewMode ?? sidebarViewModeFromSettings;
const displayAvatar = sidebarDisplayAvatar ?? sidebarDisplayAvatarFromSettings;
return useMemo(() => {
if (!displayAvatar) {
return null;
}
const size = ((): 'x36' | 'x28' | 'x16' => {
switch (viewMode) {
case 'extended':
return 'x36';
case 'medium':
return 'x28';
case 'condensed':
default:
return 'x16';
}
})();
const renderRoomAvatar = (room: IRoom & { rid: string }): ReactNode => (
<RoomAvatar size={size} room={{ ...room, _id: room.rid || room._id, type: room.t }} />
);
return renderRoomAvatar;
}, [displayAvatar, viewMode]);
};

@ -1,42 +1,46 @@
import { Sidebar } from '@rocket.chat/fuselage';
import { Box, Sidebar } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { memo } from 'react';
import React, { memo, ReactElement } from 'react';
import { hasPermission } from '../../../app/authorization/client';
import { usePermission } from '../../contexts/AuthorizationContext';
import { useIsCallEnabled } from '../../contexts/CallContext';
import { useLayout } from '../../contexts/LayoutContext';
import { useOmnichannelShowQueueLink, useOmnichannelAgentAvailable } from '../../contexts/OmnichannelContext';
import { useRoute } from '../../contexts/RouterContext';
import { useMethod } from '../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { OmnichannelCallToggle } from './components/OmnichannelCallToggle';
const OmnichannelSection = (props) => {
const changeAgentStatus = useMethod('livechat:changeLivechatStatus');
const OmnichannelSection = (props: typeof Box): ReactElement => {
const t = useTranslation();
const changeAgentStatus = useMethod('livechat:changeLivechatStatus');
const isCallEnabled = useIsCallEnabled();
const hasPermission = usePermission('view-omnichannel-contact-center');
const agentAvailable = useOmnichannelAgentAvailable();
const showOmnichannelQueueLink = useOmnichannelShowQueueLink();
const { sidebar } = useLayout();
const directoryRoute = useRoute('omnichannel-directory');
const queueListRoute = useRoute('livechat-queue');
const dispatchToastMessage = useToastMessageDispatch();
const icon = {
const availableIcon = {
title: agentAvailable ? t('Available') : t('Not_Available'),
color: agentAvailable ? 'success' : undefined,
icon: agentAvailable ? 'message' : 'message-disabled',
...(agentAvailable && { success: 1 }),
};
} as const;
const directoryIcon = {
title: t('Contact_Center'),
icon: 'contact',
};
const handleStatusChange = useMutableCallback(async () => {
} as const;
const handleAvailableStatusChange = useMutableCallback(async () => {
try {
await changeAgentStatus();
} catch (error) {
} catch (error: any) {
dispatchToastMessage({ type: 'error', message: error });
console.log(error);
}
});
@ -53,15 +57,15 @@ const OmnichannelSection = (props) => {
}
});
// The className is a paliative while we make TopBar.ToolBox optional on fuselage
return (
<Sidebar.TopBar.ToolBox {...props}>
<Sidebar.TopBar.ToolBox className='omnichannel-sidebar' {...props}>
<Sidebar.TopBar.Title>{t('Omnichannel')}</Sidebar.TopBar.Title>
<Sidebar.TopBar.Actions>
{showOmnichannelQueueLink && <Sidebar.TopBar.Action icon='queue' title={t('Queue')} onClick={() => handleRoute('queue')} />}
<Sidebar.TopBar.Action {...icon} onClick={handleStatusChange} />
{hasPermission(['view-omnichannel-contact-center']) && (
<Sidebar.TopBar.Action {...directoryIcon} onClick={() => handleRoute('directory')} />
)}
{showOmnichannelQueueLink && <Sidebar.TopBar.Action icon='queue' title={t('Queue')} onClick={(): void => handleRoute('queue')} />}
{isCallEnabled && <OmnichannelCallToggle />}
<Sidebar.TopBar.Action {...availableIcon} onClick={handleAvailableStatusChange} />
{hasPermission && <Sidebar.TopBar.Action {...directoryIcon} onClick={(): void => handleRoute('directory')} />}
</Sidebar.TopBar.Actions>
</Sidebar.TopBar.ToolBox>
);

@ -0,0 +1,20 @@
import React, { ReactElement } from 'react';
import { useIsCallReady, useIsCallError } from '../../../contexts/CallContext';
import { OmnichannelCallToggleError } from './OmnichannelCallToggleError';
import { OmnichannelCallToggleLoading } from './OmnichannelCallToggleLoading';
import { OmnichannelCallToggleReady } from './OmnichannelCallToggleReady';
export const OmnichannelCallToggle = (): ReactElement => {
const isCallReady = useIsCallReady();
const isCallError = useIsCallError();
if (isCallError) {
return <OmnichannelCallToggleError />;
}
if (!isCallReady) {
return <OmnichannelCallToggleLoading />;
}
return <OmnichannelCallToggleReady />;
};

@ -0,0 +1,9 @@
import { Sidebar } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
import { useTranslation } from '../../../contexts/TranslationContext';
export const OmnichannelCallToggleError = (): ReactElement => {
const t = useTranslation();
return <Sidebar.TopBar.Action icon='phone' danger data-title={t('Error')} disabled />;
};

@ -0,0 +1,9 @@
import { Sidebar } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
import { useTranslation } from '../../../contexts/TranslationContext';
export const OmnichannelCallToggleLoading = (): ReactElement => {
const t = useTranslation();
return <Sidebar.TopBar.Action icon='phone' data-title={t('Loading')} disabled />;
};

@ -0,0 +1,71 @@
import { Sidebar } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { ReactElement, useEffect, useState } from 'react';
import { useCallClient } from '../../../contexts/CallContext';
import { useTranslation } from '../../../contexts/TranslationContext';
export const OmnichannelCallToggleReady = (): ReactElement => {
const [agentEnabled, setAgentEnabled] = useState(false); // TODO: get from AgentInfo
const t = useTranslation();
const [registered, setRegistered] = useState(false);
const voipCallIcon = {
title: !registered ? t('Enable') : t('Disable'),
color: registered ? 'success' : undefined,
icon: registered ? 'phone' : 'phone-disabled',
} as const;
const voipClient = useCallClient();
// TODO: move registration flow to context provider
const handleVoipCallStatusChange = useMutableCallback((): void => {
// TODO: backend set voip call status
// voipClient.setVoipCallStatus(!registered);
if (agentEnabled) {
setAgentEnabled(false);
voipClient.unregister();
return;
}
setAgentEnabled(true);
voipClient.register();
});
const onUnregistrationError = useMutableCallback((): void => {
voipClient.off('unregistrationerror', onUnregistrationError);
});
const onUnregistered = useMutableCallback((): void => {
setRegistered(!registered);
voipClient.off('unregistered', onUnregistered);
voipClient.off('registrationerror', onUnregistrationError);
});
const onRegistrationError = useMutableCallback((): void => {
voipClient.off('registrationerror', onRegistrationError);
});
const onRegistered = useMutableCallback((): void => {
setRegistered(!registered);
voipClient.off('registered', onRegistered);
voipClient.off('registrationerror', onRegistrationError);
});
useEffect(() => {
if (!voipClient) {
return;
}
voipClient.on('registered', onRegistered);
voipClient.on('registrationerror', onRegistrationError);
voipClient.on('unregistered', onUnregistered);
voipClient.on('unregistrationerror', onUnregistrationError);
return (): void => {
voipClient.off('registered', onRegistered);
voipClient.off('registrationerror', onRegistrationError);
voipClient.off('unregistered', onUnregistered);
voipClient.off('unregistrationerror', onUnregistrationError);
};
}, [onRegistered, onRegistrationError, onUnregistered, onUnregistrationError, voipClient]);
return <Sidebar.TopBar.Action {...voipCallIcon} onClick={handleVoipCallStatusChange} />;
};

@ -167,3 +167,7 @@ createTemplateForComponent('sidebarFooter', () => import('./sidebar/footer'));
createTemplateForComponent('roomNotFound', () => import('./views/room/Room/RoomNotFound'), {
renderContainerView: () => HTML.DIV({ style: 'height: 100%;' }),
});
createTemplateForComponent('ComposerNotAvailablePhoneCalls', () => import('./components/voip/composer/template'), {
renderContainerView: () => HTML.DIV({ style: 'display: flex; height: 100%; width: 100%' }),
});

@ -1,154 +0,0 @@
import { Accordion, Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useMemo, memo } from 'react';
import Page from '../../../components/Page';
import { useEditableSettingsDispatch, useEditableSettings } from '../../../contexts/EditableSettingsContext';
import { useSettingsDispatch, useSettings } from '../../../contexts/SettingsContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation, useLoadLanguage } from '../../../contexts/TranslationContext';
import { useUser } from '../../../contexts/UserContext';
import GroupPageSkeleton from './GroupPageSkeleton';
function GroupPage({ children = undefined, headerButtons = undefined, _id, i18nLabel, i18nDescription = undefined, tabs = undefined }) {
const changedEditableSettings = useEditableSettings(
useMemo(
() => ({
group: _id,
changed: true,
}),
[_id],
),
);
const originalSettings = useSettings(
useMemo(
() => ({
_id: changedEditableSettings.map(({ _id }) => _id),
}),
[changedEditableSettings],
),
);
const dispatch = useSettingsDispatch();
const dispatchToastMessage = useToastMessageDispatch();
const t = useTranslation();
const loadLanguage = useLoadLanguage();
const user = useUser();
const save = useMutableCallback(async () => {
const changes = changedEditableSettings.map(({ _id, value, editor }) => ({
_id,
value,
editor,
}));
if (changes.length === 0) {
return;
}
try {
await dispatch(changes);
if (changes.some(({ _id }) => _id === 'Language')) {
const lng = user?.language || changes.filter(({ _id }) => _id === 'Language').shift()?.value || 'en';
await loadLanguage(lng);
dispatchToastMessage({ type: 'success', message: t('Settings_updated', { lng }) });
return;
}
dispatchToastMessage({ type: 'success', message: t('Settings_updated') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const dispatchToEditing = useEditableSettingsDispatch();
const cancel = useMutableCallback(() => {
dispatchToEditing(
changedEditableSettings
.map(({ _id }) => originalSettings.find((setting) => setting._id === _id))
.map((setting) => {
if (!setting) {
return;
}
return {
_id: setting._id,
value: setting.value,
editor: setting.editor,
changed: false,
};
})
.filter(Boolean),
);
});
const handleSubmit = (event) => {
event.preventDefault();
save();
};
const handleCancelClick = (event) => {
event.preventDefault();
cancel();
};
const handleSaveClick = (event) => {
event.preventDefault();
save();
};
if (!_id) {
return (
<Page>
<Page.Header />
<Page.Content />
</Page>
);
}
return (
<Page is='form' action='#' method='post' onSubmit={handleSubmit}>
<Page.Header title={t(i18nLabel)}>
<ButtonGroup>
{changedEditableSettings.length > 0 && (
<Button danger primary type='reset' onClick={handleCancelClick}>
{t('Cancel')}
</Button>
)}
<Button
children={t('Save_changes')}
className='save'
disabled={changedEditableSettings.length === 0}
primary
type='submit'
onClick={handleSaveClick}
/>
{headerButtons}
</ButtonGroup>
</Page.Header>
{tabs}
<Page.ScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
{t.has(i18nDescription) && (
<Box is='p' color='hint' fontScale='p2'>
{t(i18nDescription)}
</Box>
)}
<Accordion className='page-settings'>{children}</Accordion>
</Box>
</Page.ScrollableContentWithShadow>
</Page>
);
}
export default Object.assign(memo(GroupPage), {
Skeleton: GroupPageSkeleton,
});

@ -0,0 +1,196 @@
import { Accordion, Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useMemo, memo, FC, ReactNode, FormEvent, MouseEvent } from 'react';
import { ISetting, ISettingColor } from '../../../../definition/ISetting';
import Page from '../../../components/Page';
import { useEditableSettingsDispatch, useEditableSettings, IEditableSetting } from '../../../contexts/EditableSettingsContext';
import { useSettingsDispatch, useSettings } from '../../../contexts/SettingsContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation, useLoadLanguage, TranslationKey } from '../../../contexts/TranslationContext';
import { useUser } from '../../../contexts/UserContext';
import GroupPageSkeleton from './GroupPageSkeleton';
type GroupPageProps = {
children: ReactNode;
headerButtons?: ReactNode;
_id: string;
i18nLabel: string;
i18nDescription?: string;
tabs?: ReactNode;
isCustom?: boolean;
};
const GroupPage: FC<GroupPageProps> = ({
children = undefined,
headerButtons = undefined,
_id,
i18nLabel,
i18nDescription = undefined,
tabs = undefined,
isCustom = false,
}) => {
const changedEditableSettings = useEditableSettings(
useMemo(
() => ({
group: _id,
changed: true,
}),
[_id],
),
);
const originalSettings = useSettings(
useMemo(
() => ({
_id: changedEditableSettings.map(({ _id }) => _id),
}),
[changedEditableSettings],
),
);
const dispatch = useSettingsDispatch();
const dispatchToastMessage = useToastMessageDispatch();
const t = useTranslation();
const loadLanguage = useLoadLanguage();
const user = useUser();
const isColorSetting = (setting: ISetting): setting is ISettingColor => setting.type === 'color';
const save = useMutableCallback(async () => {
const changes = changedEditableSettings.map((setting) => {
if (isColorSetting(setting)) {
return {
_id: setting._id,
value: setting.value,
editor: setting.editor,
};
}
return {
_id: setting._id,
value: setting.value,
};
});
if (changes.length === 0) {
return;
}
try {
await dispatch(changes);
if (changes.some(({ _id }) => _id === 'Language')) {
const lng = user?.language || changes.filter(({ _id }) => _id === 'Language').shift()?.value || 'en';
if (typeof lng === 'string') {
await loadLanguage(lng);
dispatchToastMessage({ type: 'success', message: t('Settings_updated', { lng }) });
return;
}
throw new Error('lng is not a string');
}
dispatchToastMessage({ type: 'success', message: t('Settings_updated') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error as string });
}
});
const dispatchToEditing = useEditableSettingsDispatch();
const cancel = useMutableCallback(() => {
const settingsToDispatch = changedEditableSettings
.map(({ _id }) => originalSettings.find((setting) => setting._id === _id))
.map((setting) => {
if (!setting) {
return;
}
if (isColorSetting(setting)) {
return {
_id: setting._id,
value: setting.value,
editor: setting.editor,
changed: false,
};
}
return {
_id: setting._id,
value: setting.value,
changed: false,
};
})
.filter(Boolean);
dispatchToEditing(settingsToDispatch as Partial<IEditableSetting>[]);
});
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
save();
};
const handleCancelClick = (event: MouseEvent<HTMLOrSVGElement>): void => {
event.preventDefault();
cancel();
};
const handleSaveClick = (event: MouseEvent<HTMLOrSVGElement>): void => {
event.preventDefault();
save();
};
if (!_id) {
return <Page>{children}</Page>;
}
console.log('CHILDREN', children);
// The settings
const isTranslationKey = (key: string): key is TranslationKey => (key as TranslationKey) !== undefined;
return (
<Page is='form' action='#' method='post' onSubmit={handleSubmit}>
<Page.Header title={i18nLabel && isTranslationKey(i18nLabel) && t(i18nLabel)}>
<ButtonGroup>
{changedEditableSettings.length > 0 && (
<Button danger primary type='reset' onClick={handleCancelClick}>
{t('Cancel')}
</Button>
)}
<Button
children={t('Save_changes')}
className='save'
disabled={changedEditableSettings.length === 0}
primary
type='submit'
onClick={handleSaveClick}
/>
{headerButtons}
</ButtonGroup>
</Page.Header>
{tabs}
{isCustom ? (
children
) : (
<Page.ScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
{i18nDescription && isTranslationKey(i18nDescription) && t.has(i18nDescription) && (
<Box is='p' color='hint' fontScale='p2'>
{t(i18nDescription)}
</Box>
)}
<Accordion className='page-settings'>{children}</Accordion>
</Box>
</Page.ScrollableContentWithShadow>
)}
</Page>
);
};
export default Object.assign(memo(GroupPage), {
Skeleton: GroupPageSkeleton,
});

@ -7,6 +7,7 @@ import AssetsGroupPage from './groups/AssetsGroupPage';
import LDAPGroupPage from './groups/LDAPGroupPage';
import OAuthGroupPage from './groups/OAuthGroupPage';
import TabbedGroupPage from './groups/TabbedGroupPage';
import VoipGroupPage from './groups/VoipGroupPage';
type GroupSelectorProps = {
groupId: GroupId;
@ -31,6 +32,10 @@ const GroupSelector: FunctionComponent<GroupSelectorProps> = ({ groupId }) => {
return <LDAPGroupPage {...group} />;
}
if (groupId === 'VoIP') {
return <VoipGroupPage {...group} />;
}
return <TabbedGroupPage {...group} />;
};

@ -0,0 +1,54 @@
import { Tabs, Box, Accordion } from '@rocket.chat/fuselage';
import React, { memo, useMemo, useState } from 'react';
import type { ISetting } from '../../../../../definition/ISetting';
import Page from '../../../../components/Page';
import { useEditableSettingsGroupSections } from '../../../../contexts/EditableSettingsContext';
import { useTranslation, TranslationKey } from '../../../../contexts/TranslationContext';
import GroupPage from '../GroupPage';
import Section from '../Section';
import VoipExtensionsPage from './voip/VoipExtensionsPage';
function VoipGroupPage({ _id, ...group }: ISetting): JSX.Element {
const t = useTranslation();
const tabs = ['Server_Configuration', 'Extensions'];
const [tab, setTab] = useState(tabs[0]);
const handleTabClick = useMemo(() => (tab: string) => (): void => setTab(tab), [setTab]);
const sections = useEditableSettingsGroupSections('VoIP', tab);
if (!tab && tabs[0]) {
setTab(tabs[0]);
}
const tabsComponent = (
<Tabs>
{tabs.map((tabName) => (
<Tabs.Item key={tabName || ''} selected={tab === tabName} onClick={handleTabClick(tabName)}>
{tabName ? t(tabName as TranslationKey) : t(_id as TranslationKey)}
</Tabs.Item>
))}
</Tabs>
);
return (
<GroupPage _id={_id} {...group} tabs={tabsComponent} isCustom={true}>
{tab === 'Extensions' ? (
<VoipExtensionsPage />
) : (
<Page.ScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
<Accordion className='page-settings'>
{sections.map((sectionName) => (
<Section key={sectionName || ''} groupId={_id} sectionName={sectionName} tabName={tab} solo={false} />
))}
</Accordion>
</Box>
</Page.ScrollableContentWithShadow>
)}
</GroupPage>
);
}
export default memo(VoipGroupPage);

@ -0,0 +1,76 @@
import { Button, ButtonGroup, Modal, Select, Field, FieldGroup } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { FC, useState, useMemo } from 'react';
import AutoCompleteAgent from '../../../../../components/AutoCompleteAgent';
import { useEndpoint } from '../../../../../contexts/ServerContext';
import { useTranslation } from '../../../../../contexts/TranslationContext';
import { AsyncStatePhase } from '../../../../../hooks/useAsyncState';
import { useEndpointData } from '../../../../../hooks/useEndpointData';
type AssignAgentModalParams = {
closeModal: () => void;
reload: () => void;
};
const AssignAgentModal: FC<AssignAgentModalParams> = ({ closeModal, reload }) => {
const t = useTranslation();
const [agent, setAgent] = useState('');
const [extension, setExtension] = useState('');
const query = useMemo(() => ({ type: 'available' as const, userId: agent }), [agent]);
const assignAgent = useEndpoint('POST', 'omnichannel/agent/extension');
const handleAssignment = useMutableCallback(async () => {
try {
await assignAgent({ userId: agent, extension });
} catch (error) {
console.log(error);
}
reload();
closeModal();
});
const { value: availableExtensions, phase: state } = useEndpointData('omnichannel/extension', query);
return (
<Modal>
<Modal.Header>
<Modal.Title>{t('Associate_Agent_to_Extension')}</Modal.Title>
<Modal.Close onClick={closeModal} />
</Modal.Header>
<Modal.Content>
<FieldGroup>
<Field>
<Field.Label>{t('Agent_Without_Extensions')}</Field.Label>
<Field.Row>
<AutoCompleteAgent empty onChange={setAgent} />
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Free_Extension_Numbers')}</Field.Label>
<Field.Row>
<Select
disabled={state === AsyncStatePhase.LOADING || agent === ''}
options={availableExtensions?.extensions?.map((extension) => [extension, extension]) || []}
value={extension}
placeholder={t('Select_an_option')}
onChange={setExtension}
/>
</Field.Row>
</Field>
</FieldGroup>
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
<Button onClick={closeModal}>{t('Cancel')}</Button>
<Button primary disabled={!agent || !extension} onClick={handleAssignment}>
{t('Associate')}
</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>
);
};
export default AssignAgentModal;

@ -0,0 +1,58 @@
import { Table, Icon, Button } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { FC } from 'react';
import GenericModal from '../../../../../components/GenericModal';
import { useSetModal } from '../../../../../contexts/ModalContext';
import { useEndpoint } from '../../../../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../../../../contexts/ToastMessagesContext';
import { useTranslation } from '../../../../../contexts/TranslationContext';
const RemoveAgentButton: FC<{ username: string; reload: () => void }> = ({ username, reload }) => {
const removeAgent = useEndpoint('DELETE', 'omnichannel/agent/extension');
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const t = useTranslation();
const handleRemoveClick = useMutableCallback(async () => {
try {
await removeAgent({ username });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
reload();
});
const handleDelete = useMutableCallback((e) => {
e.stopPropagation();
const onDeleteAgent = async (): Promise<void> => {
try {
await handleRemoveClick();
dispatchToastMessage({ type: 'success', message: t('Agent_removed') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
setModal();
};
setModal(
<GenericModal
variant='danger'
onConfirm={onDeleteAgent}
onCancel={(): void => setModal()}
onClose={(): void => setModal()}
confirmText={t('Delete')}
/>,
);
});
return (
<Table.Cell fontScale='p2' color='hint' withTruncatedText>
<Button small ghost title={t('Remove')} onClick={handleDelete}>
<Icon name='trash' size='x16' />
</Button>
</Table.Cell>
);
};
export default RemoveAgentButton;

@ -0,0 +1,103 @@
import { Box, Chip, Table, Button } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import React, { FC, useMemo, useCallback, useState } from 'react';
import GenericTable from '../../../../../components/GenericTable';
import Page from '../../../../../components/Page';
import { useSetModal } from '../../../../../contexts/ModalContext';
import { useTranslation } from '../../../../../contexts/TranslationContext';
import { useEndpointData } from '../../../../../hooks/useEndpointData';
import AssignAgentModal from './AssignAgentModal';
import RemoveAgentButton from './RemoveAgentButton';
const VoipExtensionsPage: FC = () => {
const t = useTranslation();
const setModal = useSetModal();
const [params, setParams] = useState<{ current?: number; itemsPerPage?: 25 | 50 | 100 }>({
current: 0,
itemsPerPage: 25,
});
const { itemsPerPage, current } = useDebouncedValue(params, 500);
const query = useMemo(
() => ({
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[itemsPerPage, current],
);
const { value: data, reload } = useEndpointData('omnichannel/extensions', query);
const header = useMemo(
() =>
[
<GenericTable.HeaderCell key='extension' w='x156'>
{t('Extension_Number')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='username' w='x160'>
{t('Agent_Name')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='username' w='x120'>
{t('Extension_Status')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='queues' w='x120'>
{t('Queues')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'remove-add'} w='x60' />,
].filter(Boolean),
[t],
);
const renderRow = useCallback(
({ _id, extension, username, state, queues }) => (
<Table.Row key={_id} tabIndex={0}>
<Table.Cell withTruncatedText>{extension}</Table.Cell>
<Table.Cell withTruncatedText>{username || t('Unassigned')}</Table.Cell>
<Table.Cell withTruncatedText>{state}</Table.Cell>
<Table.Cell withTruncatedText maxHeight='x36'>
<Box display='flex' flexDirection='row' alignItems='center' title={queues?.join(', ')}>
{queues?.map(
(queue: string, index: number) =>
index <= 1 && (
<Chip mie='x4' key={queue} value={queue}>
{queue}
</Chip>
),
)}
{queues?.length > 2 && `+${(queues.length - 2).toString()}`}
</Box>
</Table.Cell>
{username ? <RemoveAgentButton username={username} reload={reload} /> : null}
</Table.Row>
),
[reload, t],
);
return (
<Page.Content>
<Box display='flex' flexDirection='row' alignItems='center' justifyContent='space-between' mb='x14'>
<Box fontScale='p2' color='hint'>
{data?.total} {t('Extensions')}
</Box>
<Box>
<Button primary onClick={(): void => setModal(<AssignAgentModal closeModal={(): void => setModal()} reload={reload} />)}>
{t('Associate_Agent')}
</Button>
</Box>
</Box>
<GenericTable
header={header}
renderRow={renderRow}
results={data?.extensions.map((extension) => ({ _id: extension.extension, ...extension }))}
total={data?.total}
params={params}
setParams={setParams}
// renderFilter={({ onChange, ...props }) => <FilterByText onChange={onChange} {...props} />}
/>
</Page.Content>
);
};
export default VoipExtensionsPage;

@ -1,41 +1,66 @@
import { Field, TextInput, Button, Margins, Box, MultiSelect, Icon, Select } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState, FC, ReactElement } from 'react';
import { useSubscription } from 'use-subscription';
import { IUser } from '../../../../definition/IUser';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import VerticalBar from '../../../components/VerticalBar';
import { useRoute } from '../../../contexts/RouterContext';
import { useMethod } from '../../../contexts/ServerContext';
import { useSetting } from '../../../contexts/SettingsContext';
import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import { useForm } from '../../../hooks/useForm';
import UserInfo from '../../room/contextualBar/UserInfo';
import { formsSubscription } from '../additionalForms';
function AgentEdit({ data, userDepartments, availableDepartments, uid, reset, ...props }) {
// TODO: TYPE:
// Department
type dataType = {
status: string;
user: IUser;
};
type AgentEditProps = {
data: dataType;
userDepartments: { departments: Array<{ departmentId: string }> };
availableDepartments: { departments: Array<{ _id: string; name?: string }> };
uid: string;
reset: () => void;
};
const AgentEdit: FC<AgentEditProps> = ({ data, userDepartments, availableDepartments, uid, reset, ...props }) => {
const t = useTranslation();
const agentsRoute = useRoute('omnichannel-agents');
const [maxChatUnsaved, setMaxChatUnsaved] = useState();
const voipEnabled = useSetting('VoIP_Enabled');
const { user } = data || { user: {} };
const { name, username, statusLivechat } = user;
const email = getUserEmailAddress(user);
const options = useMemo(
const options: [string, string][] = useMemo(
() =>
availableDepartments && availableDepartments.departments
? availableDepartments.departments.map(({ _id, name }) => [_id, name || _id])
: [],
availableDepartments?.departments ? availableDepartments.departments.map(({ _id, name }) => (name ? [_id, name] : [_id, _id])) : [],
[availableDepartments],
);
const initialDepartmentValue = useMemo(
() => (userDepartments && userDepartments.departments ? userDepartments.departments.map(({ departmentId }) => departmentId) : []),
() => (userDepartments?.departments ? userDepartments.departments.map(({ departmentId }) => departmentId) : []),
[userDepartments],
);
const eeForms = useSubscription(formsSubscription);
const saveRef = useRef({ values: {}, hasUnsavedChanges: false });
const saveRef = useRef({
values: {},
hasUnsavedChanges: false,
reset: () => undefined,
commit: () => undefined,
});
const { reset: resetMaxChats, commit: commitMaxChats } = saveRef.current;
const onChangeMaxChats = useMutableCallback(({ hasUnsavedChanges, ...value }) => {
saveRef.current = value;
@ -45,17 +70,21 @@ function AgentEdit({ data, userDepartments, availableDepartments, uid, reset, ..
}
});
const { useMaxChatsPerAgent = () => {} } = eeForms;
const { useMaxChatsPerAgent = (): ReactElement | null => null } = eeForms as any; // TODO: Find out how to use ts with eeForms
const { values, handlers, hasUnsavedChanges, commit } = useForm({
departments: initialDepartmentValue,
status: statusLivechat,
maxChats: 0,
voipExtension: '',
});
const { reset: resetMaxChats, commit: commitMaxChats } = saveRef.current;
const { handleDepartments, handleStatus } = handlers;
const { departments, status } = values;
const { handleDepartments, handleStatus, handleVoipExtension } = handlers;
const { departments, status, voipExtension } = values as {
departments: string[];
status: string;
voipExtension: string;
};
const MaxChats = useMaxChatsPerAgent();
@ -73,11 +102,11 @@ function AgentEdit({ data, userDepartments, availableDepartments, uid, reset, ..
try {
await saveAgentInfo(uid, saveRef.current.values, departments);
await saveAgentStatus({ status, agentId: uid });
dispatchToastMessage({ type: 'success', message: t('saved') });
dispatchToastMessage({ type: 'success', message: t('Success') });
agentsRoute.push({});
reset();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
dispatchToastMessage({ type: 'error', message: error as string });
}
commit();
commitMaxChats();
@ -136,6 +165,15 @@ function AgentEdit({ data, userDepartments, availableDepartments, uid, reset, ..
{MaxChats && <MaxChats data={user} onChange={onChangeMaxChats} />}
{voipEnabled && (
<Field>
<Field.Label>{t('VoIP_Extension')}</Field.Label>
<Field.Row>
<TextInput flexGrow={1} value={voipExtension as string} onChange={handleVoipExtension} />
</Field.Row>
</Field>
)}
<Field.Row>
<Box display='flex' flexDirection='row' justifyContent='space-between' w='full'>
<Margins inlineEnd='x4'>
@ -150,6 +188,6 @@ function AgentEdit({ data, userDepartments, availableDepartments, uid, reset, ..
</Field.Row>
</VerticalBar.ScrollableContent>
);
}
};
export default AgentEdit;

@ -0,0 +1,62 @@
import { Box } from '@rocket.chat/fuselage';
import React, { FC, useMemo } from 'react';
import { IVoipRoom } from '../../../../definition/IRoom';
import VerticalBar from '../../../components/VerticalBar';
import { useRoute, useRouteParameter, useQueryStringParameter } from '../../../contexts/RouterContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import { AsyncStatePhase } from '../../../hooks/useAsyncState';
import { useEndpointData } from '../../../hooks/useEndpointData';
import { FormSkeleton } from './Skeleton';
import Call from './calls/Call';
import { VoipInfo } from './calls/contextualBar/VoipInfo';
const CallsContextualBar: FC = () => {
const directoryRoute = useRoute('omnichannel-directory');
const bar = useRouteParameter('bar') || 'info';
const id = useRouteParameter('id');
const token = useQueryStringParameter('token');
const t = useTranslation();
const handleCallsVerticalBarCloseButtonClick = (): void => {
directoryRoute.push({ page: 'calls' });
};
const query = useMemo(
() => ({
rid: id || '',
token: token || '',
}),
[id, token],
);
const { value: data, phase: state, error } = useEndpointData(`voip/room`, query);
if (bar === 'view' && id) {
return <Call rid={id} />;
}
if (state === AsyncStatePhase.LOADING) {
return (
<Box pi='x24'>
<FormSkeleton />
</Box>
);
}
if (error || !data || !data.room) {
return <Box mbs='x16'>{t('Room_not_found')}</Box>;
}
const room = data.room as unknown as IVoipRoom; // TODO Check why types are incompatible even though the endpoint returns an IVoipRooms
return (
<VerticalBar className={'contextual-bar'}>
{bar === 'info' && <VoipInfo room={room} onClickClose={handleCallsVerticalBarCloseButtonClick} />}
</VerticalBar>
);
};
export default CallsContextualBar;

@ -1,5 +1,5 @@
import { Box } from '@rocket.chat/fuselage';
import React from 'react';
import React, { FC, useMemo } from 'react';
import VerticalBar from '../../../components/VerticalBar';
import { useRoute, useRouteParameter } from '../../../contexts/RouterContext';
@ -11,7 +11,7 @@ import Chat from './chats/Chat';
import ChatInfoDirectory from './chats/contextualBar/ChatInfoDirectory';
import RoomEditWithData from './chats/contextualBar/RoomEditWithData';
const ChatsContextualBar = ({ chatReload }) => {
const ChatsContextualBar: FC<{ chatReload?: () => void }> = ({ chatReload }) => {
const directoryRoute = useRoute('omnichannel-directory');
const bar = useRouteParameter('bar') || 'info';
@ -19,21 +19,28 @@ const ChatsContextualBar = ({ chatReload }) => {
const t = useTranslation();
const openInRoom = () => {
directoryRoute.push({ page: 'chats', id, bar: 'view' });
const openInRoom = (): void => {
id && directoryRoute.push({ page: 'chats', id, bar: 'view' });
};
const handleChatsVerticalBarCloseButtonClick = () => {
const handleChatsVerticalBarCloseButtonClick = (): void => {
directoryRoute.push({ page: 'chats' });
};
const handleChatsVerticalBarBackButtonClick = () => {
directoryRoute.push({ page: 'chats', id, bar: 'info' });
const handleChatsVerticalBarBackButtonClick = (): void => {
id && directoryRoute.push({ page: 'chats', id, bar: 'info' });
};
const { value: data, phase: state, error, reload: reloadInfo } = useEndpointData(`rooms.info?roomId=${id}`);
const query = useMemo(
() => ({
roomId: id || '',
}),
[id],
);
const { value: data, phase: state, error, reload: reloadInfo } = useEndpointData(`rooms.info`, query);
if (bar === 'view') {
if (bar === 'view' && id) {
return <Chat rid={id} />;
}

@ -1,10 +1,16 @@
import React from 'react';
import React, { FC } from 'react';
import { useRouteParameter } from '../../../contexts/RouterContext';
import CallsContextualBar from './CallsContextualBar';
import ChatsContextualBar from './ChatsContextualBar';
import ContactContextualBar from './ContactContextualBar';
const ContextualBar = ({ contactReload, chatReload }) => {
type ContextualBarProps = {
contactReload?: () => void;
chatReload?: () => void;
};
const ContextualBar: FC<ContextualBarProps> = ({ contactReload, chatReload }) => {
const page = useRouteParameter('page');
const bar = useRouteParameter('bar');
@ -17,6 +23,8 @@ const ContextualBar = ({ contactReload, chatReload }) => {
return <ContactContextualBar contactReload={contactReload} />;
case 'chats':
return <ChatsContextualBar chatReload={chatReload} />;
case 'calls':
return <CallsContextualBar />;
default:
return null;
}

@ -1,5 +1,5 @@
import { Tabs } from '@rocket.chat/fuselage';
import React, { useEffect, useCallback, useState } from 'react';
import React, { useEffect, useCallback, useState, ReactElement } from 'react';
import NotAuthorizedPage from '../../../components/NotAuthorizedPage';
import Page from '../../../components/Page';
@ -7,10 +7,11 @@ import { usePermission } from '../../../contexts/AuthorizationContext';
import { useCurrentRoute, useRoute, useRouteParameter } from '../../../contexts/RouterContext';
import { useTranslation } from '../../../contexts/TranslationContext';
import ContextualBar from './ContextualBar';
import CallTab from './calls/CallTab';
import ChatTab from './chats/ChatTab';
import ContactTab from './contacts/ContactTab';
const OmnichannelDirectoryPage = () => {
const OmnichannelDirectoryPage = (): ReactElement => {
const defaultTab = 'contacts';
const [routeName] = useCurrentRoute();
@ -28,7 +29,7 @@ const OmnichannelDirectoryPage = () => {
}
}, [routeName, directoryRoute, tab, defaultTab]);
const handleTabClick = useCallback((tab) => () => directoryRoute.push({ tab }), [directoryRoute]);
const handleTabClick = useCallback((tab) => (): void => directoryRoute.push({ tab }), [directoryRoute]);
const [contactReload, setContactReload] = useState();
const [chatReload, setChatReload] = useState();
@ -48,12 +49,16 @@ const OmnichannelDirectoryPage = () => {
{t('Contacts')}
</Tabs.Item>
<Tabs.Item selected={tab === 'chats'} onClick={handleTabClick('chats')}>
{t('Chats')}
{t('Chats' as 'color')}
</Tabs.Item>
<Tabs.Item selected={tab === 'calls'} onClick={handleTabClick('calls')}>
{t('Calls' as 'color')}
</Tabs.Item>
</Tabs>
<Page.Content>
{(tab === 'contacts' && <ContactTab setContactReload={setContactReload} />) ||
(tab === 'chats' && <ChatTab setChatReload={setChatReload} />)}
(tab === 'chats' && <ChatTab setChatReload={setChatReload} />) ||
(tab === 'calls' && <CallTab />)}
</Page.Content>
</Page>
<ContextualBar chatReload={chatReload} contactReload={contactReload} />

@ -0,0 +1,19 @@
import { Box } from '@rocket.chat/fuselage';
import React, { useEffect, FC } from 'react';
import { openRoom } from '../../../../../app/ui-utils/client/lib/openRoom';
import { IRoom } from '../../../../../definition/IRoom';
import RoomWithData from '../../../room/Room';
const Chat: FC<{ rid: IRoom['_id'] }> = ({ rid }) => {
useEffect(() => {
openRoom('v', rid, false);
}, [rid]);
return (
<Box position='absolute' backgroundColor='surface' width='full' height='full'>
<RoomWithData />
</Box>
);
};
export default Chat;

@ -0,0 +1,19 @@
import React, { ReactElement } from 'react';
import NotAuthorizedPage from '../../../../components/NotAuthorizedPage';
import { usePermission } from '../../../../contexts/AuthorizationContext';
import CallTable from './CallTable';
// TODO Check if I need to type the setstateaction params, if I should do:
// { setCallReload: Dispatch<SetStateAction<(param: () => void) => void>> }
const CallTab = (): ReactElement => {
const hasAccess = usePermission('view-l-room');
if (hasAccess) {
return <CallTable />;
}
return <NotAuthorizedPage />;
};
export default CallTab;

@ -0,0 +1,164 @@
import { Table } from '@rocket.chat/fuselage';
import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import React, { useState, useMemo, useCallback, FC } from 'react';
import GenericTable from '../../../../components/GenericTable';
import { useRoute } from '../../../../contexts/RouterContext';
import { useTranslation } from '../../../../contexts/TranslationContext';
import { useEndpointData } from '../../../../hooks/useEndpointData';
const useQuery = (
{
text,
itemsPerPage,
current,
}: {
text?: string;
itemsPerPage?: 25 | 50 | 100;
current?: number;
},
[column, direction]: string[],
userIdLoggedIn: string | null,
): {
sort: string;
open: boolean;
roomName: string;
agents: string[];
count?: number;
current?: number;
} =>
useMemo(
() => ({
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
open: false,
roomName: text || '',
agents: userIdLoggedIn ? [userIdLoggedIn] : [],
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[column, current, direction, itemsPerPage, userIdLoggedIn, text],
);
const CallTable: FC = () => {
const [params, setParams] = useState<{ text?: string; current?: number; itemsPerPage?: 25 | 50 | 100 }>({
text: '',
current: 0,
itemsPerPage: 25,
});
const [sort, setSort] = useState<[string, 'asc' | 'desc']>(['closedAt', 'desc']);
const t = useTranslation();
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
const userIdLoggedIn = Meteor.userId();
const query = useQuery(debouncedParams, debouncedSort, userIdLoggedIn);
const directoryRoute = useRoute('omnichannel-directory');
const onHeaderClick = useMutableCallback((id) => {
const [sortBy, sortDirection] = sort;
if (sortBy === id) {
setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']);
return;
}
setSort([id, 'asc']);
});
const onRowClick = useMutableCallback((id, token) => {
directoryRoute.push(
{
page: 'calls',
bar: 'info',
id,
},
{ token },
);
});
const { value: data } = useEndpointData('voip/rooms', query);
const header = useMemo(
() =>
[
<GenericTable.HeaderCell
key={'fname'}
direction={sort[1]}
active={sort[0] === 'fname'}
onClick={onHeaderClick}
sort='fname'
w='x400'
>
{t('Contact_Name')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell
key={'phone'}
direction={sort[1]}
active={sort[0] === 'phone'}
onClick={onHeaderClick}
sort='phone'
w='x200'
>
{t('Phone')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'queue'} direction={sort[1]} active={sort[0] === 'queue'} onClick={onHeaderClick} sort='ts' w='x100'>
{t('Queue')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'ts'} direction={sort[1]} active={sort[0] === 'ts'} onClick={onHeaderClick} sort='ts' w='x200'>
{t('Started_At')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell
key={'callDuration'}
direction={sort[1]}
active={sort[0] === 'callDuration'}
onClick={onHeaderClick}
sort='callDuration'
w='x120'
>
{t('Talk_Time')}
</GenericTable.HeaderCell>,
<GenericTable.HeaderCell
key={'source'}
direction={sort[1]}
active={sort[0] === 'source'}
onClick={onHeaderClick}
sort='source'
w='x200'
>
{t('Source')}
</GenericTable.HeaderCell>,
].filter(Boolean),
[sort, onHeaderClick, t],
);
const renderRow = useCallback(
({ _id, fname, callStarted, queue, callDuration, v }) => {
const duration = moment.duration(callDuration / 1000, 'seconds');
return (
<Table.Row key={_id} tabIndex={0} role='link' onClick={(): void => onRowClick(_id, v?.token)} action qa-user-id={_id}>
<Table.Cell withTruncatedText>{fname}</Table.Cell>
<Table.Cell withTruncatedText>{v?.phone}</Table.Cell>
<Table.Cell withTruncatedText>{queue}</Table.Cell>
<Table.Cell withTruncatedText>{moment(callStarted).format('L LTS')}</Table.Cell>
<Table.Cell withTruncatedText>{duration.isValid() && duration.humanize()}</Table.Cell>
<Table.Cell withTruncatedText>{t('Incoming')}</Table.Cell>
</Table.Row>
);
},
[onRowClick, t],
);
return (
<GenericTable
header={header}
renderRow={renderRow}
results={data?.rooms}
total={data?.total}
setParams={setParams}
params={params}
// renderFilter={({ onChange, ...props }: any): ReactElement => <FilterByText onChange={onChange} {...props} />}
/>
);
};
export default CallTable;

@ -0,0 +1,14 @@
import { Box } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
type InfoFieldPropsType = {
label: string;
info: string;
};
export const InfoField = ({ label, info }: InfoFieldPropsType): ReactElement => (
<Box fontScale='p2' mb='14px'>
<Box mbe='8px'>{label}</Box>
<Box color='info'>{info}</Box>
</Box>
);

@ -0,0 +1,65 @@
import { Box } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { ReactElement } from 'react';
import { IVoipRoom } from '../../../../../../definition/IRoom';
import UserCard from '../../../../../components/UserCard';
import { UserStatus } from '../../../../../components/UserStatus';
import VerticalBar from '../../../../../components/VerticalBar';
import UserAvatar from '../../../../../components/avatar/UserAvatar';
import { useTranslation } from '../../../../../contexts/TranslationContext';
import Field from '../../../components/Field';
import Info from '../../../components/Info';
import Label from '../../../components/Label';
import AgentField from '../../chats/contextualBar/AgentField';
import { InfoField } from './InfoField';
type VoipInfoPropsType = {
room: IVoipRoom;
onClickClose: () => void;
};
export const VoipInfo = ({ room, onClickClose }: VoipInfoPropsType): ReactElement => {
const t = useTranslation();
const { servedBy, queue, v, fname, callDuration, callTotalHoldTime, callEndedAt, callWaitingTime } = room;
return (
<>
<VerticalBar.Header>
<VerticalBar.Icon name='phone' />
<VerticalBar.Text>{t('Call_Information')}</VerticalBar.Text>
<VerticalBar.Close onClick={onClickClose} />
</VerticalBar.Header>
<VerticalBar.ScrollableContent>
{/* <InfoField label={t('Channel')} info={} /> */}
{servedBy && <AgentField agent={servedBy} />}
{v && fname && (
<Field>
<Label>{t('Contact')}</Label>
<Info>
<Box display='flex'>
<UserAvatar size='x40' username={fname} />
<UserCard.Username mis='x10' title={fname} name={fname} status={<UserStatus status={v?.status} />} />
</Box>
</Info>
</Field>
)}
{fname && <InfoField label={t('Contact')} info={fname} />}
{v?.phone && <InfoField label={t('Caller_Id')} info={v?.phone} />}
{queue && <InfoField label={t('Queue')} info={queue} />}
{callEndedAt && <InfoField label={t('Last_Call')} info={moment(callEndedAt).format('L LTS')} />}
{callWaitingTime !== undefined && (
<InfoField label={t('Waiting_Time')} info={moment.duration(callWaitingTime / 1000, 'seconds').humanize()} />
)}
{callDuration !== undefined && (
<InfoField label={t('Talk_Time')} info={moment.duration(callDuration / 1000, 'seconds').humanize()} />
)}
{callTotalHoldTime !== undefined && (
<InfoField label={t('Hold_Time')} info={moment.duration(callTotalHoldTime, 'seconds').humanize()} />
)}
{/* <InfoField label={t('Wrap_Up_Note')} info={guest.holdTime} /> */}
</VerticalBar.ScrollableContent>
</>
);
};

@ -1,10 +1,12 @@
import React from 'react';
import React, { ReactElement, SetStateAction, Dispatch } from 'react';
import NotAuthorizedPage from '../../../../components/NotAuthorizedPage';
import { usePermission } from '../../../../contexts/AuthorizationContext';
import ChatTable from './ChatTable';
function ChatTab(props) {
// TODO Check if I need to type the setstateaction params, if I should do:
// { setChatReload: Dispatch<SetStateAction<(param: () => void) => void>> }
const ChatTab = (props: { setChatReload: Dispatch<SetStateAction<any>> }): ReactElement => {
const hasAccess = usePermission('view-l-room');
if (hasAccess) {
@ -12,6 +14,6 @@ function ChatTab(props) {
}
return <NotAuthorizedPage />;
}
};
export default ChatTab;

@ -2,7 +2,7 @@ import { Table, Tag, Box } from '@rocket.chat/fuselage';
import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import React, { useState, useMemo, useCallback, useEffect, FC, ReactElement, Dispatch, SetStateAction } from 'react';
import FilterByText from '../../../../components/FilterByText';
import GenericTable from '../../../../components/GenericTable';
@ -10,22 +10,45 @@ import { useRoute } from '../../../../contexts/RouterContext';
import { useTranslation } from '../../../../contexts/TranslationContext';
import { useEndpointData } from '../../../../hooks/useEndpointData';
const useQuery = ({ text, itemsPerPage, current }, [column, direction], userIdLoggedIn) =>
const useQuery = (
{
text,
itemsPerPage,
current,
}: {
text?: string;
itemsPerPage: 25 | 50 | 100;
current: number;
},
[column, direction]: string[],
userIdLoggedIn: string | null,
): {
sort: string;
open: boolean;
roomName: string;
agents: string[];
count?: number;
current?: number;
} =>
useMemo(
() => ({
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
open: false,
roomName: text,
agents: [userIdLoggedIn],
roomName: text || '',
agents: userIdLoggedIn ? [userIdLoggedIn] : [],
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[column, current, direction, itemsPerPage, userIdLoggedIn, text],
);
const ChatTable = ({ setChatReload }) => {
const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 });
const [sort, setSort] = useState(['closedAt', 'desc']);
const ChatTable: FC<{ setChatReload: Dispatch<SetStateAction<any>> }> = ({ setChatReload }) => {
const [params, setParams] = useState<{ text?: string; current: number; itemsPerPage: 25 | 50 | 100 }>({
text: '',
current: 0,
itemsPerPage: 25,
});
const [sort, setSort] = useState<[string, 'asc' | 'desc']>(['closedAt', 'desc']);
const t = useTranslation();
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
@ -51,10 +74,10 @@ const ChatTable = ({ setChatReload }) => {
}),
);
const { value: data, reload } = useEndpointData('livechat/rooms', query);
const { value: data, reload } = useEndpointData('livechat/rooms', query as any); // TODO: Check the typing for the livechat/rooms endpoint as it seems wrong
useEffect(() => {
setChatReload(() => reload);
setChatReload?.(() => reload);
}, [reload, setChatReload]);
const header = useMemo(
@ -109,13 +132,13 @@ const ChatTable = ({ setChatReload }) => {
const renderRow = useCallback(
({ _id, fname, ts, closedAt, department, tags }) => (
<Table.Row key={_id} tabIndex={0} role='link' onClick={() => onRowClick(_id)} action qa-user-id={_id}>
<Table.Row key={_id} tabIndex={0} role='link' onClick={(): void => onRowClick(_id)} action qa-user-id={_id}>
<Table.Cell withTruncatedText>
<Box display='flex' flexDirection='column'>
<Box withTruncatedText>{fname}</Box>
{tags && (
<Box color='hint' display='flex' flex-direction='row'>
{tags.map((tag) => (
{tags.map((tag: string) => (
<Box
style={{
marginTop: 4,
@ -148,11 +171,11 @@ const ChatTable = ({ setChatReload }) => {
<GenericTable
header={header}
renderRow={renderRow}
results={data && data.rooms}
total={data && data.total}
results={data?.rooms}
total={data?.total}
setParams={setParams}
params={params}
renderFilter={({ onChange, ...props }) => <FilterByText onChange={onChange} {...props} />}
renderFilter={({ onChange, ...props }: any): ReactElement => <FilterByText onChange={onChange} {...props} />}
/>
);
};

@ -23,7 +23,7 @@ import DepartmentField from './DepartmentField';
import PriorityField from './PriorityField';
import VisitorClientInfo from './VisitorClientInfo';
function ChatInfoDirectory({ id, route, room }) {
function ChatInfoDirectory({ id, route = undefined, room }) {
const t = useTranslation();
const formatDateAndTime = useFormatDateAndTime();

@ -0,0 +1,14 @@
import { Box } from '@rocket.chat/fuselage';
import React, { ReactElement } from 'react';
type InfoFieldPropsType = {
label: string;
info?: string | 0;
};
export const InfoField = ({ label, info }: InfoFieldPropsType): ReactElement => (
<Box fontScale='p2' mb='14px'>
<Box mbe='8px'>{label}</Box>
<Box color='info'>{info}</Box>
</Box>
);

@ -0,0 +1,61 @@
// import { Box, Button, ButtonGroup, Icon } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { ReactElement } from 'react';
import { IVoipRoom } from '../../../../../../../definition/IRoom';
import VerticalBar from '../../../../../../components/VerticalBar';
import { useTranslation } from '../../../../../../contexts/TranslationContext';
import AgentField from '../AgentField';
import { InfoField } from './InfoField';
type VoipInfoPropsType = {
room: IVoipRoom;
onClickClose: () => void;
onClickReport: () => void;
onClickCall: () => void;
};
export const VoipInfo = ({ room, onClickClose /* , onClickReport, onClickCall */ }: VoipInfoPropsType): ReactElement => {
const t = useTranslation();
const duration = room.callDuration && moment.duration(room.callDuration / 1000, 'seconds').humanize();
const waiting = room.callWaitingTime && moment.duration(room.callWaitingTime / 1000, 'seconds').humanize();
const hold = room.callTotalHoldTime && moment.duration(room.callTotalHoldTime, 'seconds').humanize();
return (
<>
<VerticalBar.Header>
<VerticalBar.Icon name='phone' />
<VerticalBar.Text>{t('Call_Information')}</VerticalBar.Text>
<VerticalBar.Close onClick={onClickClose} />
</VerticalBar.Header>
<VerticalBar.ScrollableContent>
<InfoField label={t('Contact')} info={room.name || room.fname} />
{room.v.phone && <InfoField label={t('Phone_Number')} info={room.v.phone} />}
{room.queue && <InfoField label={t('Queue')} info={room.queue} />}
{/* {room.lastCall && <InfoField label={t('Last_Call')} info={room.lastCall} />} */}
<InfoField label={t('Waiting_Time')} info={waiting || t('Not_Available')} />
<InfoField label={t('Talk_Time')} info={duration || t('Not_Available')} />
<InfoField label={t('Hold_Time')} info={hold || t('Not_Available')} />
<AgentField agent={room.servedBy} />
</VerticalBar.ScrollableContent>
<VerticalBar.Footer>
{/* TODO: Introduce this buttons [Not part of MVP] */}
{/* <ButtonGroup stretch>
<Button danger onClick={onClickReport}>
<Box display='flex' justifyContent='center' fontSize='p2'>
<Icon name='ban' size='x20' mie='4px' />
{t('Report_Number')}
</Box>
</Button>
<Button onClick={onClickCall}>
<Box display='flex' justifyContent='center' fontSize='p2'>
<Icon name='phone' size='x20' mie='4px' />
{t('Call')}
</Box>
</Button>
</ButtonGroup> */}
</VerticalBar.Footer>
</>
);
};

@ -0,0 +1,20 @@
import React, { ReactElement } from 'react';
import { useVoipRoom } from '../../../../../room/contexts/RoomContext';
import { VoipInfo } from './VoipInfo';
const VoipInfoWithData = ({ tabBar: { close } }: any): ReactElement => {
const room = useVoipRoom();
const onClickReport = (): void => {
// TODO: report
};
const onClickCall = (): void => {
// TODO: Call
};
return <VoipInfo room={room} onClickClose={close} onClickReport={onClickReport} onClickCall={onClickCall} />;
};
export default VoipInfoWithData;

@ -5,6 +5,7 @@ import TemplateHeader from '../../../components/Header';
import { useLayout } from '../../../contexts/LayoutContext';
import DirectRoomHeader from './DirectRoomHeader';
import OmnichannelRoomHeader from './Omnichannel/OmnichannelRoomHeader';
import VoipRoomHeader from './Omnichannel/VoipRoomHeader';
import RoomHeader from './RoomHeader';
const Header = ({ room }) => {
@ -33,6 +34,10 @@ const Header = ({ room }) => {
return <OmnichannelRoomHeader slots={slots} />;
}
if (room.t === 'v') {
return <VoipRoomHeader slots={slots} room={room} />;
}
return <RoomHeader slots={slots} room={room} topic={room.topic} />;
};

@ -0,0 +1,44 @@
import React, { FC, useMemo } from 'react';
import BurgerMenu from '../../../../components/BurgerMenu';
import TemplateHeader from '../../../../components/Header';
import { useLayout } from '../../../../contexts/LayoutContext';
import { useCurrentRoute } from '../../../../contexts/RouterContext';
import { ToolboxActionConfig } from '../../lib/Toolbox';
import { ToolboxContext, useToolboxContext } from '../../lib/Toolbox/ToolboxContext';
import RoomHeader, { RoomHeaderProps } from '../RoomHeader';
import { BackButton } from './BackButton';
const VoipRoomHeader: FC<RoomHeaderProps> = ({ slots: parentSlot, room }) => {
const [name] = useCurrentRoute();
const { isMobile } = useLayout();
const context = useToolboxContext();
const slots = useMemo(
() => ({
...parentSlot,
start: (!!isMobile || name === 'omnichannel-directory') && (
<TemplateHeader.ToolBox>
{isMobile && <BurgerMenu />}
{name === 'omnichannel-directory' && <BackButton />}
</TemplateHeader.ToolBox>
),
}),
[isMobile, name, parentSlot],
);
return (
<ToolboxContext.Provider
value={useMemo(
() => ({
...context,
actions: new Map([...(Array.from(context.actions.entries()) as [string, ToolboxActionConfig][])]),
}),
[context],
)}
>
<RoomHeader slots={slots} room={room} />
</ToolboxContext.Provider>
);
};
export default VoipRoomHeader;

@ -1,6 +1,6 @@
import React, { FC } from 'react';
import { IOmnichannelRoom } from '../../../../definition/IRoom';
import { IRoom } from '../../../../definition/IRoom';
import Header from '../../../components/Header';
import MarkdownText from '../../../components/MarkdownText';
import RoomAvatar from '../../../components/avatar/RoomAvatar';
@ -13,7 +13,7 @@ import Favorite from './icons/Favorite';
import Translate from './icons/Translate';
export type RoomHeaderProps = {
room: IOmnichannelRoom;
room: IRoom;
topic?: string;
slots: {
start?: unknown;

@ -1,7 +1,6 @@
import { Menu, Option, Box } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { memo, ReactNode, useRef, ComponentProps, FC, ReactElement } from 'react';
// import tinykeys from 'tinykeys';
import React, { memo, ReactNode, useRef, ComponentProps, ReactElement } from 'react';
// used to open the menu option by keyboard
import { IRoom } from '../../../../../definition/IRoom';
@ -21,7 +20,7 @@ type ToolBoxProps = {
room?: IRoom;
};
const ToolBox: FC<ToolBoxProps> = ({ className }) => {
const ToolBox = ({ className }: ToolBoxProps): ReactElement => {
const tab = useTab();
const openTabBar = useTabBarOpen();
const { isMobile } = useLayout();
@ -100,7 +99,7 @@ const ToolBox: FC<ToolBoxProps> = ({ className }) => {
aria-keyshortcuts='alt'
tabIndex={-1}
options={hiddenActions}
renderItem={({ value, ...props }): ReactElement | null => value && hiddenActionRenderers.current[value](props)}
renderItem={({ value, ...props }): ReactElement | null => value && (hiddenActionRenderers.current[value](props) as ReactElement)}
/>
)}
</>

@ -7,9 +7,9 @@ import { useMethod } from '../../../../contexts/ServerContext';
import { useSetting } from '../../../../contexts/SettingsContext';
import { useTranslation } from '../../../../contexts/TranslationContext';
const Favorite = ({ room: { _id, f: favorited = false } }) => {
const Favorite = ({ room: { _id, f: favorited = false, t: type } }) => {
const t = useTranslation();
const isFavoritesEnabled = useSetting('Favorite_Rooms');
const isFavoritesEnabled = useSetting('Favorite_Rooms') && ['c', 'p', 'd', 't'].includes(type);
const toggleFavorite = useMethod('toggleFavorite');
const handleFavoriteClick = useMutableCallback(() => {
if (!isFavoritesEnabled) {

@ -1,6 +1,6 @@
import { createContext, useContext } from 'react';
import { IRoom, IOmnichannelRoom, isOmnichannelRoom } from '../../../../definition/IRoom';
import { IRoom, IOmnichannelRoom, isOmnichannelRoom, isVoipRoom, IVoipRoom } from '../../../../definition/IRoom';
export type RoomContextValue = {
rid: IRoom['_id'];
@ -12,9 +12,11 @@ export const RoomContext = createContext<RoomContextValue | null>(null);
export const useRoom = (): IRoom => {
const { room } = useContext(RoomContext) || {};
if (!room) {
throw new Error('use useRoom only inside opened rooms');
}
return room;
};
@ -24,9 +26,24 @@ export const useOmnichannelRoom = (): IOmnichannelRoom => {
if (!room) {
throw new Error('use useRoom only inside opened rooms');
}
if (!isOmnichannelRoom(room)) {
throw new Error('invalid room type');
}
return room;
};
export const useVoipRoom = (): IVoipRoom => {
const { room } = useContext(RoomContext) || {};
if (!room) {
throw new Error('use useRoom only inside opened rooms');
}
if (!isVoipRoom(room)) {
throw new Error('invalid room type');
}
return room;
};

@ -34,6 +34,7 @@ export const useFilesList = (
const roomTypes = {
c: 'channels.files',
l: 'channels.files',
v: 'channels.files',
d: 'im.files',
p: 'groups.files',
} as const;

@ -22,7 +22,7 @@ addAction('user-info', {
});
addAction('contact-profile', {
groups: ['live'],
groups: ['live' /* , 'voip'*/],
id: 'contact-profile',
title: 'Contact_Info',
icon: 'user',

@ -28,7 +28,7 @@ export type ToolboxActionConfig = {
full?: true;
renderOption?: OptionRenderer;
order?: number;
groups: Array<'group' | 'channel' | 'live' | 'direct' | 'direct_multiple' | 'team'>;
groups: Array<'group' | 'channel' | 'live' | 'direct' | 'direct_multiple' | 'team' | 'voip'>;
hotkey?: string;
action?: (e?: MouseEvent<HTMLElement>) => void;
template?: string | FC | LazyExoticComponent<FC<{ rid: string; tabBar: any }>>;

@ -6,6 +6,7 @@ import { ToolboxAction } from '../lib/Toolbox/index';
const groupsDict = {
l: 'live',
v: 'voip',
d: 'direct',
p: 'group',
c: 'channel',

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save