[NEW] Audio and Video calling in Livechat using Jitsi and WebRTC (#23004)

* [NEW] Livechat WebRTC call settings (#22559)

* [NEW] Livechat WebRTC call settings

* [FIX] Omnichannel Call Settings

- Change Livechat call to Video and Audio Call as it will apply to other omnichannels in future
- Change Audio and Video Setting alert to description to be conformant with the other settings
- Remove unrelated changes(base.css) that got induced unknowingly
- Refactor to remove translation for "Jitsi" and "WebRTC" and remove unnecessary dependency on t
- Refactor to add return type of handleClick
- Add/remove related i18n labels

* [FIX] Livechat videoCall api and method

* [FIX] Add migrations for webRTC enabled settings and omnichannel call provider

* [FIX] 'Jitsi' typo

* [NEW] Join call action button (#22689)

* [NEW] WebRTC Call Session

* [IMPROVEMENT] Use API endpoint instead of method, fix handleClick dependency warning

* [FIX] Return updated callStatus, use translation for join call message, remove system logger, make callStatus optional in room interface

* [NEW] Join and End Call Action Message Button for Agent

* [FIX] Use translation for Call ALready Ended toastr, convert actionLink to tsx

* [IMPROVE] Remove redundant callStatus from message collection, Display Call Ended message with call duration

* [REF] Use translation for call ended message, Store danger field in db with other action fields, store actionAlignment field in db

* [NEW] update call status (#22854)

* add code for update call status

* remove fourth param

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

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

* [NEW] P2P WebRTC Connection Establishment (#22847)

* [NEW] WebRTC P2P Connection with Basic Call UI

* [FIX] Set Stream on a stable connection

* [FIX] userId typecheck error

* [REFACTOR]
 - Restore type of userId to string by removing `| undefined`
 - Add translation for visitor does not exist toastr
 - Set visitorId from room object fetched instead of fetching from livechat widget as query param
 - Type Checking

* [FIX] Running startCall 2 times for agent

* [FIX] Call declined Page

* [NEW] Control Buttons - mic, cam, expand and end call (#22928)

* [NEW] Control Buttons - mic, cam, expand and end call

* [REFACTOR] Add an empty file on EOF en18.i18n that was mistakenly removed

* [FIX] UI responsiveness (#22934)

* [NEW] Control Buttons - mic, cam, expand and end call

* [REFACTOR] Add an empty file on EOF en18.i18n that was mistakenly removed

* [FIX] UI Responsiveness

* [REF] Use const and ternary op

* [FIX] Handle decline message action link (#22936)

* [NEW] Control Buttons - mic, cam, expand and end call

* [REFACTOR] Add an empty file on EOF en18.i18n that was mistakenly removed

* [FIX] UI Responsiveness

* [REF] Use const and ternary op

* [FIX] Action Link not updating when call declined

* [FIX] WebRTC_call_declined_message

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

* [REF] Use a single IF statement to handle status

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

* [FIX] Call button visible even when chat is queued (#22943)

* [FIX] add callstatus attribute in room object (#22959)

* [IMPROVE] Attach jitsi link to message object for Livechat  (#22690)

* [Improve] Attach jitsi link to message object for Livechat

(cherry picked from commit c888961da3313de06eaeb0700b7ce0b6371ef469)

* Update WebRTCClass.js

* [NEW] Webrtc meet page layout (#22932)

* [NEW] Control Buttons - mic, cam, expand and end call

* webrtc meet page desgin

* [REFACTOR] Add an empty file on EOF en18.i18n that was mistakenly removed

* [FIX] UI Responsiveness

* [FIX] Action Link not updating when call declined

* webrtc meet page desgin

* [FIX] Remote user avatar screen and video switching

* fix-alert

* fix 2 aletrs

* improve codebase

* make ui responsive

* fix issue

* Add call timer component + minor refactoring

* some css changes

Co-authored-by: Dhruv Jain <dhruv.jain93@gmail.com>
Co-authored-by: murtaza98 <murtaza.patrawala@rocket.chat>
Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>

* Add migration

* Changing files to tsx

* Update default value for setting -Jitsi_Open_New_Window

* Update invalid-livechat-config issue

* Fix typescript errors

* Fix build errors caused by new room prop - webRtcCallStartTime

* Simplify call duration calculation logic

* Add definition PUT method for REST on client

Co-authored-by: Dhruv Jain <51796498+djcruz93@users.noreply.github.com>
Co-authored-by: Deepak Agarwal <deepak710agarwal@gmail.com>
Co-authored-by: Dhruv Jain <dhruv.jain93@gmail.com>
pull/23547/head^2
Murtaza Patrawala 4 years ago committed by GitHub
parent 9771ee67f8
commit 6d7752fd21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 18
      app/livechat/lib/messageTypes.js
  2. 18
      app/livechat/server/api/lib/livechat.js
  3. 110
      app/livechat/server/api/v1/videoCall.js
  4. 24
      app/livechat/server/api/v1/visitor.js
  5. 25
      app/livechat/server/config.ts
  6. 8
      app/livechat/server/lib/Livechat.js
  7. 2
      app/livechat/server/methods/getInitialData.js
  8. 29
      app/models/server/models/Rooms.js
  9. 13
      app/models/server/raw/Subscriptions.ts
  10. 10
      app/notifications/client/lib/Notifications.js
  11. 1
      app/utils/client/lib/RestApiClient.d.ts
  12. 13
      app/utils/client/lib/RestApiClient.js
  13. 3
      app/videobridge/client/tabBar.tsx
  14. 11
      app/videobridge/server/methods/jitsiSetTimeout.js
  15. 2
      app/videobridge/server/settings.ts
  16. 115
      app/webrtc/client/WebRTCClass.js
  17. 27
      app/webrtc/client/actionLink.tsx
  18. 2
      app/webrtc/client/index.js
  19. 26
      app/webrtc/client/tabBar.tsx
  20. 10
      app/webrtc/server/settings.ts
  21. 14
      client/components/Message/Actions/Action.tsx
  22. 23
      client/components/Message/Actions/Actions.tsx
  23. 23
      client/startup/routes.ts
  24. 396
      client/views/meet/CallPage.tsx
  25. 159
      client/views/meet/MeetPage.tsx
  26. 21
      client/views/meet/OngoingCallDuration.tsx
  27. 41
      client/views/meet/styles.css
  28. 19
      client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts
  29. 6
      definition/IRoom.ts
  30. 2
      definition/ISubscription.ts
  31. 19
      packages/rocketchat-i18n/i18n/en.i18n.json
  32. 42
      server/modules/notifications/notifications.module.ts
  33. 1
      server/startup/migrations/index.ts
  34. 25
      server/startup/migrations/v246.ts

@ -1,4 +1,6 @@
import formatDistance from 'date-fns/formatDistance';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import moment from 'moment';
import { MessageTypes } from '../../ui-utils';
@ -81,6 +83,22 @@ MessageTypes.registerType({
message: 'New_videocall_request',
});
MessageTypes.registerType({
id: 'livechat_webrtc_video_call',
render(message) {
if (message.msg === 'ended' && message.webRtcCallEndTs && message.ts) {
return TAPi18n.__('WebRTC_call_ended_message', {
callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)),
endTime: moment(message.webRtcCallEndTs).format('h:mm A'),
});
}
if (message.msg === 'declined' && message.webRtcCallEndTs) {
return TAPi18n.__('WebRTC_call_declined_message');
}
return message.msg;
},
});
MessageTypes.registerType({
id: 'omnichannel_placed_chat_on_hold',
system: true,

@ -1,5 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { LivechatRooms, LivechatVisitors, LivechatDepartment, LivechatTrigger } from '../../../../models/server';
import { EmojiCustom } from '../../../../models/server/raw';
@ -56,6 +57,7 @@ export function findOpenRoom(token, departmentId) {
departmentId: 1,
servedBy: 1,
open: 1,
callStatus: 1,
},
};
@ -101,7 +103,7 @@ export async function settings() {
nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form,
emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form,
displayOfflineForm: initSettings.Livechat_display_offline_form,
videoCall: initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true,
videoCall: initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true,
fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled,
language: initSettings.Language,
transcript: initSettings.Livechat_enable_transcript,
@ -117,10 +119,16 @@ export async function settings() {
color: initSettings.Livechat_title_color,
offlineTitle: initSettings.Livechat_offline_title,
offlineColor: initSettings.Livechat_offline_title_color,
actionLinks: [
{ icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall', params: '' },
{ icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall', params: '' },
],
actionLinks: {
webrtc: [
{ actionLinksAlignment: 'flex-start', i18nLabel: 'Join_call', label: TAPi18n.__('Join_call'), method_id: 'joinLivechatWebRTCCall' },
{ i18nLabel: 'End_call', label: TAPi18n.__('End_call'), method_id: 'endLivechatWebRTCCall', danger: true },
],
jitsi: [
{ icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall' },
{ icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall' },
],
},
},
messages: {
offlineMessage: initSettings.Livechat_offline_message,

@ -1,12 +1,15 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { Random } from 'meteor/random';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { Messages } from '../../../../models';
import { settings as rcSettings } from '../../../../settings';
import { Messages, Rooms } from '../../../../models';
import { settings as rcSettings } from '../../../../settings/server';
import { API } from '../../../../api/server';
import { findGuest, getRoom, settings } from '../lib/livechat';
import { OmnichannelSourceType } from '../../../../../definition/IRoom';
import { hasPermission, canSendMessage } from '../../../../authorization';
import { Livechat } from '../../lib/Livechat';
API.v1.addRoute('livechat/video.call/:token', {
get() {
@ -36,12 +39,12 @@ API.v1.addRoute('livechat/video.call/:token', {
};
const { room } = getRoom({ guest, rid, roomInfo });
const config = Promise.await(settings());
if (!config.theme || !config.theme.actionLinks) {
if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.jitsi) {
throw new Meteor.Error('invalid-livechat-config');
}
Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, {
actionLinks: config.theme.actionLinks,
actionLinks: config.theme.actionLinks.jitsi,
});
let rname;
if (rcSettings.get('Jitsi_URL_Room_Hash')) {
@ -63,3 +66,102 @@ API.v1.addRoute('livechat/video.call/:token', {
}
},
});
API.v1.addRoute('livechat/webrtc.call', { authRequired: true }, {
get() {
try {
check(this.queryParams, {
rid: Match.Maybe(String),
});
if (!hasPermission(this.userId, 'view-l-room')) {
return API.v1.unauthorized();
}
const room = canSendMessage(this.queryParams.rid, {
uid: this.userId,
username: this.user.username,
type: this.user.type,
});
if (!room) {
throw new Meteor.Error('invalid-room');
}
const webrtcCallingAllowed = (rcSettings.get('WebRTC_Enabled') === true) && (rcSettings.get('Omnichannel_call_provider') === 'WebRTC');
if (!webrtcCallingAllowed) {
throw new Meteor.Error('webRTC calling not enabled');
}
const config = Promise.await(settings());
if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.webrtc) {
throw new Meteor.Error('invalid-livechat-config');
}
let { callStatus } = room;
if (!callStatus || callStatus === 'ended' || callStatus === 'declined') {
callStatus = 'ringing';
Promise.await(Rooms.setCallStatusAndCallStartTime(room._id, callStatus));
Promise.await(Messages.createWithTypeRoomIdMessageAndUser(
'livechat_webrtc_video_call',
room._id,
TAPi18n.__('Join_my_room_to_start_the_video_call'),
this.user,
{
actionLinks: config.theme.actionLinks.webrtc,
},
));
}
const videoCall = {
rid: room._id,
provider: 'webrtc',
callStatus,
};
return API.v1.success({ videoCall });
} catch (e) {
return API.v1.failure(e);
}
},
});
API.v1.addRoute('livechat/webrtc.call/:callId', { authRequired: true }, {
put() {
try {
check(this.urlParams, {
callId: String,
});
check(this.bodyParams, {
rid: Match.Maybe(String),
status: Match.Maybe(String),
});
const { callId } = this.urlParams;
const { rid, status } = this.bodyParams;
if (!hasPermission(this.userId, 'view-l-room')) {
return API.v1.unauthorized();
}
const room = canSendMessage(rid, {
uid: this.userId,
username: this.user.username,
type: this.user.type,
});
if (!room) {
throw new Meteor.Error('invalid-room');
}
const call = Promise.await(Messages.findOneById(callId));
if (!call || call.t !== 'livechat_webrtc_video_call') {
throw new Meteor.Error('invalid-callId');
}
Livechat.updateCallStatus(callId, rid, status, this.user);
return API.v1.success({ status });
} catch (e) {
return API.v1.failure(e);
}
},
});

@ -128,6 +128,30 @@ API.v1.addRoute('livechat/visitor/:token/room', { authRequired: true }, {
},
});
API.v1.addRoute('livechat/visitor.callStatus', {
post() {
try {
check(this.bodyParams, {
token: String,
callStatus: String,
rid: String,
callId: String,
});
const { token, callStatus, rid, callId } = this.bodyParams;
const guest = findGuest(token);
if (!guest) {
throw new Meteor.Error('invalid-token');
}
const status = callStatus;
Livechat.updateCallStatus(callId, rid, status, guest);
return API.v1.success({ token, callStatus });
} catch (e) {
return API.v1.failure(e);
}
},
});
API.v1.addRoute('livechat/visitor.status', {
post() {
try {

@ -375,16 +375,6 @@ Meteor.startup(function() {
enableQuery: omnichannelEnabledQuery,
});
this.add('Livechat_videocall_enabled', false, {
type: 'boolean',
group: 'Omnichannel',
section: 'Livechat',
public: true,
i18nLabel: 'Videocall_enabled',
i18nDescription: 'Beta_feature_Depends_on_Video_Conference_to_be_enabled',
enableQuery: [{ _id: 'Jitsi_Enabled', value: true }, omnichannelEnabledQuery],
});
this.add('Livechat_fileupload_enabled', true, {
type: 'boolean',
group: 'Omnichannel',
@ -616,5 +606,20 @@ Meteor.startup(function() {
i18nDescription: 'Time_in_seconds',
enableQuery: omnichannelEnabledQuery,
});
this.add('Omnichannel_call_provider', 'none', {
type: 'select',
public: true,
group: 'Omnichannel',
section: 'Video_and_Audio_Call',
values: [
{ key: 'none', i18nLabel: 'None' },
{ key: 'Jitsi', i18nLabel: 'Jitsi' },
{ key: 'WebRTC', i18nLabel: 'WebRTC' },
],
i18nDescription: 'Feature_depends_on_selected_call_provider_to_be_enabled_from_administration_settings',
i18nLabel: 'Call_provider',
enableQuery: omnichannelEnabledQuery,
});
});
});

@ -514,7 +514,7 @@ export const Livechat = {
'Livechat_offline_success_message',
'Livechat_offline_form_unavailable',
'Livechat_display_offline_form',
'Livechat_videocall_enabled',
'Omnichannel_call_provider',
'Jitsi_Enabled',
'Language',
'Livechat_enable_transcript',
@ -1278,6 +1278,12 @@ export const Livechat = {
};
LivechatVisitors.updateById(contactId, updateUser);
},
updateCallStatus(callId, rid, status, user) {
Rooms.setCallStatus(rid, status);
if (status === 'ended' || status === 'declined') {
return updateMessage({ _id: callId, msg: status, actionLinks: [], webRtcCallEndTs: new Date() }, user);
}
},
};
settings.watch('Livechat_history_monitor_type', (value) => {

@ -75,7 +75,7 @@ Meteor.methods({
info.offlineUnavailableMessage = initSettings.Livechat_offline_form_unavailable;
info.displayOfflineForm = initSettings.Livechat_display_offline_form;
info.language = initSettings.Language;
info.videoCall = initSettings.Livechat_videocall_enabled === true && initSettings.Jitsi_Enabled === true;
info.videoCall = initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true;
info.fileUpload = initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled;
info.transcript = initSettings.Livechat_enable_transcript;
info.transcriptMessage = initSettings.Livechat_transcript_message;

@ -58,6 +58,35 @@ export class Rooms extends Base {
return this.update(query, update);
}
setCallStatus(_id, status) {
const query = {
_id,
};
const update = {
$set: {
callStatus: status,
},
};
return this.update(query, update);
}
setCallStatusAndCallStartTime(_id, status) {
const query = {
_id,
};
const update = {
$set: {
callStatus: status,
webRtcCallStartTime: new Date(),
},
};
return this.update(query, update);
}
findByTokenpass(tokens) {
const query = {
'tokenpass.tokens.token': {

@ -46,7 +46,18 @@ export class SubscriptionsRaw extends BaseRaw<T> {
return this.find(query, options);
}
countByRoomIdAndUserId(rid: string, uid: string): Promise<number> {
findByLivechatRoomIdAndNotUserId(roomId: string, userId: string, options: FindOneOptions<T> = {}): Cursor<T> {
const query = {
rid: roomId,
'servedBy._id': {
$ne: userId,
},
};
return this.find(query, options);
}
countByRoomIdAndUserId(rid: string, uid: string | undefined): Promise<number> {
const query = {
rid,
'u._id': uid,

@ -78,9 +78,9 @@ class Notifications {
return this.streamRoom.on(`${ room }/${ eventName }`, callback);
}
async onUser(eventName, callback) {
await this.streamUser.on(`${ Meteor.userId() }/${ eventName }`, callback);
return () => this.unUser(eventName, callback);
async onUser(eventName, callback, visitorId = null) {
await this.streamUser.on(`${ Meteor.userId() || visitorId }/${ eventName }`, callback);
return () => this.unUser(eventName, callback, visitorId);
}
unAll(callback) {
@ -95,8 +95,8 @@ class Notifications {
return this.streamRoom.removeListener(`${ room }/${ eventName }`, callback);
}
unUser(eventName, callback) {
return this.streamUser.removeListener(`${ Meteor.userId() }/${ eventName }`, callback);
unUser(eventName, callback, visitorId = null) {
return this.streamUser.removeListener(`${ Meteor.userId() || visitorId }/${ eventName }`, callback);
}
}

@ -30,6 +30,7 @@ export declare const APIClient: {
delete<P, R = any>(endpoint: string, params?: Serialized<P>): Promise<Serialized<R>>;
get<P, R = any>(endpoint: string, params?: Serialized<P>): Promise<Serialized<R>>;
post<P, B, R = any>(endpoint: string, params?: Serialized<P>, body?: B): Promise<Serialized<R>>;
put<P, B, R = any>(endpoint: string, params?: Serialized<P>, body?: B): Promise<Serialized<R>>;
upload<P, B, R = any>(
endpoint: string,
params?: Serialized<P>,

@ -23,6 +23,15 @@ export const APIClient = {
return APIClient._jqueryCall('POST', endpoint, params, body);
},
put(endpoint, params, body) {
if (!body) {
body = params;
params = {};
}
return APIClient._jqueryCall('PUT', endpoint, params, body);
},
upload(endpoint, params, formData, xhrOptions) {
if (!formData) {
formData = params;
@ -169,5 +178,9 @@ export const APIClient = {
upload(endpoint, params, formData) {
return APIClient.upload(`v1/${ endpoint }`, params, formData);
},
put(endpoint, params, body) {
return APIClient.put(`v1/${ endpoint }`, params, body);
},
},
};

@ -53,12 +53,13 @@ addAction('video', ({ room }) => {
const enabledChannel = useSetting('Jitsi_Enable_Channels');
const enabledTeams = useSetting('Jitsi_Enable_Teams');
const enabledLiveChat = useSetting('Omnichannel_call_provider') === 'Jitsi';
const groups = useStableArray([
'direct',
'direct_multiple',
'group',
'live',
enabledLiveChat && 'live',
enabledTeams && 'team',
enabledChannel && 'channel',
].filter(Boolean) as ToolboxActionConfig['groups']);

@ -7,6 +7,13 @@ import { metrics } from '../../../metrics/server';
import * as CONSTANTS from '../../constants';
import { canSendMessage } from '../../../authorization/server';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { settings } from '../../../settings';
// TODO: Access Token missing. This is just a partial solution, it doesn't handle access token generation logic as present in this file - client/views/room/contextualBar/Call/Jitsi/CallJitsWithData.js
const resolveJitsiCallUrl = (room) => {
const rname = settings.get('Jitsi_URL_Room_Hash') ? settings.get('uniqueID') + room._id : encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name);
return `${ settings.get('Jitsi_SSL') ? 'https://' : 'http://' }${ settings.get('Jitsi_Domain') }/${ settings.get('Jitsi_URL_Room_Prefix') }${ rname }${ settings.get('Jitsi_URL_Room_Suffix') }`;
};
Meteor.methods({
'jitsi:updateTimeout': (rid, joiningNow = true) => {
@ -43,6 +50,10 @@ Meteor.methods({
actionLinks: [
{ icon: 'icon-videocam', label: TAPi18n.__('Click_to_join'), i18nLabel: 'Click_to_join', method_id: 'joinJitsiCall', params: '' },
],
customFields: {
...room.customFields && { ...room.customFields },
...room.t === 'l' && { jitsiCallUrl: resolveJitsiCallUrl(room) }, // Note: this is just a temporary solution for the jitsi calls to work in Livechat. In future we wish to create specific events for specific to livechat calls (eg: start, accept, decline, end, etc) and this url info will be passed via there
},
});
message.msg = TAPi18n.__('Started_a_video_call');
callbacks.run('afterSaveMessage', message, { ...room, jitsiTimeout: currentTime + CONSTANTS.TIMEOUT });

@ -139,7 +139,7 @@ settingsRegistry.addGroup('Video Conference', function() {
public: true,
});
this.add('Jitsi_Open_New_Window', false, {
this.add('Jitsi_Open_New_Window', true, {
type: 'boolean',
enableQuery: {
_id: 'Jitsi_Enabled',

@ -115,7 +115,7 @@ class WebRTCClass {
@param room {String}
*/
constructor(selfId, room) {
constructor(selfId, room, autoAccept = false) {
this.config = {
iceServers: [],
};
@ -145,15 +145,15 @@ class WebRTCClass {
this.remoteItems = new ReactiveVar([]);
this.remoteItemsById = new ReactiveVar({});
this.callInProgress = new ReactiveVar(false);
this.audioEnabled = new ReactiveVar(true);
this.videoEnabled = new ReactiveVar(true);
this.audioEnabled = new ReactiveVar(false);
this.videoEnabled = new ReactiveVar(false);
this.overlayEnabled = new ReactiveVar(false);
this.screenShareEnabled = new ReactiveVar(false);
this.localUrl = new ReactiveVar();
this.active = false;
this.remoteMonitoring = false;
this.monitor = false;
this.autoAccept = false;
this.autoAccept = autoAccept;
this.navigator = undefined;
const userAgent = navigator.userAgent.toLocaleLowerCase();
@ -169,7 +169,7 @@ class WebRTCClass {
this.screenShareAvailable = ['chrome', 'firefox', 'electron'].includes(this.navigator);
this.media = {
video: false,
video: true,
audio: true,
};
this.transport = new this.TransportClass(this);
@ -498,11 +498,12 @@ class WebRTCClass {
}
const onSuccess = (stream) => {
this.localStream = stream;
!this.audioEnabled.get() && this.disableAudio();
!this.videoEnabled.get() && this.disableVideo();
this.localUrl.set(stream);
this.videoEnabled.set(this.media.video === true);
this.audioEnabled.set(this.media.audio === true);
const { peerConnections } = this;
Object.entries(peerConnections).forEach(([, peerConnection]) => peerConnection.addStream(stream));
document.querySelector('video#localVideo').srcObject = stream;
callback(null, this.localStream);
};
const onError = (error) => {
@ -537,19 +538,10 @@ class WebRTCClass {
setAudioEnabled(enabled = true) {
if (this.localStream != null) {
if (enabled === true && this.media.audio !== true) {
delete this.localStream;
this.media.audio = true;
this.getLocalUserMedia(() => {
this.stopAllPeerConnections();
this.joinCall();
});
} else {
this.localStream.getAudioTracks().forEach(function(audio) {
audio.enabled = enabled;
});
this.audioEnabled.set(enabled);
}
this.localStream.getAudioTracks().forEach(function(audio) {
audio.enabled = enabled;
});
this.audioEnabled.set(enabled);
}
}
@ -561,21 +553,19 @@ class WebRTCClass {
this.setAudioEnabled(true);
}
toggleAudio() {
if (this.audioEnabled.get()) {
return this.disableAudio();
}
return this.enableAudio();
}
setVideoEnabled(enabled = true) {
if (this.localStream != null) {
if (enabled === true && this.media.video !== true) {
delete this.localStream;
this.media.video = true;
this.getLocalUserMedia(() => {
this.stopAllPeerConnections();
this.joinCall();
});
} else {
this.localStream.getVideoTracks().forEach(function(video) {
video.enabled = enabled;
});
this.videoEnabled.set(enabled);
}
this.localStream.getVideoTracks().forEach(function(video) {
video.enabled = enabled;
});
this.videoEnabled.set(enabled);
}
}
@ -610,6 +600,13 @@ class WebRTCClass {
this.setVideoEnabled(true);
}
toggleVideo() {
if (this.videoEnabled.get()) {
return this.disableVideo();
}
return this.enableVideo();
}
stop() {
this.active = false;
this.monitor = false;
@ -663,7 +660,6 @@ class WebRTCClass {
onRemoteCall(data) {
if (this.autoAccept === true) {
goToRoomById(data.room);
Meteor.defer(() => {
this.joinCall({
to: data.from,
@ -735,12 +731,6 @@ class WebRTCClass {
*/
joinCall(data = {}, ...args) {
if (data.media && data.media.audio) {
this.media.audio = data.media.audio;
}
if (data.media && data.media.video) {
this.media.video = data.media.video;
}
data.media = this.media;
this.log('joinCall', [data, ...args]);
this.getLocalUserMedia(() => {
@ -873,6 +863,7 @@ class WebRTCClass {
if (peerConnection.iceConnectionState !== 'closed' && peerConnection.iceConnectionState !== 'failed' && peerConnection.iceConnectionState !== 'disconnected' && peerConnection.iceConnectionState !== 'completed') {
peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate));
}
document.querySelector('video#remoteVideo').srcObject = this.remoteItems.get()[0]?.url;
}
@ -916,27 +907,41 @@ const WebRTC = new class {
this.instancesByRoomId = {};
}
getInstanceByRoomId(rid) {
const subscription = ChatSubscription.findOne({ rid });
if (!subscription) {
return;
}
getInstanceByRoomId(rid, visitorId = null) {
let enabled = false;
switch (subscription.t) {
case 'd':
enabled = settings.get('WebRTC_Enable_Direct');
break;
case 'p':
enabled = settings.get('WebRTC_Enable_Private');
break;
case 'c':
enabled = settings.get('WebRTC_Enable_Channel');
if (!visitorId) {
const subscription = ChatSubscription.findOne({ rid });
if (!subscription) {
return;
}
switch (subscription.t) {
case 'd':
enabled = settings.get('WebRTC_Enable_Direct');
break;
case 'p':
enabled = settings.get('WebRTC_Enable_Private');
break;
case 'c':
enabled = settings.get('WebRTC_Enable_Channel');
break;
case 'l':
enabled = settings.get('Omnichannel_call_provider') === 'WebRTC';
}
} else {
enabled = settings.get('Omnichannel_call_provider') === 'WebRTC';
}
enabled = enabled && settings.get('WebRTC_Enabled');
if (enabled === false) {
return;
}
if (this.instancesByRoomId[rid] == null) {
this.instancesByRoomId[rid] = new WebRTCClass(Meteor.userId(), rid);
let uid = Meteor.userId();
let autoAccept = false;
if (visitorId) {
uid = visitorId;
autoAccept = true;
}
this.instancesByRoomId[rid] = new WebRTCClass(uid, rid, autoAccept);
}
return this.instancesByRoomId[rid];
}

@ -0,0 +1,27 @@
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import toastr from 'toastr';
import { actionLinks } from '../../action-links/client';
import { APIClient } from '../../utils/client';
import { Rooms } from '../../models/client';
import { IMessage } from '../../../definition/IMessage';
import { Notifications } from '../../notifications/client';
actionLinks.register('joinLivechatWebRTCCall', (message: IMessage) => {
const { callStatus, _id } = Rooms.findOne({ _id: message.rid });
if (callStatus === 'declined' || callStatus === 'ended') {
toastr.info(TAPi18n.__('Call_Already_Ended'));
return;
}
window.open(`/meet/${ _id }`, _id);
});
actionLinks.register('endLivechatWebRTCCall', async (message: IMessage) => {
const { callStatus, _id } = Rooms.findOne({ _id: message.rid });
if (callStatus === 'declined' || callStatus === 'ended') {
toastr.info(TAPi18n.__('Call_Already_Ended'));
return;
}
await APIClient.v1.put(`livechat/webrtc.call/${ message._id }`, {}, { rid: _id, status: 'ended' });
Notifications.notifyRoom(_id, 'webrtc', 'callStatus', { callStatus: 'ended' });
});

@ -1,3 +1,5 @@
import './adapter';
import './tabBar';
import './actionLink';
export * from './WebRTCClass';

@ -0,0 +1,26 @@
import { useMemo, useCallback } from 'react';
import { useSetting } from '../../../client/contexts/SettingsContext';
import { addAction } from '../../../client/views/room/lib/Toolbox';
import { APIClient } from '../../utils/client';
addAction('webRTCVideo', ({ room }) => {
const enabled = useSetting('WebRTC_Enabled') && (useSetting('Omnichannel_call_provider') === 'WebRTC') && room.servedBy;
const handleClick = useCallback(async (): Promise<void> => {
if (!room.callStatus || room.callStatus === 'declined' || room.callStatus === 'ended') {
await APIClient.v1.get('livechat/webrtc.call', { rid: room._id });
}
window.open(`/meet/${ room._id }`, room._id);
}, [room._id, room.callStatus]);
return useMemo(() => (enabled ? {
groups: ['live'],
id: 'webRTCVideo',
title: 'WebRTC_Call',
icon: 'phone',
action: handleClick,
full: true,
order: 4,
} : null), [enabled, handleClick]);
});

@ -1,24 +1,34 @@
import { settingsRegistry } from '../../settings/server';
settingsRegistry.addGroup('WebRTC', function() {
this.add('WebRTC_Enabled', false, {
type: 'boolean',
group: 'WebRTC',
public: true,
i18nLabel: 'Enabled',
});
this.add('WebRTC_Enable_Channel', false, {
type: 'boolean',
group: 'WebRTC',
public: true,
enableQuery: { _id: 'WebRTC_Enabled', value: true },
});
this.add('WebRTC_Enable_Private', false, {
type: 'boolean',
group: 'WebRTC',
public: true,
enableQuery: { _id: 'WebRTC_Enabled', value: true },
});
this.add('WebRTC_Enable_Direct', false, {
type: 'boolean',
group: 'WebRTC',
public: true,
enableQuery: { _id: 'WebRTC_Enabled', value: true },
});
return this.add('WebRTC_Servers', 'stun:stun.l.google.com:19302, stun:23.21.150.121, team%40rocket.chat:demo@turn:numb.viagenie.ca:3478', {
type: 'string',
group: 'WebRTC',
public: true,
enableQuery: { _id: 'WebRTC_Enabled', value: true },
});
});

@ -12,6 +12,7 @@ type ActionOptions = {
i18nLabel?: TranslationKey;
label?: string;
runAction?: RunAction;
danger?: boolean;
};
const resolveLegacyIcon = (legacyIcon: string | undefined): string | undefined => {
@ -22,13 +23,22 @@ const resolveLegacyIcon = (legacyIcon: string | undefined): string | undefined =
return legacyIcon && legacyIcon.replace(/^icon-/, '');
};
const Action: FC<ActionOptions> = ({ id, icon, i18nLabel, label, mid, runAction }) => {
const Action: FC<ActionOptions> = ({ id, icon, i18nLabel, label, mid, runAction, danger }) => {
const t = useTranslation();
const resolvedIcon = resolveLegacyIcon(icon);
return (
<Button id={id} data-mid={mid} data-actionlink={id} onClick={runAction} primary small>
<Button
id={id}
data-mid={mid}
data-actionlink={id}
onClick={runAction}
marginInline='x4'
primary
small
danger={danger}
>
{icon && <Icon name={resolvedIcon} />}
{i18nLabel ? t(i18nLabel) : label}
</Button>

@ -14,19 +14,24 @@ type ActionOptions = {
i18nLabel?: TranslationKey;
label?: string;
runAction?: RunAction;
actionLinksAlignment?: string;
};
const Actions: FC<{ actions: Array<ActionOptions>; runAction: RunAction; mid: string }> = ({
actions,
runAction,
}) => (
<Content width='full' justifyContent='center'>
<ButtonGroup align='center'>
{actions.map((action) => (
<Action runAction={runAction} key={action.id} {...action} />
))}
</ButtonGroup>
</Content>
);
}) => {
const alignment = actions[0]?.actionLinksAlignment || 'center';
return (
<Content width='full' justifyContent={alignment}>
<ButtonGroup align='center'>
{actions.map((action) => (
<Action runAction={runAction} key={action.id} {...action} />
))}
</ButtonGroup>
</Content>
);
};
export default Actions;

@ -1,10 +1,13 @@
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { Session } from 'meteor/session';
import { Tracker } from 'meteor/tracker';
import { lazy } from 'react';
import toastr from 'toastr';
import { KonchatNotification } from '../../app/ui/client';
import { APIClient } from '../../app/utils/client';
import { IUser } from '../../definition/IUser';
import { appLayout } from '../lib/appLayout';
import { createTemplateForComponent } from '../lib/portals/createTemplateForComponent';
@ -14,6 +17,7 @@ import { handleError } from '../lib/utils/handleError';
const SetupWizardRoute = lazy(() => import('../views/setupWizard/SetupWizardRoute'));
const MailerUnsubscriptionPage = lazy(() => import('../views/mailer/MailerUnsubscriptionPage'));
const NotFoundPage = lazy(() => import('../views/notFound/NotFoundPage'));
const MeetPage = lazy(() => import('../views/meet/MeetPage'));
FlowRouter.wait();
@ -50,6 +54,25 @@ FlowRouter.route('/login', {
},
});
FlowRouter.route('/meet/:rid', {
name: 'meet',
async action(_params, queryParams) {
if (queryParams?.token !== undefined) {
// visitor login
const visitor = await APIClient.v1.get(`/livechat/visitor/${queryParams?.token}`);
if (visitor?.visitor) {
return appLayout.render({ component: MeetPage });
}
return toastr.error(TAPi18n.__('Visitor_does_not_exist'));
}
if (!Meteor.userId()) {
FlowRouter.go('home');
}
appLayout.render({ component: MeetPage });
},
});
FlowRouter.route('/home', {
name: 'home',

@ -0,0 +1,396 @@
import { Box, Flex, ButtonGroup, Button, Icon } from '@rocket.chat/fuselage';
import moment from 'moment';
import React, { FC, useEffect, useState } from 'react';
import { Notifications } from '../../../app/notifications/client';
import { WebRTC } from '../../../app/webrtc/client';
import { WEB_RTC_EVENTS } from '../../../app/webrtc/index';
import UserAvatar from '../../components/avatar/UserAvatar';
import { useTranslation } from '../../contexts/TranslationContext';
import OngoingCallDuration from './OngoingCallDuration';
import './styles.css';
type CallPageProps = {
roomId: any;
visitorToken: any;
visitorId: any;
status: any;
setStatus: any;
layout: any;
visitorName: any;
agentName: any;
callStartTime: any;
};
const CallPage: FC<CallPageProps> = ({
roomId,
visitorToken,
visitorId,
status,
setStatus,
layout,
visitorName,
agentName,
callStartTime,
}) => {
const [isAgentActive, setIsAgentActive] = useState(false);
const [isMicOn, setIsMicOn] = useState(false);
const [isCameraOn, setIsCameraOn] = useState(false);
const [isRemoteMobileDevice, setIsRemoteMobileDevice] = useState(false);
const [callInIframe, setCallInIframe] = useState(false);
const [isRemoteCameraOn, setIsRemoteCameraOn] = useState(false);
const [isLocalMobileDevice, setIsLocalMobileDevice] = useState(false);
let iconSize = 'x21';
let buttonSize = 'x40';
const avatarSize = 'x48';
if (layout === 'embedded') {
iconSize = 'x19';
buttonSize = 'x35';
}
const t = useTranslation();
useEffect(() => {
if (visitorToken) {
const webrtcInstance = WebRTC.getInstanceByRoomId(roomId, visitorId);
const isMobileDevice = (): boolean => {
if (layout === 'embedded') {
setCallInIframe(true);
}
if (window.innerWidth <= 450 && window.innerHeight >= 629 && window.innerHeight <= 900) {
setIsLocalMobileDevice(true);
webrtcInstance.media = {
audio: true,
video: {
width: { ideal: 440 },
height: { ideal: 580 },
},
};
return true;
}
return false;
};
Notifications.onUser(
WEB_RTC_EVENTS.WEB_RTC,
(type: any, data: any) => {
if (data.room == null) {
return;
}
webrtcInstance.onUserStream(type, data);
},
visitorId,
);
Notifications.onRoom(roomId, 'webrtc', (type: any, data: any) => {
if (type === 'callStatus' && data.callStatus === 'ended') {
webrtcInstance.stop();
setStatus(data.callStatus);
} else if (type === 'getDeviceType') {
Notifications.notifyRoom(roomId, 'webrtc', 'deviceType', {
isMobileDevice: isMobileDevice(),
});
} else if (type === 'cameraStatus') {
setIsRemoteCameraOn(data.isCameraOn);
}
});
Notifications.notifyRoom(roomId, 'webrtc', 'deviceType', {
isMobileDevice: isMobileDevice(),
});
Notifications.notifyRoom(roomId, 'webrtc', 'callStatus', { callStatus: 'inProgress' });
} else if (!isAgentActive) {
const webrtcInstance = WebRTC.getInstanceByRoomId(roomId);
if (status === 'inProgress') {
Notifications.notifyRoom(roomId, 'webrtc', 'getDeviceType');
webrtcInstance.startCall({
audio: true,
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
},
});
}
Notifications.onRoom(roomId, 'webrtc', (type: any, data: any) => {
if (type === 'callStatus') {
switch (data.callStatus) {
case 'ended':
webrtcInstance.stop();
break;
case 'inProgress':
webrtcInstance.startCall({
audio: true,
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
},
});
}
setStatus(data.callStatus);
} else if (type === 'deviceType' && data.isMobileDevice) {
setIsRemoteMobileDevice(true);
} else if (type === 'cameraStatus') {
setIsRemoteCameraOn(data.isCameraOn);
}
});
setIsAgentActive(true);
}
}, [isAgentActive, status, setStatus, visitorId, roomId, visitorToken, layout]);
const toggleButton = (control: any): any => {
if (control === 'mic') {
WebRTC.getInstanceByRoomId(roomId, visitorToken).toggleAudio();
return setIsMicOn(!isMicOn);
}
WebRTC.getInstanceByRoomId(roomId, visitorToken).toggleVideo();
setIsCameraOn(!isCameraOn);
Notifications.notifyRoom(roomId, 'webrtc', 'cameraStatus', { isCameraOn: !isCameraOn });
};
const closeWindow = (): void => {
if (layout === 'embedded') {
return (parent as any)?.handleIframeClose();
}
return window.close();
};
const getCallDuration = (callStartTime: any): any =>
moment.duration(moment(new Date()).diff(moment(callStartTime))).asSeconds();
const showCallPage = (localAvatar: any, remoteAvatar: any): any => (
<Flex.Container direction='column' justifyContent='center'>
<Box
width='full'
minHeight='sh'
alignItems='center'
backgroundColor='neutral-900'
overflow='hidden'
position='relative'
>
<Box
position='absolute'
zIndex={1}
style={{
top: '5%',
right: '2%',
}}
className='Self_Video'
alignItems='center'
backgroundColor='#2F343D'
>
<video
id='localVideo'
autoPlay
playsInline
muted
style={{
width: '100%',
transform: 'scaleX(-1)',
display: isCameraOn ? 'block' : 'none',
}}
></video>
<UserAvatar
style={{
display: isCameraOn ? 'none' : 'block',
margin: 'auto',
}}
username={localAvatar}
className='rcx-message__avatar'
size={isLocalMobileDevice || callInIframe ? 'x32' : 'x48'}
/>
</Box>
<ButtonGroup
position='absolute'
zIndex={1}
style={{
bottom: '5%',
}}
>
<Button
id='mic'
square
data-title={isMicOn ? t('Mute_microphone') : t('Unmute_microphone')}
onClick={(): any => toggleButton('mic')}
className={isMicOn ? 'On' : 'Off'}
size={Number(buttonSize)}
>
{isMicOn ? (
<Icon name='mic' size={iconSize} />
) : (
<Icon name='mic-off' size={iconSize} />
)}
</Button>
<Button
id='camera'
square
data-title={isCameraOn ? t('Turn_off_video') : t('Turn_on_video')}
onClick={(): void => toggleButton('camera')}
className={isCameraOn ? 'On' : 'Off'}
size={parseInt(buttonSize)}
>
{isCameraOn ? (
<Icon name='video' size={iconSize} />
) : (
<Icon name='video-off' size={iconSize} />
)}
</Button>
{layout === 'embedded' && (
<Button
square
backgroundColor='#2F343D'
borderColor='#2F343D'
data-title={t('Expand_view')}
onClick={(): void => (parent as any)?.expandCall()}
size={parseInt(buttonSize)}
>
<Icon name='arrow-expand' size={iconSize} color='white' />
</Button>
)}
<Button
square
primary
danger
data-title={t('End_call')}
onClick={closeWindow}
size={parseInt(buttonSize)}
>
<Icon name='phone-off' size={iconSize} color='white' />
</Button>
</ButtonGroup>
<video
id='remoteVideo'
autoPlay
playsInline
style={{
width: isRemoteMobileDevice ? '45%' : '100%',
transform: 'scaleX(-1)',
display: isRemoteCameraOn ? 'block' : 'none',
}}
></video>
<Box
position='absolute'
zIndex={1}
display={isRemoteCameraOn ? 'none' : 'flex'}
justifyContent='center'
flexDirection='column'
alignItems='center'
style={{
top: isRemoteMobileDevice || isLocalMobileDevice ? '10%' : '30%',
}}
>
<UserAvatar
style={{
display: 'block',
margin: 'auto',
}}
username={remoteAvatar}
className='rcx-message__avatar'
size={!callInIframe ? 'x124' : avatarSize}
/>
<Box color='white' fontSize={callInIframe ? 12 : 18} textAlign='center' margin={3}>
<OngoingCallDuration counter={getCallDuration(callStartTime)} />
</Box>
<Box
style={{
color: 'white',
fontSize: callInIframe ? 12 : 22,
margin: callInIframe ? 5 : 9,
...(callInIframe && { marginTop: 0 }),
}}
>
{remoteAvatar}
</Box>
</Box>
</Box>
</Flex.Container>
);
return (
<>
{status === 'ringing' && (
<Flex.Container direction='column' justifyContent='center'>
<Box
width='full'
minHeight='sh'
alignItems='center'
backgroundColor='neutral-900'
overflow='hidden'
position='relative'
>
<Box
position='absolute'
zIndex={1}
style={{
top: '5%',
right: '2%',
}}
className='Self_Video'
backgroundColor='#2F343D'
alignItems='center'
>
<UserAvatar
style={{
display: 'block',
margin: 'auto',
}}
username={agentName}
className='rcx-message__avatar'
size={isLocalMobileDevice ? 'x32' : 'x48'}
/>
</Box>
<Box
position='absolute'
zIndex={1}
style={{
top: '20%',
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
}}
alignItems='center'
>
<UserAvatar
style={{
display: 'block',
margin: 'auto',
}}
username={visitorName}
className='rcx-message__avatar'
size='x124'
/>
<Box color='white' fontSize={16} margin={15}>
{'Calling...'}
</Box>
<Box
style={{
color: 'white',
fontSize: isLocalMobileDevice ? 15 : 22,
}}
>
{visitorName}
</Box>
</Box>
</Box>
</Flex.Container>
)}
{status === 'declined' && (
<Box
minHeight='90%'
display='flex'
justifyContent='center'
alignItems='center'
color='white'
fontSize='s1'
>
{t('Call_declined')}
</Box>
)}
{status === 'inProgress' && (
<Flex.Container direction='column' justifyContent='center'>
{visitorToken
? showCallPage(visitorName, agentName)
: showCallPage(agentName, visitorName)}
</Flex.Container>
)}
</>
);
};
export default CallPage;

@ -0,0 +1,159 @@
import { Button, Box, Icon, Flex } from '@rocket.chat/fuselage';
import { Meteor } from 'meteor/meteor';
import React, { useEffect, useState, useCallback, FC } from 'react';
import { APIClient } from '../../../app/utils/client';
import UserAvatar from '../../components/avatar/UserAvatar';
import { useRouteParameter, useQueryStringParameter } from '../../contexts/RouterContext';
import NotFoundPage from '../notFound/NotFoundPage';
import PageLoading from '../root/PageLoading';
import CallPage from './CallPage';
import './styles.css';
const MeetPage: FC = () => {
const [isRoomMember, setIsRoomMember] = useState(false);
const [status, setStatus] = useState(null);
const [visitorId, setVisitorId] = useState(null);
const roomId = useRouteParameter('rid');
const visitorToken = useQueryStringParameter('token');
const layout = useQueryStringParameter('layout');
const [visitorName, setVisitorName] = useState('');
const [agentName, setAgentName] = useState('');
const [callStartTime, setCallStartTime] = useState(undefined);
const isMobileDevice = (): boolean => window.innerWidth <= 450;
const closeCallTab = (): void => window.close();
const setupCallForVisitor = useCallback(async () => {
const room = await APIClient.v1.get(`/livechat/room?token=${visitorToken}&rid=${roomId}`);
if (room?.room?.v?.token === visitorToken) {
setVisitorId(room.room.v._id);
setVisitorName(room.room.fname);
room?.room?.responseBy?.username
? setAgentName(room.room.responseBy.username)
: setAgentName(room.room.servedBy.username);
setStatus(room?.room?.callStatus || 'ended');
setCallStartTime(room.room.webRtcCallStartTime);
return setIsRoomMember(true);
}
}, [visitorToken, roomId]);
const setupCallForAgent = useCallback(async () => {
const room = await APIClient.v1.get(`/rooms.info?roomId=${roomId}`);
if (room?.room?.servedBy?._id === Meteor.userId()) {
setVisitorName(room.room.fname);
room?.room?.responseBy?.username
? setAgentName(room.room.responseBy.username)
: setAgentName(room.room.servedBy.username);
setStatus(room?.room?.callStatus || 'ended');
setCallStartTime(room.room.webRtcCallStartTime);
return setIsRoomMember(true);
}
}, [roomId]);
useEffect(() => {
if (visitorToken) {
setupCallForVisitor();
return;
}
setupCallForAgent();
}, [setupCallForAgent, setupCallForVisitor, visitorToken]);
if (status === null) {
return <PageLoading></PageLoading>;
}
if (!isRoomMember) {
return <NotFoundPage></NotFoundPage>;
}
if (status === 'ended') {
return (
<Flex.Container direction='column' justifyContent='center'>
<Box
width='full'
minHeight='sh'
alignItems='center'
backgroundColor='neutral-900'
overflow='hidden'
position='relative'
>
<Box
position='absolute'
style={{
top: '5%',
right: '2%',
}}
className='Self_Video'
backgroundColor='#2F343D'
alignItems='center'
>
<UserAvatar
style={{
display: 'block',
margin: 'auto',
}}
username={visitorToken ? visitorName : agentName}
className='rcx-message__avatar'
size={isMobileDevice() ? 'x32' : 'x48'}
/>
</Box>
<Box
position='absolute'
zIndex={1}
style={{
top: isMobileDevice() ? '30%' : '20%',
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
}}
alignItems='center'
>
<UserAvatar
style={{
display: 'block',
margin: 'auto',
}}
username={visitorToken ? agentName : visitorName}
className='rcx-message__avatar'
size='x124'
/>
<p style={{ color: 'white', fontSize: 16, margin: 15 }}>{'Call Ended!'}</p>
<p
style={{
color: 'white',
fontSize: isMobileDevice() ? 15 : 22,
}}
>
{visitorToken ? agentName : visitorName}
</p>
</Box>
<Box position='absolute' alignItems='center' style={{ bottom: '20%' }}>
<Button
square
title='Close Window'
onClick={closeCallTab}
backgroundColor='#2F343D'
borderColor='#2F343D'
>
<Icon name='cross' size='x16' color='white' />
</Button>
</Box>
</Box>
</Flex.Container>
);
}
return (
<CallPage
roomId={roomId}
status={status}
visitorToken={visitorToken}
visitorId={visitorId}
setStatus={setStatus}
visitorName={visitorName}
agentName={agentName}
layout={layout}
callStartTime={callStartTime}
/>
);
};
export default MeetPage;

@ -0,0 +1,21 @@
import { Box } from '@rocket.chat/fuselage';
import React, { FC, useEffect, useState } from 'react';
type OngoingCallDurationProps = {
counter: number;
};
const OngoingCallDuration: FC<OngoingCallDurationProps> = ({ counter: defaultCounter = 0 }) => {
const [counter, setCounter] = useState(defaultCounter);
useEffect(() => {
setTimeout(() => setCounter(counter + 1), 1000);
}, [counter]);
return (
<Box color='#E4E7EA' textAlign='center'>
{new Date(counter * 1000).toISOString().substr(11, 8)}
</Box>
);
};
export default OngoingCallDuration;

@ -0,0 +1,41 @@
.Off {
color: #ffffff !important;
border-color: #2f343d !important;
background-color: #2f343d !important;
}
.On {
color: #000000 !important;
border-color: #ffffff !important;
background-color: #ffffff !important;
}
.Self_Video {
display: flex;
width: 15%;
height: 17.5%;
justify-content: center;
}
@media (max-width: 900px) and (min-height: 500px) {
.Self_Video {
width: 30%;
height: 20%;
}
}
@media (max-width: 900px) and (max-height: 500px) {
.Self_Video {
width: 30%;
height: 35%;
}
}
@media (min-width: 901px) and (max-width: 1300px) and (max-height: 500px) {
.Self_Video {
width: 20%;
height: 40%;
}
}

@ -41,14 +41,17 @@ export const useTeamsChannelList = (
});
return {
items: rooms.map(({ _updatedAt, lastMessage, lm, ts, jitsiTimeout, ...room }) => ({
jitsiTimeout: new Date(jitsiTimeout),
...(lm && { lm: new Date(lm) }),
...(ts && { ts: new Date(ts) }),
_updatedAt: new Date(_updatedAt),
...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }),
...room,
})),
items: rooms.map(
({ _updatedAt, lastMessage, lm, ts, jitsiTimeout, webRtcCallStartTime, ...room }) => ({
jitsiTimeout: new Date(jitsiTimeout),
...(lm && { lm: new Date(lm) }),
...(ts && { ts: new Date(ts) }),
_updatedAt: new Date(_updatedAt),
...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }),
...(webRtcCallStartTime && { webRtcCallStartTime: new Date(webRtcCallStartTime) }),
...room,
}),
),
itemCount: total,
};
},

@ -3,6 +3,7 @@ import { IMessage } from './IMessage';
import { IUser, Username } from './IUser';
type RoomType = 'c' | 'd' | 'p' | 'l';
type CallStatus = 'ringing' | 'ended' | 'declined' | 'ongoing';
export type RoomID = string;
export type ChannelName = string;
@ -32,6 +33,11 @@ export interface IRoom extends IRocketChatRecord {
lm?: Date;
usersCount: number;
jitsiTimeout: Date;
callStatus?: CallStatus;
webRtcCallStartTime?: Date;
servedBy?: {
_id: string;
};
streamingOptions?: {
id?: string;

@ -7,6 +7,7 @@ type RoomID = string;
export interface ISubscription extends IRocketChatRecord {
u: Pick<IUser, '_id' | 'username' | 'name'>;
v?: Pick<IUser, '_id' | 'username' | 'name'>;
rid: RoomID;
open: boolean;
ts: Date;
@ -57,7 +58,6 @@ export interface ISubscription extends IRocketChatRecord {
ignored?: unknown;
department?: unknown;
v?: unknown;
}
export interface IOmnichannelSubscription extends ISubscription {

@ -701,6 +701,9 @@
"By_author": "By __author__",
"cache_cleared": "Cache cleared",
"Call": "Call",
"Call_declined": "Call Declined!",
"Call_provider": "Call Provider",
"Call_Already_Ended": "Call Already Ended",
"call-management": "Call Management",
"call-management_description": "Permission to start a meeting",
"Caller": "Caller",
@ -1609,6 +1612,8 @@
"Encryption_key_saved_successfully": "Your encryption key was saved successfully.",
"EncryptionKey_Change_Disabled": "You can't set a password for your encryption key because your private key is not present on this client. In order to set a new password you need load your private key using your existing password or use a client where the key is already loaded.",
"End": "End",
"End_call": "End call",
"Expand_view": "Expand view",
"End_OTR": "End OTR",
"Engagement_Dashboard": "Engagement Dashboard",
"Enter": "Enter",
@ -1836,6 +1841,7 @@
"Favorite": "Favorite",
"Favorite_Rooms": "Enable Favorite Rooms",
"Favorites": "Favorites",
"Feature_depends_on_selected_call_provider_to_be_enabled_from_administration_settings": "This feature depends on the above selected call provider to be enabled from the administration settings.",
"Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled": "This feature depends on \"Send Visitor Navigation History as a Message\" to be enabled.",
"Features": "Features",
"Features_Enabled": "Features Enabled",
@ -2339,12 +2345,14 @@
"Jitsi_Limit_Token_To_Room": "Limit token to Jitsi Room",
"Job_Title": "Job Title",
"join": "Join",
"Join_call": "Join Call",
"Join_audio_call": "Join audio call",
"Join_Chat": "Join Chat",
"Join_default_channels": "Join default channels",
"Join_the_Community": "Join the Community",
"Join_the_given_channel": "Join the given channel",
"Join_video_call": "Join video call",
"Join_my_room_to_start_the_video_call": "Join my room to start the video call",
"join-without-join-code": "Join Without Join Code",
"join-without-join-code_description": "Permission to bypass the join code in channels with join code enabled",
"Joined": "Joined",
@ -2669,6 +2677,7 @@
"Livechat_Triggers": "Livechat Triggers",
"Livechat_user_sent_chat_transcript_to_visitor": "__agent__ sent the chat transcript to __guest__",
"Livechat_Users": "Omnichannel Users",
"Livechat_Calls": "Livechat Calls",
"Livechat_visitor_email_and_transcript_email_do_not_match": "Visitor's email and transcript's email do not match",
"Livechat_visitor_transcript_request": "__guest__ requested the chat transcript",
"LiveStream & Broadcasting": "LiveStream & Broadcasting",
@ -3011,6 +3020,7 @@
"Mute_Group_Mentions": "Mute @all and @here mentions",
"Mute_someone_in_room": "Mute someone in the room",
"Mute_user": "Mute user",
"Mute_microphone": "Mute Microphone",
"mute-user": "Mute User",
"mute-user_description": "Permission to mute other users in the same channel",
"Muted": "Muted",
@ -4287,6 +4297,8 @@
"Tuesday": "Tuesday",
"Turn_OFF": "Turn OFF",
"Turn_ON": "Turn ON",
"Turn_on_video": "Turn on video",
"Turn_off_video": "Turn off video",
"Two Factor Authentication": "Two Factor Authentication",
"Two-factor_authentication": "Two-factor authentication via TOTP",
"Two-factor_authentication_disabled": "Two-factor authentication disabled",
@ -4348,6 +4360,7 @@
"Unread_Rooms_Mode": "Unread Rooms Mode",
"Unread_Tray_Icon_Alert": "Unread Tray Icon Alert",
"Unstar_Message": "Remove Star",
"Unmute_microphone": "Unmute Microphone",
"Update": "Update",
"Update_EnableChecker": "Enable the Update Checker",
"Update_EnableChecker_Description": "Checks automatically for new updates / important messages from the Rocket.Chat developers and receives notifications when available. The notification appears once per new version as a clickable banner and as a message from the Rocket.Cat bot, both visible only for administrators.",
@ -4529,7 +4542,7 @@
"Video_Conference": "Video Conference",
"Video_message": "Video message",
"Videocall_declined": "Video Call Declined.",
"Videocall_enabled": "Video Call Enabled",
"Video_and_Audio_Call": "Video and Audio Call",
"Videos": "Videos",
"View_All": "View All Members",
"View_channels": "View Channels",
@ -4608,6 +4621,7 @@
"Visitor_message": "Visitor Messages",
"Visitor_Name": "Visitor Name",
"Visitor_Name_Placeholder": "Please enter a visitor name...",
"Visitor_does_not_exist": "Visitor does not exist!",
"Visitor_Navigation": "Visitor Navigation",
"Visitor_page_URL": "Visitor page URL",
"Visitor_time_on_site": "Visitor time on site",
@ -4635,6 +4649,7 @@
"Webhook_Details": "WebHook Details",
"Webhook_URL": "Webhook URL",
"Webhooks": "Webhooks",
"WebRTC_Call": "WebRTC Call",
"WebRTC_direct_audio_call_from_%s": "Direct audio call from %s",
"WebRTC_direct_video_call_from_%s": "Direct video call from %s",
"WebRTC_Enable_Channel": "Enable for Public Channels",
@ -4645,6 +4660,8 @@
"WebRTC_monitor_call_from_%s": "Monitor call from %s",
"WebRTC_Servers": "STUN/TURN Servers",
"WebRTC_Servers_Description": "A list of STUN and TURN servers separated by comma.<br/>Username, password and port are allowed in the format `username:password@stun:host:port` or `username:password@turn:host:port`.",
"WebRTC_call_ended_message": "<i class=\"icon-phone\"></i> Call ended at __endTime__ - Lasted __callDuration__",
"WebRTC_call_declined_message": "<i class=\"icon-phone\"></i> Call Declined by Contact.",
"Website": "Website",
"Wednesday": "Wednesday",
"Weekly_Active_Users": "Weekly Active Users",

@ -169,7 +169,11 @@ export class NotificationsModule {
this.streamRoom.allowRead(async function(eventName, extraData): Promise<boolean> {
const [rid] = eventName.split('/');
const [rid, e] = eventName.split('/');
if (e === 'webrtc') {
return true;
}
// typing from livechat widget
if (extraData?.token) {
@ -214,7 +218,7 @@ export class NotificationsModule {
return user[key] === username;
} catch (e) {
SystemLogger.error(e);
SystemLogger.error('Error: ', e);
return false;
}
}
@ -251,21 +255,41 @@ export class NotificationsModule {
this.streamRoomUsers.allowRead('none');
this.streamRoomUsers.allowWrite(async function(eventName, ...args) {
if (!this.userId) {
return false;
}
const [roomId, e] = eventName.split('/');
if (await Subscriptions.countByRoomIdAndUserId(roomId, this.userId) > 0) {
if (!this.userId) {
const room = await Rooms.findOneById<IOmnichannelRoom>(roomId, { projection: { t: 1, 'servedBy._id': 1 } });
if (room && room.t === 'l' && e === 'webrtc' && room.servedBy) {
notifyUser(room.servedBy._id, e, ...args);
return false;
}
} else if (await Subscriptions.countByRoomIdAndUserId(roomId, this.userId) > 0) {
const livechatSubscriptions: ISubscription[] = await Subscriptions.findByLivechatRoomIdAndNotUserId(roomId, this.userId, { projection: { 'v._id': 1, _id: 0 } }).toArray();
if (livechatSubscriptions && e === 'webrtc') {
livechatSubscriptions.forEach((subscription) => subscription.v && notifyUser(subscription.v._id, e, ...args));
return false;
}
const subscriptions: ISubscription[] = await Subscriptions.findByRoomIdAndNotUserId(roomId, this.userId, { projection: { 'u._id': 1, _id: 0 } }).toArray();
subscriptions.forEach((subscription) => notifyUser(subscription.u._id, e, ...args));
}
return false;
});
this.streamUser.allowWrite('logged');
this.streamUser.allowWrite(async function(eventName) {
const [userId, e] = eventName.split('/');
if (e === 'webrtc') {
return true;
}
return (this.userId != null) && (this.userId === userId);
});
this.streamUser.allowRead(async function(eventName) {
const [userId] = eventName.split('/');
const [userId, e] = eventName.split('/');
if (e === 'webrtc') {
return true;
}
return (this.userId != null) && this.userId === userId;
});

@ -69,4 +69,5 @@ import './v242';
import './v243';
import './v244';
import './v245';
import './v246';
import './xrun';

@ -0,0 +1,25 @@
import { addMigration } from '../../lib/migrations';
import { Settings } from '../../../app/models/server';
import { settings } from '../../../app/settings/server';
addMigration({
version: 246,
up() {
const livechatVideoCallEnabled = settings.get('Livechat_videocall_enabled');
if (livechatVideoCallEnabled) {
Settings.upsert({ _id: 'Omnichannel_call_provider' }, {
$set: { value: 'Jitsi' },
});
}
Settings.removeById('Livechat_videocall_enabled');
const webRTCEnableChannel = settings.get('WebRTC_Enable_Channel');
const webRTCEnableDirect = settings.get('WebRTC_Enable_Direct');
const webRTCEnablePrivate = settings.get('WebRTC_Enable_Private');
if (webRTCEnableChannel || webRTCEnableDirect || webRTCEnablePrivate) {
Settings.upsert({ _id: 'WebRTC_Enabled' }, {
$set: { value: true },
});
}
},
});
Loading…
Cancel
Save