apps/meteor/client/lib/VideoConfManager.ts
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { sdk } from '../../app/utils/client/lib/SDKClient';
import { getConfig } from './utils/getConfig';
const debug = !!(getConfig('debug') || getConfig('debug-VideoConf'));
// The interval between attempts to call the remote user
const CALL_INTERVAL = 3000;
// How many attempts to call we're gonna make
const CALL_ATTEMPT_LIMIT = 10;
// The amount of time we'll assume an incoming call is still valid without any updates from the remote user
const CALL_TIMEOUT = 10000;
// How long are we gonna wait for a link after accepting an incoming call
const ACCEPT_TIMEOUT = 5000;
type DirectCallParams = {
uid: IUser['_id'];
rid: IRoom['_id'];
callId: string;
};
export type DirectCallData = DirectCallParams & {
dismissed: boolean;
};
type IncomingDirectCall = DirectCallParams & {
timeout: ReturnType<typeof setTimeout> | undefined;
acceptTimeout?: ReturnType<typeof setTimeout> | undefined;
};
export type CallPreferences = {
mic?: boolean;
cam?: boolean;
};
export type ProviderCapabilities = {
mic?: boolean;
cam?: boolean;
title?: boolean;
};
type CurrentCallParams = {
callId: string;
url: string;
providerName?: string;
};
type VideoConfEvents = {
// We gave up on calling a remote user or they rejected our call
'direct/cancel': DirectCallParams;
// A remote user is calling us
'direct/ringing': DirectCallParams;
// An incoming call was lost, either by timeout or because the remote user canceled
'direct/lost': DirectCallParams;
// We tried to accept an incoming call but the process failed
'direct/failed': DirectCallParams;
// A remote user accepted our call
'direct/accepted': DirectCallParams;
// We stopped calling a remote user
'direct/stopped': DirectCallParams;
'preference/changed': { key: keyof CallPreferences; value: boolean };
// The list of incoming calls has changed in some way
'incoming/changed': void;
// The list of ringing incoming calls may have changed
'ringing/changed': void;
// The value of `isCalling` may have changed
'calling/changed': void;
'calling/ended': void;
// When join call
'call/join': CurrentCallParams;
'join/error': { error: string };
'start/error': { error: string };
'capabilities/changed': void;
};
export const VideoConfManager = new (class VideoConfManager extends Emitter<VideoConfEvents> {
private userId: string | undefined;
private currentCallHandler: ReturnType<typeof setTimeout> | undefined;
private currentCallData: (DirectCallParams & { joined?: boolean }) | undefined;
private startingNewCall = false;
private hooks: (() => void)[] = [];
private incomingDirectCalls: Map<string, IncomingDirectCall>;
private dismissedCalls: Set<string>;
private _preferences: CallPreferences;
private _capabilities: ProviderCapabilities;
public get preferences(): CallPreferences {
return this._preferences;
}
public get capabilities(): ProviderCapabilities {
return this._capabilities;
}
constructor() {
super();
this.incomingDirectCalls = new Map<string, IncomingDirectCall>();
this.dismissedCalls = new Set<string>();
this._preferences = { mic: true, cam: false };
this._capabilities = {};
}
public isBusy(): boolean {
if (this.startingNewCall) {
return true;
}
return this.isCalling();
}
public isRinging(): boolean {
return [...this.incomingDirectCalls.values()].some(({ callId }) => !this.isCallDismissed(callId));
}
public isCalling(): boolean {
if (this.currentCallHandler || (this.currentCallData && !this.currentCallData.joined)) {
return true;
}
return false;
}
public getIncomingDirectCalls(): DirectCallData[] {
return (
[...this.incomingDirectCalls.values()]
// Filter out any calls that we're in the process of accepting, so they're already hidden from the UI
.filter((call) => !call.acceptTimeout)
.map(({ timeout: _, acceptTimeout: _t, ...call }) => ({ ...call, dismissed: this.isCallDismissed(call.callId) }))
);
}
public async startCall(roomId: IRoom['_id'], title?: string): Promise<void> {
if (!this.userId || this.isBusy()) {
throw new Error('Video manager is busy.');
}
debug && console.log(`[VideoConf] Starting new call on room ${roomId}`);
this.startingNewCall = true;
this.emit('calling/changed');
const { data } = await sdk.rest.post('/v1/video-conference.start', { roomId, title, allowRinging: true }).catch((e: any) => {
debug && console.error(`[VideoConf] Failed to start new call on room ${roomId}`);
this.startingNewCall = false;
this.emit('calling/changed');
this.emit('start/error', { error: e?.xhr?.responseJSON?.error || 'unknown-error' });
return Promise.reject(e);
});
this.startingNewCall = false;
this.emit('calling/changed');
if (data.type !== 'direct') {
this.emit('calling/ended');
}
switch (data.type) {
case 'direct':
return this.callUser({ uid: data.calleeId, rid: roomId, callId: data.callId });
case 'videoconference':
return this.joinCall(data.callId);
case 'livechat':
return this.joinCall(data.callId);
}
}
public acceptIncomingCall(callId: string): void {
const callData = this.incomingDirectCalls.get(callId);
if (!callData) {
throw new Error('Unable to find accepted call information.');
}
if (callData.acceptTimeout) {
debug && console.log(`[VideoConf] We're already trying to accept call ${callId}.`);
return;
}
debug && console.log(`[VideoConf] Accepting incoming call ${callId}.`);
if (callData.timeout) {
clearTimeout(callData.timeout);
this.setIncomingCallAttribute(callId, 'timeout', undefined);
}
// Mute this call Id so any lingering notifications don't trigger it again
this.dismissIncomingCall(callId);
this.setIncomingCallAttribute(
callId,
'acceptTimeout',
setTimeout(() => {
const updatedCallData = this.incomingDirectCalls.get(callId);
if (!updatedCallData?.acceptTimeout) {
return;
}
debug && console.log(`[VideoConf] Attempt to accept call has timed out.`);
this.removeIncomingCall(callId);
this.emit('direct/failed', { callId, uid: callData.uid, rid: callData.rid });
}, ACCEPT_TIMEOUT),
);
this.emit('incoming/changed');
debug && console.log(`[VideoConf] Notifying user ${callData.uid} that we accept their call.`);
this.userId && this.notifyUser(callData.uid, 'accepted', { callId, uid: this.userId, rid: callData.rid });
}
public rejectIncomingCall(callId: string): void {
this.dismissIncomingCall(callId);
const callData = this.incomingDirectCalls.get(callId);
if (!callData) {
return;
}
this.userId && this.notifyUser(callData.uid, 'rejected', { callId, uid: this.userId, rid: callData.rid });
this.loseIncomingCall(callId);
}
public dismissedIncomingCalls(): void {
// Mute all calls that are currently ringing
if ([...this.incomingDirectCalls.keys()].some((callId) => this.dismissedIncomingCallHelper(callId))) {
this.emit('ringing/changed');
this.emit('incoming/changed');
}
}
public async loadCapabilities(): Promise<void> {
const { capabilities } = await sdk.rest.get('/v1/video-conference.capabilities').catch((e: any) => {
debug && console.error(`[VideoConf] Failed to load video conference capabilities`);
return Promise.reject(e);
});
this._capabilities = capabilities || {};
this.emit('capabilities/changed');
}
private setIncomingCallAttribute<T extends keyof IncomingDirectCall>(
callId: string,
attributeName: T,
value: IncomingDirectCall[T] | undefined,
): void {
const callData = this.incomingDirectCalls.get(callId);
if (!callData) {
debug && console.error(`[VideoConf] Cannot change attribute "${attributeName}" of unknown call "${callId}".`);
return;
}
const newData: IncomingDirectCall = {
...callData,
};
if (value === undefined) {
delete newData[attributeName];
} else {
newData[attributeName] = value;
}
debug && console.log(`[VideoConf] Updating attribute "${attributeName}" of call "${callId}".`);
this.incomingDirectCalls.set(callId, newData);
}
private dismissedIncomingCallHelper(callId: string): boolean {
// Muting will stop a callId from ringing, but it doesn't affect any part of the existing workflow
if (this.isCallDismissed(callId)) {
return false;
}
debug && console.log(`[VideoConf] Dismissing call ${callId}`);
this.dismissedCalls.add(callId);
// We don't need to hold on to the dismissed callIds forever because the server won't let anyone call us with it for very long
setTimeout(() => this.dismissedCalls.delete(callId), CALL_TIMEOUT * 20);
// Only change the state if this call is actually in our list
return this.incomingDirectCalls.has(callId);
}
public dismissIncomingCall(callId: string): boolean {
if (this.dismissedIncomingCallHelper(callId)) {
this.emit('ringing/changed');
this.emit('incoming/changed');
return true;
}
return false;
}
public updateUser(): void {
const userId = Meteor.userId();
if (this.userId === userId) {
debug && console.log(`[VideoConf] Logged user has not changed, so we're not changing the hooks.`);
return;
}
debug && console.log(`[VideoConf] Logged user has changed.`);
if (this.userId) {
this.disconnect();
}
if (userId) {
this.connectUser(userId);
}
}
public changePreference(key: keyof CallPreferences, value: boolean): void {
this._preferences[key] = value;
this.emit('preference/changed', { key, value });
}
public setPreferences(prefs: Partial<CallPreferences>): void {
for (const key in prefs) {
if (prefs.hasOwnProperty(key)) {
const prefKey = key as keyof CallPreferences;
this.changePreference(prefKey, prefs[prefKey] as boolean);
}
}
}
public async joinCall(callId: string): Promise<void> {
debug && console.log(`[VideoConf] Joining call ${callId}.`);
if (this.incomingDirectCalls.has(callId)) {
const data = this.incomingDirectCalls.get(callId);
if (data?.acceptTimeout) {
debug && console.log('[VideoConf] Clearing acceptance timeout');
clearTimeout(data.acceptTimeout);
}
this.removeIncomingCall(callId);
}
const params = {
callId,
state: {
...(this._preferences.mic !== undefined ? { mic: this._preferences.mic } : {}),
...(this._preferences.cam !== undefined ? { cam: this._preferences.cam } : {}),
},
};
const { url, providerName } = await sdk.rest.post('/v1/video-conference.join', params).catch((e) => {
debug && console.error(`[VideoConf] Failed to join call ${callId}`);
this.emit('join/error', { error: e?.xhr?.responseJSON?.error || 'unknown-error' });
return Promise.reject(e);
});
if (!url) {
throw new Error('Failed to get video conference URL.');
}
debug && console.log(`[VideoConf] Opening ${url}.`);
this.emit('call/join', { url, callId, providerName });
}
public abortCall(): void {
if (!this.currentCallData) {
return;
}
this.giveUp(this.currentCallData);
}
private rejectIncomingCallsFromUser(userId: string): void {
for (const [, { callId, uid }] of this.incomingDirectCalls) {
if (userId === uid) {
debug && console.log(`[VideoConf] Rejecting old incoming call from user ${userId}`);
this.rejectIncomingCall(callId);
}
}
}
private async callUser({ uid, rid, callId }: DirectCallParams): Promise<void> {
if (this.currentCallHandler || this.currentCallData) {
throw new Error('Video Conference State Error.');
}
let attempt = 1;
this.currentCallData = { callId, rid, uid };
this.currentCallHandler = setInterval(() => {
if (!this.currentCallHandler) {
debug && console.warn(`[VideoConf] Ringing interval was not properly cleared.`);
return;
}
attempt++;
if (attempt > CALL_ATTEMPT_LIMIT) {
this.giveUp({ uid, rid, callId });
return;
}
debug && console.log(`[VideoConf] Ringing user ${uid}, attempt number ${attempt}.`);
this.userId && this.notifyUser(uid, 'call', { uid: this.userId, rid, callId });
}, CALL_INTERVAL);
this.emit('calling/changed');
debug && console.log(`[VideoConf] Ringing user ${uid} for the first time.`);
this.userId && this.notifyUser(uid, 'call', { uid: this.userId, rid, callId });
}
private async giveUp({ uid, rid, callId }: DirectCallParams): Promise<void> {
const joined = this.currentCallData?.joined;
debug && console.log(`[VideoConf] Stop ringing user ${uid}.`);
if (this.currentCallHandler) {
clearInterval(this.currentCallHandler);
this.currentCallHandler = undefined;
this.currentCallData = undefined;
this.emit('calling/changed');
}
debug && console.log(`[VideoConf] Notifying user ${uid} that we are no longer calling.`);
this.userId && this.notifyUser(uid, 'canceled', { uid: this.userId, rid, callId });
this.emit('direct/cancel', { uid, rid, callId });
this.emit('direct/stopped', { uid, rid, callId });
if (joined) {
return;
}
sdk.rest.post('/v1/video-conference.cancel', { callId });
}
private disconnect(): void {
debug && console.log(`[VideoConf] disconnecting user ${this.userId}`);
for (const hook of this.hooks) {
hook();
}
this.hooks = [];
if (this.currentCallHandler) {
clearInterval(this.currentCallHandler);
this.currentCallHandler = undefined;
}
this.incomingDirectCalls.forEach((call) => {
if (call.timeout) {
clearTimeout(call.timeout);
}
if (call.acceptTimeout) {
clearTimeout(call.acceptTimeout);
}
});
this.incomingDirectCalls.clear();
this.dismissedCalls.clear();
this.currentCallData = undefined;
this._preferences = {};
this.emit('incoming/changed');
this.emit('ringing/changed');
this.emit('calling/changed');
}
private async onVideoConfNotification({ action, params }: { action: string; params: DirectCallParams }): Promise<void> {
if (!action || typeof action !== 'string') {
debug && console.error('[VideoConf] Invalid action received.');
return;
}
if (!params || typeof params !== 'object' || !params.callId || !params.uid || !params.rid) {
debug && console.error('[VideoConf] Invalid params received.');
return;
}
switch (action) {
case 'call':
return this.onDirectCall(params);
case 'canceled':
return this.onDirectCallCanceled(params);
case 'accepted':
return this.onDirectCallAccepted(params);
case 'rejected':
return this.onDirectCallRejected(params);
case 'confirmed':
return this.onDirectCallConfirmed(params);
case 'join':
return this.onDirectCallJoined(params);
case 'end':
return this.onDirectCallEnded(params);
}
}
private async notifyUser(uid: IUser['_id'], action: string, params: DirectCallParams): Promise<void> {
return sdk.publish('notify-user', [`${uid}/video-conference`, { action, params }]);
}
private async connectUser(userId: string): Promise<void> {
debug && console.log(`[VideoConf] connecting user ${userId}`);
this.userId = userId;
const { stop, ready } = sdk.stream('notify-user', [`${userId}/video-conference`], (data) => this.onVideoConfNotification(data));
await ready();
this.hooks.push(stop);
}
private abortIncomingCall(callId: string): void {
// If we just accepted this call, then ignore the timeout
if (this.incomingDirectCalls.get(callId)?.acceptTimeout) {
return;
}
debug && console.log(`[VideoConf] Canceling call ${callId} due to ringing timeout.`);
this.loseIncomingCall(callId);
}
private loseIncomingCall(callId: string): void {
const lostCall = this.incomingDirectCalls.get(callId);
if (!lostCall) {
debug && console.warn(`[VideoConf] Unable to cancel ${callId} because we have no information about it.`);
return;
}
this.removeIncomingCall(callId);
debug && console.log(`[VideoConf] Call ${callId} from ${lostCall.uid} was lost.`);
this.emit('direct/lost', { callId, uid: lostCall.uid, rid: lostCall.rid });
}
private removeIncomingCall(callId: string): void {
debug && console.log(`[VideoConf] Removing call with id "${callId}" from Incoming Calls list.`);
if (!this.incomingDirectCalls.has(callId)) {
return;
}
const isRinging = this.isRinging();
const callData = this.incomingDirectCalls.get(callId);
if (callData?.timeout) {
clearTimeout(callData.timeout);
}
this.incomingDirectCalls.delete(callId);
this.emit('incoming/changed');
if (isRinging !== this.isRinging()) {
this.emit('ringing/changed');
}
}
private createAbortTimeout(callId: string): ReturnType<typeof setTimeout> {
return setTimeout(() => this.abortIncomingCall(callId), CALL_TIMEOUT);
}
private startNewIncomingCall({ callId, uid, rid }: DirectCallParams): void {
if (this.isCallDismissed(callId)) {
debug && console.log(`[VideoConf] Ignoring dismissed call.`);
return;
}
// Reject any currently ringing call from the user before registering the new one.
this.rejectIncomingCallsFromUser(uid);
debug && console.log(`[VideoConf] Storing this new call information.`);
this.incomingDirectCalls.set(callId, {
callId,
uid,
rid,
timeout: this.createAbortTimeout(callId),
});
this.emit('incoming/changed');
this.emit('ringing/changed');
this.emit('direct/ringing', { callId, uid, rid });
}
private refreshExistingIncomingCall({ callId, uid, rid }: DirectCallParams): void {
const existingData = this.incomingDirectCalls.get(callId);
if (!existingData) {
throw new Error('Video Conference Manager State Error');
}
debug && console.log(`[VideoConf] Resetting call timeout.`);
if (existingData.timeout) {
clearTimeout(existingData.timeout);
}
existingData.timeout = this.createAbortTimeout(callId);
if (!this.isCallDismissed(callId)) {
this.emit('direct/ringing', { callId, uid, rid });
}
}
private onDirectCall({ callId, uid, rid }: DirectCallParams): void {
// If we already accepted this call, then don't ring again
if (this.incomingDirectCalls.get(callId)?.acceptTimeout) {
return;
}
debug && console.log(`[VideoConf] User ${uid} is ringing with call ${callId}.`);
if (this.incomingDirectCalls.has(callId)) {
this.refreshExistingIncomingCall({ callId, uid, rid });
} else {
this.startNewIncomingCall({ callId, uid, rid });
}
}
private onDirectCallCanceled({ callId }: DirectCallParams): void {
debug && console.log(`[VideoConf] Call ${callId} was canceled by the remote user.`);
// We had just accepted this call, but the remote user hang up before they got the notification, so cancel our acceptance
const callData = this.incomingDirectCalls.get(callId);
if (callData?.acceptTimeout) {
clearTimeout(callData.acceptTimeout);
this.setIncomingCallAttribute(callId, 'acceptTimeout', undefined);
}
this.loseIncomingCall(callId);
}
private onDirectCallAccepted(params: DirectCallParams, skipConfirmation = false): void {
if (!params.callId || params.callId !== this.currentCallData?.callId) {
debug && console.log(`[VideoConf] User ${params.uid} has accepted a call ${params.callId} from us, but we're not calling.`);
return;
}
debug && console.log(`[VideoConf] User ${params.uid} has accepted our call ${params.callId}.`);
// Stop ringing
if (this.currentCallHandler) {
clearInterval(this.currentCallHandler);
this.currentCallHandler = undefined;
}
const callData = this.currentCallData;
this.emit('direct/accepted', params);
this.emit('direct/stopped', params);
this.currentCallData = undefined;
this.emit('calling/changed');
if (!callData.joined) {
this.joinCall(params.callId);
}
if (skipConfirmation) {
return;
}
debug && console.log(`[VideoConf] Notifying user ${callData.uid} that they can join the call now.`);
this.userId && this.notifyUser(callData.uid, 'confirmed', { callId: callData.callId, uid: this.userId, rid: callData.rid });
}
private onDirectCallConfirmed(params: DirectCallParams): void {
if (!params.callId || !this.incomingDirectCalls.get(params.callId)?.acceptTimeout) {
debug && console.log(`[VideoConf] User ${params.uid} confirmed we can join ${params.callId} but we aren't trying to join it.`);
return;
}
this.joinCall(params.callId);
}
private onDirectCallJoined(params: DirectCallParams): void {
if (!params.callId) {
debug && console.log(`[VideoConf] Invalid 'video-conference.join' event received: ${params.callId}, ${params.uid}.`);
return;
}
if (params.uid === this.userId) {
if (this.currentCallData?.callId === params.callId) {
debug && console.log(`[VideoConf] We joined our own call (${this.userId}) from somewhere else. Flagging the call appropriatelly.`);
this.currentCallData.joined = true;
this.emit('calling/changed');
return;
}
if (this.incomingDirectCalls.has(params.callId)) {
debug && console.log(`[VideoConf] We joined the call ${params.callId} from somewhere else. Dismissing it.`);
this.dismissIncomingCall(params.callId);
this.loseIncomingCall(params.callId);
}
return;
}
debug && console.log(`[VideoConf] User ${params.uid} has joined a call we started ${params.callId}.`);
this.onDirectCallAccepted(params, true);
}
private onDirectCallEnded(params: DirectCallParams): void {
if (!params.callId) {
debug && console.log(`[VideoConf] Invalid 'video-conference.end' event received: ${params.callId}, ${params.uid}.`);
return;
}
const callData = this.incomingDirectCalls.get(params.callId);
if (callData) {
debug && console.log(`[VideoConf] Incoming call ended by the server: ${params.callId}.`);
if (callData.acceptTimeout) {
clearTimeout(callData.acceptTimeout);
this.setIncomingCallAttribute(params.callId, 'acceptTimeout', undefined);
}
this.loseIncomingCall(params.callId);
return;
}
if (this.currentCallData?.callId !== params.callId) {
debug && console.log(`[VideoConf] Server sent a call ended event for a call we're not aware of: ${params.callId}.`);
return;
}
debug && console.log(`[VideoConf] Outgoing call ended by the server: ${params.callId}.`);
// Stop ringing
this.currentCallData = undefined;
if (this.currentCallHandler) {
clearInterval(this.currentCallHandler);
this.currentCallHandler = undefined;
this.emit('calling/changed');
this.emit('direct/stopped', params);
}
}
private onDirectCallRejected(params: DirectCallParams): void {
if (!params.callId || params.callId !== this.currentCallData?.callId) {
debug && console.log(`[VideoConf] User ${params.uid} has rejected a call ${params.callId} from us, but we're not calling.`);
return;
}
debug && console.log(`[VideoConf] User ${params.uid} has rejected our call ${params.callId}.`);
// Stop ringing
if (this.currentCallHandler) {
clearInterval(this.currentCallHandler);
this.currentCallHandler = undefined;
}
const { joined } = this.currentCallData;
this.emit('direct/cancel', params);
this.currentCallData = undefined;
this.emit('direct/stopped', params);
this.emit('calling/changed');
if (!joined) {
sdk.rest.post('/v1/video-conference.cancel', { callId: params.callId });
}
}
private isCallDismissed(callId: string): boolean {
return this.dismissedCalls.has(callId);
}
})();
Meteor.startup(() => Tracker.autorun(() => VideoConfManager.updateUser()));