From f884752498bf1c9eefdff6eb976a084093862128 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 16 Jun 2026 11:14:07 -0300 Subject: [PATCH 01/18] feat: voice to video escalation --- apps/meteor/ee/server/settings/voip.ts | 12 ++ apps/meteor/server/api/v1/media-calls.ts | 70 +++++++++ .../server/services/media-call/service.ts | 140 ++++++++++++++++-- .../services/video-conference/service.ts | 88 ++++++++++- apps/meteor/server/settings/pexip.ts | 29 +++- ee/packages/media-calls/src/constants.ts | 2 +- .../src/definition/IMediaCallServer.ts | 10 +- .../media-calls/src/server/CallDirector.ts | 11 +- .../media-calls/src/server/MediaCallServer.ts | 30 +++- .../src/server/getDefaultSettings.ts | 6 +- ee/packages/media-calls/src/sip/Session.ts | 56 +++++++ .../src/sip/providers/BaseSipCall.ts | 133 ++++++++++++++++- .../src/sip/providers/IncomingSipCall.ts | 54 +------ .../src/sip/providers/OutgoingSipCall.ts | 58 +------- .../src/types/IMediaCallService.ts | 1 + .../src/types/IVideoConfService.ts | 11 ++ packages/core-typings/src/IVideoConference.ts | 3 + .../core-typings/src/mediaCalls/IMediaCall.ts | 2 + packages/i18n/src/locales/en.i18n.json | 10 ++ .../src/definition/call/CallEvents.ts | 3 + .../src/definition/call/IClientMediaCall.ts | 3 +- .../call/callStates/IDirectMediaCallData.ts | 1 + packages/media-signaling/src/lib/Call.ts | 21 ++- packages/media-signaling/src/lib/Session.ts | 11 +- .../src/models/IMediaCallsModel.ts | 2 + .../src/models/IVideoConferenceModel.ts | 25 +++- packages/models/src/models/MediaCalls.ts | 34 +++++ packages/models/src/models/VideoConference.ts | 78 +++++++++- .../pexip/src/definition/PexipSettings.ts | 5 + packages/pexip/src/endpoints/endpoint.ts | 29 ++++ packages/pexip/src/endpoints/eventSink.ts | 54 +++++-- .../src/endpoints/serviceConfiguration.ts | 20 +-- .../src/providers/useMediaSessionInstance.ts | 2 +- 33 files changed, 849 insertions(+), 165 deletions(-) create mode 100644 packages/pexip/src/endpoints/endpoint.ts diff --git a/apps/meteor/ee/server/settings/voip.ts b/apps/meteor/ee/server/settings/voip.ts index 5ae0596670916..b9e609e4a9756 100644 --- a/apps/meteor/ee/server/settings/voip.ts +++ b/apps/meteor/ee/server/settings/voip.ts @@ -123,6 +123,18 @@ export function addSettings(): Promise { i18nDescription: 'VoIP_TeamCollab_ExternalCallHistory_Timeout_Description', }); }); + + await this.section('VoIP_TeamCollab_AdvancedFeatures', async function () { + const enableQuery = { _id: 'Pexip_Integration_Enabled', value: true }; + + await this.add('VoIP_TeamCollab_Video_Escalation_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + enableQuery, + i18nDescription: 'VoIP_TeamCollab_Video_Escalation_Enabled_Description', + }); + }); }, ); }); diff --git a/apps/meteor/server/api/v1/media-calls.ts b/apps/meteor/server/api/v1/media-calls.ts index 0c544a7070754..9dfaad8fa2773 100644 --- a/apps/meteor/server/api/v1/media-calls.ts +++ b/apps/meteor/server/api/v1/media-calls.ts @@ -98,6 +98,76 @@ declare module '@rocket.chat/rest-typings' { interface Endpoints extends MediaCallsAnswerEndpoints {} } +type MediaCallsEscalate = { + callId: string; +}; + +const MediaCallsEscalateSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isMediaCallsEscalateProps = ajv.compile(MediaCallsEscalateSchema); + +const mediaCallsEscalateEndpoints = API.v1.post( + 'media-calls.escalate', + { + response: { + 200: ajv.compile<{ + providerName: string; + url: string; + }>({ + additionalProperties: false, + type: 'object', + properties: { + providerName: { + type: 'string', + description: 'The name of the conference provider.', + }, + url: { + type: 'string', + description: 'The url of the conference.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['providerName', 'url', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 404: validateNotFoundErrorResponse, + }, + body: isMediaCallsEscalateProps, + authRequired: true, + }, + async function action() { + const { callId } = this.bodyParams; + + const url = await MediaCall.escalateCall(this.userId, { callId }); + + return API.v1.success({ + providerName: 'core.pexip', + url, + }); + }, +); + +type MediaCallsEscalateEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends MediaCallsEscalateEndpoints {} +} + type MediaCallsStateSignalsParams = { contractId: string; }; diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 44ae6ab05b9ea..c024fe46e822a 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,4 +1,4 @@ -import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services'; +import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization, VideoConf } from '@rocket.chat/core-services'; import type { IMediaCall, IUser, @@ -6,6 +6,9 @@ import type { IInternalMediaCallHistoryItem, CallHistoryItemState, IExternalMediaCallHistoryItem, + VideoConference, + AtLeast, + IGroupVideoConference, } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; @@ -18,7 +21,7 @@ import type { } from '@rocket.chat/media-signaling'; import { isClientMediaSignal } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; -import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models'; +import { CallHistory, MediaCalls, Rooms, Users, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload'; import { logger } from './logger'; @@ -42,7 +45,10 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params)); this.onEvent('watch.settings', async ({ setting }): Promise => { - if (setting._id.startsWith('VoIP_TeamCollab_') && !setting._id.includes('ExternalCallHistory')) { + if ( + (setting._id.startsWith('VoIP_TeamCollab_') && !setting._id.includes('ExternalCallHistory')) || + setting._id.startsWith('Pexip_Integration_SIP_') + ) { setImmediate(() => this.configureMediaCallServer()); } }); @@ -429,23 +435,26 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall host: settings.get('VoIP_TeamCollab_SIP_Server_Host') ?? '', port: settings.get('VoIP_TeamCollab_SIP_Server_Port') ?? 5060, }, + pexipServer: { + host: settings.get('Pexip_Integration_SIP_Host') ?? '', + port: settings.get('Pexip_Integration_SIP_Port') ?? 5060, + }, }, mobileRinging, permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType), - isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature), + isFeatureEnabled: (feature) => this.isFeatureEnabled(feature), }; } - private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean { - if (feature === 'audio') { - return true; - } - - if (feature === 'screen-share') { - return settings.get('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false; + private isFeatureEnabled(feature: CallFeature): boolean { + switch (feature) { + case 'screen-share': + return settings.get('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false; + case 'conference-escalation': + return Boolean(settings.get('VoIP_TeamCollab_Video_Escalation_Enabled') && settings.get('Pexip_Integration_Enabled')); + default: + return true; } - - return true; } private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise { @@ -470,4 +479,109 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw err; } } + + public async escalateCall(uid: IUser['_id'], params: { callId: string }): Promise { + const { callId } = params; + + const call = await MediaCalls.findOneById(callId); + if (!call?.acceptedAt) { + throw new Error('not-found'); + } + + if (!call.uids.includes(uid)) { + throw new Error('not-found'); + } + + const user = await Users.findOneById(uid); + if (!user) { + throw new Error('internal-error'); + } + + // if (!call.features.includes('conference-escalation')) { + // throw new Error('feature-not-available'); + // } + + const url = await this.escalateVoiceCallToConference(user, call); + return url; + } + + private async escalateVoiceCallToConference(user: IUser, call: IMediaCall): Promise { + const conference = await this.getOrCreateConferenceForEscalatingCall(call, user); + if (conference?.type !== 'videoconference') { + logger.error({ msg: 'Failed to create conference for voice call escalation', type: conference?.type }); + throw new Error('internal-error'); + } + + void this.flagAsEscalated(call).catch((err) => { + logger.error({ msg: 'Unexpected error while flagging call as escalated', err }); + }); + + const result = await VideoConf.joinCall(conference, user, { mic: true, cam: false }); + + return result; + } + + private async findExistingConferenceForCall(call: IMediaCall): Promise { + const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id); + if (existingConference) { + return existingConference; + } + + // TODO: find some call escalated from the other side? + return null; + } + + private async getOrCreateConferenceForEscalatingCall(call: IMediaCall, user: IUser): Promise { + const existingConference = await this.findExistingConferenceForCall(call); + if (existingConference) { + return existingConference; + } + + // If the call is already flagged as escalated but no conference for it exists, don't create a new conference - some other process might still be running + if (call.escalatedAt) { + throw new Error('pre-escalated-conference-not-found'); + } + + return this.createConferenceForEscalatingCall(user, call); + } + + private async createConferenceForEscalatingCall(user: IUser, call: IMediaCall): Promise { + const rid = 'GENERAL'; + + return VideoConf.createEscalatedConference({ + rid, + createdBy: { + _id: user._id, + name: user.name as string, + username: user.username as string, + }, + mediaCallIds: [call._id], + }); + } + + private async flagAsEscalated(call: IMediaCall): Promise { + if (call.escalatedAt) { + return; + } + + const updateResult = await MediaCalls.flagAsEscalatedByCallId(call._id); + if (!updateResult.modifiedCount) { + return; + } + + await this.notifyEscalatedCall(call); + api.broadcast('media-call.updated', { + callId: call._id, + }); + } + + private async notifyEscalatedCall(call: AtLeast): Promise { + for (const uid of call.uids) { + await this.sendSignal(uid, { + callId: call._id, + type: 'notification', + notification: 'escalated', + }); + } + } } diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 8c491f8435072..9fa17e34b2f1a 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import { Apps } from '@rocket.chat/apps'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager'; import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders'; @@ -772,6 +774,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf providerName, }); + await this.maybeAddSipAliasToCall(callId, providerName); + await this.runNewVideoConferenceEvent(callId); await this.maybeCreateDiscussion(callId, user); @@ -827,6 +831,86 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await subscriptions.forEach((subscription) => this.notifyUser(subscription.u._id, action, params)); } + private makeSipAlias(): string { + const result: number[] = []; + const buffer = new Uint8Array(16); + crypto.getRandomValues(buffer); + + let bufferIndex = 0; + + const nextByte = (): number => { + if (bufferIndex >= buffer.length) { + crypto.getRandomValues(buffer); + bufferIndex = 0; + } + return buffer[bufferIndex++]; + }; + + while (result.length === 0) { + const value = nextByte(); + if (value < 252) { + result.push((value % 9) + 1); + } + } + + while (result.length < 8) { + const value = nextByte(); + if (value < 250) { + result.push(value % 10); + } + } + + return result.join(''); + } + + private async addSipAlias(callId: string, attempt = 0): Promise { + const alias = this.makeSipAlias(); + + try { + await VideoConferenceModel.setSipAliasById(callId, alias); + return alias; + } catch (err) { + if (err && typeof err === 'object' && err instanceof Error && err.message.includes('E11000')) { + if (attempt >= 20) { + logger.error({ msg: 'Failed to generate a unique SIP alias for this conference.', err }); + return null; + } + return this.addSipAlias(callId, attempt + 1); + } + + logger.error({ msg: 'Failed to add Sip Alias to video conference', err }); + return null; + } + } + + private async maybeAddSipAliasToCall(callId: string, providerName: string): Promise { + if (providerName !== 'core.pexip') { + return; + } + + if (!settings.get('Pexip_Integration_SIP_AddAlias')) { + return; + } + + await this.addSipAlias(callId); + } + + public async createEscalatedConference( + data: Required>, + ): Promise { + const providerName = 'core.pexip'; + + const callId = await VideoConferenceModel.createGroup({ + ...data, + // TODO: custom title + title: 'Escalated Media Call', + providerName, + }); + + await this.maybeAddSipAliasToCall(callId, providerName); + return VideoConferenceModel.findOneById(callId); + } + private async startGroup( providerName: string, user: IUser, @@ -847,6 +931,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf providerName, }); + await this.maybeAddSipAliasToCall(callId, providerName); + await this.runNewVideoConferenceEvent(callId); await this.maybeCreateDiscussion(callId, user); @@ -907,7 +993,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }; } - private async joinCall( + public async joinCall( call: ExternalVideoConference, user: AtLeast | undefined, options: VideoConferenceJoinOptions, diff --git a/apps/meteor/server/settings/pexip.ts b/apps/meteor/server/settings/pexip.ts index 9684aaa35ae19..827dbcadf7cfc 100644 --- a/apps/meteor/server/settings/pexip.ts +++ b/apps/meteor/server/settings/pexip.ts @@ -98,6 +98,29 @@ export function createPexipSettings(): Promise { ], }); }); + + await this.section('Pexip_Integration_SIP', async function () { + await this.add('Pexip_Integration_SIP_AddAlias', false, { + type: 'boolean', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_SIP_AddAlias_Description`, + }); + + await this.add('Pexip_Integration_SIP_Host', '', { + type: 'string', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_SIP_Host_Description`, + }); + + await this.add('Pexip_Integration_SIP_Port', 5060, { + type: 'int', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_SIP_Port_Description`, + }); + }); }); } @@ -120,11 +143,15 @@ export function getPexipSettings(): PexipSettings { overlayText: settings.get('Pexip_Integration_Overlay_Text'), meetingLayout: settings.get('Pexip_Integration_Meeting_Layout'), }, - workspace: { siteUrl: settings.get('Site_Url'), discussionsEnabled: settings.get('Discussion_enabled'), persistentChatEnabled: settings.get('VideoConf_Enable_Persistent_Chat'), }, + sip: { + addAlias: settings.get('Pexip_Integration_SIP_AddAlias'), + host: settings.get('Pexip_Integration_SIP_Host'), + port: settings.get('Pexip_Integration_SIP_Port'), + }, }; } diff --git a/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts index 86fa92bb08709..11684481b79f1 100644 --- a/ee/packages/media-calls/src/constants.ts +++ b/ee/packages/media-calls/src/constants.ts @@ -1,4 +1,4 @@ import type { CallFeature } from '@rocket.chat/media-signaling'; export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio']; -export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share']; +export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share', 'conference-escalation']; diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 37b119e4df5c3..c92e20e6c16b6 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, MediaCallContact } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling'; @@ -33,12 +33,16 @@ export interface IMediaCallServerSettings { host: string; port: number; }; + pexipServer: { + host: string; + port: number; + }; }; mobileRinging: boolean; permissionCheck: (uid: IUser['_id'], callType: 'internal' | 'external' | 'any') => Promise; - isFeatureAvailableForUser: (uid: IUser['_id'], feature: CallFeature) => boolean; + isFeatureEnabled: (feature: CallFeature) => boolean; } export interface IMediaCallServer { @@ -62,5 +66,5 @@ export interface IMediaCallServer { requestCall(params: InternalCallParams): Promise; permissionCheck(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise; - isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean; + isFeatureAvailableForParticipants(feature: CallFeature, participants: MediaCallContact[]): boolean; } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 194d948befea8..3c038949aea7c 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -215,7 +215,16 @@ class MediaCallDirector { callerAgent.oppositeAgent = calleeAgent; calleeAgent.oppositeAgent = callerAgent; - const allowedFeatures = features.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature)); + const forbiddenFeatures: CallFeature[] = []; + if (parentCallId) { + // Transferred calls can not be escalated yet + forbiddenFeatures.push('conference-escalation'); + } + + const participants = [caller, callee]; + const allowedFeatures = features.filter( + (feature) => !forbiddenFeatures.includes(feature) && getMediaCallServer().isFeatureAvailableForParticipants(feature, participants), + ); const call: Omit = { // Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit) _id: randomUUID(), diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 86aaa932db858..99501d65a895d 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, MediaCallContact } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, @@ -146,8 +146,32 @@ export class MediaCallServer implements IMediaCallServer { return this.settings.permissionCheck(uid, callType); } - public isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean { - return this.settings.isFeatureAvailableForUser(uid, feature); + public isFeatureAvailableForParticipants(feature: CallFeature, participants: MediaCallContact[]): boolean { + if (!this.settings.isFeatureEnabled(feature)) { + return false; + } + + if (feature === 'conference-escalation') { + // Conference escalation is only implemented on SIP calls + return this.isSipCallParticipants(participants); + } + + return true; + } + + private isSipCallParticipants(participants: MediaCallContact[]): boolean { + // On sip calls, one participant is an internal user and the other is a sip extension; The order depends on the call direction + const sipUser = participants.find(({ type }) => type === 'sip'); + if (!sipUser) { + return false; + } + + const internalUser = participants.find(({ type }) => type === 'user'); + if (!internalUser) { + return false; + } + + return true; } /** diff --git a/ee/packages/media-calls/src/server/getDefaultSettings.ts b/ee/packages/media-calls/src/server/getDefaultSettings.ts index 07ddfe33ccc9a..1c9947c3af096 100644 --- a/ee/packages/media-calls/src/server/getDefaultSettings.ts +++ b/ee/packages/media-calls/src/server/getDefaultSettings.ts @@ -17,10 +17,14 @@ export function getDefaultSettings(): IMediaCallServerSettings { host: '', port: 5080, }, + pexipServer: { + host: '', + port: 5060, + }, }, mobileRinging: false, permissionCheck: async () => false, - isFeatureAvailableForUser: () => false, + isFeatureEnabled: () => false, }; } diff --git a/ee/packages/media-calls/src/sip/Session.ts b/ee/packages/media-calls/src/sip/Session.ts index 7afd6944744c9..dffbecd1f657d 100644 --- a/ee/packages/media-calls/src/sip/Session.ts +++ b/ee/packages/media-calls/src/sip/Session.ts @@ -12,6 +12,7 @@ import { IncomingSipCall } from './providers/IncomingSipCall'; import { OutgoingSipCall } from './providers/OutgoingSipCall'; import type { IMediaCallServerSettings } from '../definition/IMediaCallServer'; import type { InternalCallParams } from '../definition/common'; +import { mediaCallDirector } from '../server/CallDirector'; import { getDefaultSettings } from '../server/getDefaultSettings'; export class SipServerSession { @@ -110,6 +111,61 @@ export class SipServerSession { return `sip:${extension}@${host}${portStr}`; } + public getPexipUri(alias: string): string { + const { host, port } = this.settings.sip.pexipServer; + if (!host) { + throw new Error('Pexip Server Host is not configured'); + } + + const portStr = port ? `:${port}` : ''; + return `sip:${alias}@${host}${portStr}`; + } + + public isPexipIdentity(identity: string): boolean { + if (!identity) { + return false; + } + + const { host } = this.settings.sip.pexipServer; + if (!host) { + return false; + } + + return identity.includes(host); + } + + public async sendReferRequest( + sipDialog: Srf.Dialog, + params: { transferredTo?: MediaCallContact; transferredBy?: MediaCallContact; conferenceAlias?: string }, + ): Promise { + const { transferredBy, transferredTo, conferenceAlias } = params; + if (!transferredTo && !conferenceAlias) { + throw new Error('Missing refer destination'); + } + + // Sip targets can only be referred to other sip users + const referToActor = transferredTo && (await mediaCallDirector.cast.getContactForActor(transferredTo, { requiredType: 'sip' })); + const referredBy = transferredBy && this.geContactUri(transferredBy); + const referToConference = conferenceAlias && this.getPexipUri(conferenceAlias); + + const referTo = referToConference || (referToActor && this.geContactUri(referToActor)); + if (!referTo) { + throw new Error('invalid-transfer'); + } + + const res = await sipDialog.request({ + method: 'REFER', + headers: { + 'Refer-To': referTo, + ...(referredBy && { 'Referred-By': referredBy }), + }, + }); + + if (res.status === 202) { + logger.debug({ msg: 'REFER was accepted', method: 'SipServerSession.sendReferRequest', ...params }); + } + } + public stripDrachtioServerDetails(reqOrRes: Srf.SipMessage): Record { const { _agent, socket: _socket, _req, _res, ...data } = reqOrRes as Record; diff --git a/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts b/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts index 4196da5440d2c..000f80aee3739 100644 --- a/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts @@ -1,10 +1,11 @@ import type { IMediaCall, IMediaCallChannel, MediaCallContact } from '@rocket.chat/core-typings'; import type { ClientMediaSignalBody } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; +import { MediaCalls, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; import type Srf from 'drachtio-srf'; import type { SrfRequest, SrfResponse } from 'drachtio-srf'; import { BaseCallProvider } from '../../base/BaseCallProvider'; +import { UserActorAgent } from '../../internal/agents/UserActorAgent'; import { logger } from '../../logger'; import type { BroadcastActorAgent } from '../../server/BroadcastAgent'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -25,6 +26,12 @@ export abstract class BaseSipCall extends BaseCallProvider { protected abstract inboundRenegotiations: Map; + protected sipDialog: Srf.Dialog | null; + + protected processedTransfer: boolean; + + protected processedEscalation: boolean; + constructor( protected readonly session: SipServerSession, call: IMediaCall, @@ -33,6 +40,9 @@ export abstract class BaseSipCall extends BaseCallProvider { ) { super(call); this.lastCallState = 'none'; + this.sipDialog = null; + this.processedTransfer = false; + this.processedEscalation = false; } protected async handleDialogModify(req: SrfRequest, res: SrfResponse): Promise { @@ -42,7 +52,38 @@ export abstract class BaseSipCall extends BaseCallProvider { const newContact = await this.detectSipInitiatedTransfer(callingNumber); if (newContact) { - return this.updateRemoteContact(newContact); + const header = req.has('p-asserted-identity') ? req.get('p-asserted-identity') : req.get('from'); + if (header && this.session.isPexipIdentity(header)) { + await this.processEscalatedRemotely(callingNumber); + } + + await this.updateRemoteContact(newContact); + } + } + + protected async processEscalatedRemotely(sipAlias: string): Promise { + if (this.call.escalatedAt) { + return; + } + + const updateResult = await MediaCalls.flagAsEscalatedByCallId(this.call._id); + if (!updateResult.modifiedCount) { + return; + } + + const conference = await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', sipAlias, this.call._id); + if (!conference) { + // TODO: maybe rollback `flagAsEscalatedByCallId` ? + return; + } + + const { oppositeAgent } = this.agent; + if (oppositeAgent && oppositeAgent instanceof UserActorAgent) { + await oppositeAgent.sendSignal({ + callId: this.call._id, + type: 'notification', + notification: 'escalated', + }); } } @@ -217,6 +258,8 @@ export abstract class BaseSipCall extends BaseCallProvider { protected abstract reflectCall(call: IMediaCall, params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise; + protected abstract processEndedCall(call: IMediaCall): Promise; + protected async sendDTMF(dialog: Srf.Dialog, dtmf: string, duration: number): Promise { logger.debug({ msg: 'BaseSipCall.sendDTMF' }); await dialog.request({ @@ -227,4 +270,90 @@ export abstract class BaseSipCall extends BaseCallProvider { body: `Signal=${dtmf}\r\nDuration=${duration}`, }); } + + protected async processTransferredCall(call: IMediaCall): Promise { + if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) { + return; + } + + if (!this.sipDialog || this.processedTransfer) { + if (call.ended) { + return this.processEndedCall(call); + } + return; + } + + logger.debug({ msg: 'processTransferredCall', callId: call._id, lastCallState: this.lastCallState, type: this.constructor.name }); + this.processedTransfer = true; + + try { + await this.session.sendReferRequest(this.sipDialog, { + transferredTo: call.transferredTo, + transferredBy: call.transferredBy, + }); + } catch (err) { + logger.error({ msg: 'REFER failed', method: 'processTransferredCall', err, callId: call._id, type: this.constructor.name }); + if (!call.ended) { + void mediaCallDirector.hangupByServer(call, 'signaling-error'); + } + return this.processEndedCall(call); + } + } + + protected async processEscalatedCall(call: IMediaCall): Promise { + if (this.lastCallState === 'hangup' || !call.escalatedAt) { + return; + } + + if (!this.sipDialog || this.processedEscalation) { + if (call.ended) { + return this.processEndedCall(call); + } + return; + } + + const conference = await VideoConferenceModel.findOneByMediaCallId(call._id, { projection: { sipAlias: 1, mediaCallIds: 1 } }); + if (!conference) { + logger.debug({ + msg: 'Could not find Conference for escalated voice call', + method: 'processEscalatedCall', + callId: call._id, + type: this.constructor.name, + }); + return; + } + + const { sipAlias: conferenceAlias, mediaCallIds } = conference; + + if (!conferenceAlias || !mediaCallIds) { + logger.debug({ + msg: 'Escalated Conference does not have a SIP Alias', + method: 'processEscalatedCall', + callId: call._id, + conferenceId: conference._id, + type: this.constructor.name, + }); + return; + } + + logger.debug({ msg: 'Processing Call Escalation', callId: call._id, lastCallState: this.lastCallState, type: this.constructor.name }); + this.processedEscalation = true; + + try { + // If the conference is already associated with two voice calls, then the remote SIP leg is already in it, do not refer + if (mediaCallIds.length >= 2) { + if (!call.ended) { + void mediaCallDirector.hangupByServer(call, 'escalated-remotely'); + } + return; + } + + await this.session.sendReferRequest(this.sipDialog, { conferenceAlias }); + } catch (err) { + logger.error({ msg: 'REFER failed', method: 'processEscalatedCall', err, type: this.constructor.name }); + if (!call.ended) { + void mediaCallDirector.hangupByServer(call, 'signaling-error'); + } + } + } } diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts index fc32d93f6b686..c82758db300eb 100644 --- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts @@ -21,12 +21,8 @@ import { SipError, SipErrorCodes } from '../errorCodes'; import { parseDiversionHeader } from '../utils/parseDiversionHeader'; export class IncomingSipCall extends BaseSipCall { - private sipDialog: Srf.Dialog | null; - protected inboundRenegotiations: Map; - private processedTransfer: boolean; - constructor( session: SipServerSession, call: IMediaCall, @@ -40,6 +36,7 @@ export class IncomingSipCall extends BaseSipCall { this.sipDialog = null; this.inboundRenegotiations = new Map(); this.processedTransfer = false; + this.processedEscalation = false; } public static async processInvite(session: SipServerSession, srf: Srf, req: SrfRequest, res: SrfResponse): Promise { @@ -172,6 +169,10 @@ export class IncomingSipCall extends BaseSipCall { return this.processTransferredCall(call); } + if (call.escalatedAt) { + return this.processEscalatedCall(call); + } + if (call.ended) { return this.processEndedCall(call); } @@ -183,51 +184,6 @@ export class IncomingSipCall extends BaseSipCall { logger.debug({ msg: 'no changes detected', method: 'IncomingSipCall.reflectCall' }); } - protected async processTransferredCall(call: IMediaCall): Promise { - if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) { - return; - } - - if (!this.sipDialog || this.processedTransfer) { - if (call.ended) { - return this.processEndedCall(call); - } - return; - } - - logger.debug({ msg: 'IncomingSipCall.processTransferredCall', callId: call._id, lastCallState: this.lastCallState }); - this.processedTransfer = true; - - try { - // Sip targets can only be referred to other sip users - const newCallee = await mediaCallDirector.cast.getContactForActor(call.transferredTo, { requiredType: 'sip' }); - if (!newCallee) { - throw new Error('invalid-transfer'); - } - - const referTo = this.session.geContactUri(newCallee); - const referredBy = this.session.geContactUri(call.transferredBy); - - const res = await this.sipDialog.request({ - method: 'REFER', - headers: { - 'Refer-To': referTo, - 'Referred-By': referredBy, - }, - }); - - if (res.status === 202) { - logger.debug({ msg: 'REFER was accepted', method: 'IncomingSipCall.processTransferredCall' }); - } - } catch (err) { - logger.error({ msg: 'REFER failed', method: 'IncomingSipCall.processTransferredCall', err }); - if (!call.ended) { - void mediaCallDirector.hangupByServer(call, 'sip-refer-failed'); - } - return this.processEndedCall(call); - } - } - protected async processEndedCall(call: IMediaCall): Promise { logger.debug({ msg: 'IncomingSipCall.processEndedCall', lastCallState: this.lastCallState, hangupReason: call.hangupReason }); diff --git a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts index 7a51125013b9a..3c12732d82986 100644 --- a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts @@ -1,7 +1,6 @@ import type { IMediaCall, IMediaCallChannel, MediaCallSignedContact } from '@rocket.chat/core-typings'; import { isBusyState, type ClientMediaSignalBody, type CallHangupReason } from '@rocket.chat/media-signaling'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; -import type Srf from 'drachtio-srf'; import type { SrfRequest } from 'drachtio-srf'; import { BaseSipCall, type SipCallNegotiation } from './BaseSipCall'; @@ -14,14 +13,10 @@ import type { SipServerSession } from '../Session'; import { SipError, SipErrorCodes } from '../errorCodes'; export class OutgoingSipCall extends BaseSipCall { - private sipDialog: Srf.Dialog | null; - private sipDialogReq: SrfRequest | null; protected inboundRenegotiations: Map; - private processedTransfer: boolean; - constructor( session: SipServerSession, call: IMediaCall, @@ -89,8 +84,12 @@ export class OutgoingSipCall extends BaseSipCall { return this.processTransferredCall(call); } + if (call.escalatedAt) { + return this.processEscalatedCall(call); + } + if (call.state === 'hangup') { - return this.processEndedCall(); + return this.processEndedCall(call); } if (this.lastCallState === 'none') { @@ -295,52 +294,7 @@ export class OutgoingSipCall extends BaseSipCall { }); } - protected async processTransferredCall(call: IMediaCall): Promise { - if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) { - return; - } - - if (!this.sipDialog || this.processedTransfer) { - if (call.ended) { - return this.processEndedCall(); - } - return; - } - - logger.debug({ msg: 'OutgoingSipCall.processTransferredCall', callId: call._id, lastCallState: this.lastCallState }); - this.processedTransfer = true; - - try { - // Sip targets can only be referred to other sip users - const newCallee = await mediaCallDirector.cast.getContactForActor(call.transferredTo, { requiredType: 'sip' }); - if (!newCallee) { - throw new Error('invalid-transfer'); - } - - const referTo = this.session.geContactUri(newCallee); - const referredBy = this.session.geContactUri(call.transferredBy); - - const res = await this.sipDialog.request({ - method: 'REFER', - headers: { - 'Refer-To': referTo, - 'Referred-By': referredBy, - }, - }); - - if (res.status === 202) { - logger.debug({ msg: 'REFER was accepted', method: 'OutgoingSipCall.processTransferredCall' }); - } - } catch (err) { - logger.error({ msg: 'REFER failed', method: 'OutgoingSipCall.processTransferredCall', err, callId: call._id }); - if (!call.ended) { - void mediaCallDirector.hangupByServer(call, 'signaling-error'); - } - return this.processEndedCall(); - } - } - - protected async processEndedCall(): Promise { + protected async processEndedCall(_call: IMediaCall): Promise { if (this.lastCallState === 'hangup') { return; } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 60a9493a178d2..8c777f788e247 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -7,4 +7,5 @@ export interface IMediaCallService { processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; hangupExpiredCalls(): Promise; getUserStateSignals(uid: IUser['_id'], contractId: string): Promise; + escalateCall(uid: IUser['_id'], params: { callId: string }): Promise; } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 6d74413ccf211..d445e44b1e436 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -1,4 +1,7 @@ import type { + AtLeast, + ExternalVideoConference, + IGroupVideoConference, IRoom, IStats, IUser, @@ -44,4 +47,12 @@ export interface IVideoConfService { ): Promise; assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise; createVoIP(data: InsertionModel): Promise; + joinCall( + call: ExternalVideoConference, + user: AtLeast | undefined, + options: VideoConferenceJoinOptions, + ): Promise; + createEscalatedConference( + data: Required>, + ): Promise; } diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 74cb7fc04ee63..12439abaadd4c 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -79,6 +79,9 @@ export interface IVideoConference extends IRocketChatRecord { ringing?: boolean; discussionRid?: IRoom['_id']; + + mediaCallIds?: string[]; + sipAlias?: string; } export interface IDirectVideoConference extends IVideoConference { diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 454032dcecffb..2e2faf8a20e1c 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -70,6 +70,8 @@ export interface IMediaCall extends IRocketChatRecord { uids: IUser['_id'][]; + escalatedAt?: Date; + /** The list of features that may be used in this call. Values are final once the call is accepted. */ features: string[]; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 3841386682a46..2e6e5ee959a0e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4248,6 +4248,13 @@ "Pexip_Integration_Pins": "Static Pins", "Pexip_Integration_Theme_Name": "Theme Name", "Pexip_Integration_Theme_Name_Description": "Name of the pexip theme to be used on Rocket.Chat calls", + "Pexip_Integration_SIP": "SIP", + "Pexip_Integration_SIP_AddAlias": "Add Numeric Alias to Conferences", + "Pexip_Integration_SIP_AddAlias_Description": "Creates an alias for every new conference, using only numeric digits", + "Pexip_Integration_SIP_Host": "SIP Host", + "Pexip_Integration_SIP_Host_Description": "The host that should be used when transferring users to a conference through SIP.", + "Pexip_Integration_SIP_Port": "SIP Port", + "Pexip_Integration_SIP_Port_Description": "The port that should be used when transferring users to a conference through SIP.", "Pharmaceutical": "Pharmaceutical", "Phone": "Phone", "Phone_Number": "Phone Number", @@ -5987,6 +5994,7 @@ "Visitor_time_on_site": "Visitor time on site", "VoIP": "VoIP", "VoIP_TeamCollab": "Team voice calls (VoIP)", + "VoIP_TeamCollab_AdvancedFeatures": "Features", "VoIP_TeamCollab_Description": "Set up VoIP in Team collaboration", "VoIP_TeamCollab_ExternalCallHistory": "External Call History", "VoIP_TeamCollab_ExternalCallHistory_Enabled": "Enabled", @@ -6021,6 +6029,8 @@ "VoIP_TeamCollab_Drachtio_Password": "Drachtio Password", "VoIP_TeamCollab_SIP_Server_Host": "SIP Server Host", "VoIP_TeamCollab_SIP_Server_Port": "SIP Server Port", + "VoIP_TeamCollab_Video_Escalation_Enabled": "Video escalation", + "VoIP_TeamCollab_Video_Escalation_Enabled_Description": "Allow users to escalate voice calls to a video conference.", "VoIP_device_permission_required": "Mic/speaker access required", "VoIP_device_permission_required_description": "Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.", "VoIP_allow_and_call": "Allow and call", diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 4c6c5ecf7f19b..dbffafc4ad688 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -36,4 +36,7 @@ export type CallEvents = { /* Triggered when any of the streams or tracks have changed */ streamChange: void; + + /* Triggered when the call is escalated into a video conference by any participant */ + escalated: void; }; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 708f7cacc6fd0..7ca263c206c2c 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -10,7 +10,7 @@ import type { CallActorType } from './common'; export type CallService = 'webrtc'; -export const callFeatureList = ['audio', 'screen-share', 'transfer', 'hold'] as const; +export const callFeatureList = ['audio', 'screen-share', 'transfer', 'hold', 'conference-escalation'] as const; export type CallFeature = (typeof callFeatureList)[number]; @@ -63,6 +63,7 @@ export const callNotificationList = [ 'active', // notify that call activity was confirmed 'hangup', // notify that the call is over; 'trying', // notify that the other client is connecting but still need more time + 'escalated', // notify that the call was escalated to a video-conference ] as const; export type CallNotification = (typeof callNotificationList)[number]; diff --git a/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts index d3a0d15a948cc..b0043e044579e 100644 --- a/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts +++ b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts @@ -11,6 +11,7 @@ export interface IDirectMediaCallData { readonly features: readonly CallFeature[]; readonly state: CallState; readonly hidden: boolean; + readonly escalated: boolean; readonly ringing: boolean; readonly transferredBy: CallContact | null; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index c1cb3899a26a7..4d67eea60e5fb 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -121,7 +121,7 @@ export class ClientMediaCall implements IClientMediaCall { * Since the Call instance is only created when we receive "something" from the server, this would mean we received signals out of order, or missed one. */ - return this.ignored || this.contractState === 'ignored' || !this.initialized; + return this.ignored || this.contractState === 'ignored' || !this._initialized; } public get muted(): boolean { @@ -246,6 +246,8 @@ export class ClientMediaCall implements IClientMediaCall { private enabledFeatures: CallFeature[] | null; + private escalated: boolean; + private _flags: CallFlag[]; public get flags(): CallFlag[] { @@ -299,6 +301,7 @@ export class ClientMediaCall implements IClientMediaCall { activeTimestamp: this.activeTimestamp, tempCallId: this.tempCallId, hidden: this.hidden, + escalated: this.escalated, ringing: this.ringing, localParticipant: this.localParticipant, @@ -334,6 +337,7 @@ export class ClientMediaCall implements IClientMediaCall { this.sentLocalSdp = false; this.receivedRemoteSdp = false; this.enabledFeatures = null; + this.escalated = false; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); @@ -389,7 +393,7 @@ export class ClientMediaCall implements IClientMediaCall { supportedFeatures: CallFeature[], contactInfo?: CallContact, ): Promise { - if (this.initialized) { + if (this._initialized) { return; } @@ -1220,6 +1224,8 @@ export class ClientMediaCall implements IClientMediaCall { case 'hangup': return this.flagAsEnded('remote'); + case 'escalated': + return this.flagAsEscalated(); } } @@ -1279,6 +1285,17 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('hangup'); } + private flagAsEscalated(): void { + if (this.escalated) { + return; + } + + this.config.logger?.debug('ClientMediaCall.flagAsEscalated'); + + this.escalated = true; + this.emitter.emit('escalated'); + } + private addStateTimeout(state: ClientState, timeout: number, callback?: () => void): void { this.config.logger?.debug('ClientMediaCall.addStateTimeout', state, `${timeout / 1000}s`); if (this.getClientState() !== state) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 8b992280cb169..5b2c16a1189d3 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -221,6 +221,9 @@ export class MediaSignalingSession extends Emitter { } const call = this.getOrCreateCallBySignal(signal); + if (!call) { + return; + } if (signal.type === 'notification' && signal.signedContractId) { if (signal.signedContractId === this._sessionId) { @@ -356,13 +359,18 @@ export class MediaSignalingSession extends Emitter { return null; } - private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall { + private getOrCreateCallBySignal(signal: ServerMediaCallSignal): ClientMediaCall | null { this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); const existingCall = this.getExistingCallBySignal(signal); if (existingCall) { return existingCall; } + // Notifications that do not cause state change can be ignored if the call is still unknown + if (signal.type === 'notification' && ['escalated', 'trying'].includes(signal.notification)) { + return null; + } + return this.createCall(signal.callId); } @@ -663,6 +671,7 @@ export class MediaSignalingSession extends Emitter { call.emitter.on('ended', () => this.onEndedCall(call)); call.emitter.on('screenShareRequestChange', (requested: boolean) => this.onScreenShareRequestChange(call, requested)); call.emitter.on('streamChange', () => this.onSessionStateChange()); + call.emitter.on('escalated', () => this.onSessionStateChange()); return call; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index ec807b1e1b1f9..28b1b99c32563 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -33,10 +33,12 @@ export interface IMediaCallsModel extends IBaseModel { transferCallById(callId: string, params: { by: MediaCallSignedContact; to: MediaCallContact }): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; findAllNotOverByUid(uid: IUser['_id'], options?: FindOptions): FindCursor; + findAllNotOverByOppositeSipExtension(sipExtension: string, options?: FindOptions): FindCursor; hasUnfinishedCalls(): Promise; hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise; updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, ): Promise; + flagAsEscalatedByCallId(callId: string): Promise; } diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 66a082af85d2a..edc2db6b0a0cc 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -7,7 +7,7 @@ import type { VideoConferenceStatus, IVoIPVideoConference, } from '@rocket.chat/core-typings'; -import type { FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions } from 'mongodb'; +import type { FindCursor, UpdateOptions, UpdateFilter, UpdateResult, FindOptions, WithId } from 'mongodb'; import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel'; @@ -30,7 +30,8 @@ export interface IVideoConferenceModel extends IBaseModel { createGroup({ providerName, ...callDetails - }: Required>): Promise; + }: Required> & + Pick): Promise; createLivechat({ providerName, @@ -43,7 +44,7 @@ export interface IVideoConferenceModel extends IBaseModel { options?: UpdateOptions, ): Promise; - setDataById(callId: string, data: Partial>): Promise; + setDataById(callId: string, data: Partial>): Promise; setEndedById(callId: string, endedBy?: { _id: string; name: string; username: string }, endedAt?: Date): Promise; @@ -70,4 +71,22 @@ export interface IVideoConferenceModel extends IBaseModel { unsetDiscussionRid(discussionRid: IRoom['_id']): Promise; createVoIP(call: InsertionModel): Promise; + + findOneByMediaCallId(callId: string, options?: FindOptions): Promise; + + setSipAliasById(callId: string, sipAlias: string): Promise; + + unsetSipAliasById(callId: string): Promise; + + addMediaCallIdByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + mediaCallId: string, + ): Promise | null>; + + findOneByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + options?: FindOptions, + ): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 2c7b8d25bf7f8..5ca72359f545d 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -152,6 +152,21 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public async flagAsEscalatedByCallId(callId: string): Promise { + return this.updateOne( + { + _id: callId, + ended: false, + escalatedAt: { $exists: false }, + }, + { + $set: { + escalatedAt: new Date(), + }, + }, + ); + } + public async setExpiresAtById(callId: string, expiresAt: Date): Promise { return this.updateOne( { @@ -210,6 +225,25 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public findAllNotOverByOppositeSipExtension( + sipExtension: string, + options?: FindOptions, + ): FindCursor { + return this.find( + { + ended: false, + expiresAt: { + $gt: new Date(), + }, + $or: [ + { 'caller.type': 'user', 'caller.sipExtension': sipExtension, 'callee.type': 'sip' }, + { 'callee.type': 'user', 'callee.sipExtension': sipExtension, 'caller.type': 'sip' }, + ], + }, + options, + ); + } + public async hasUnfinishedCalls(): Promise { const count = await this.countDocuments({ ended: false }, { limit: 1 }); return count > 0; diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index e6bba09747c03..0098712e6118d 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -18,6 +18,8 @@ import type { Collection, Db, CountDocumentsOptions, + FindOptions, + WithId, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -32,6 +34,8 @@ export class VideoConferenceRaw extends BaseRaw implements IVid { key: { rid: 1, createdAt: 1 }, unique: false }, { key: { type: 1, status: 1 }, unique: false }, { key: { discussionRid: 1 }, unique: false }, + { key: { mediaCallIds: 1 }, unique: true, sparse: true }, + { key: { providerName: 1, sipAlias: 1 }, unique: true, partialFilterExpression: { sipAlias: { $exists: true } } }, ]; } @@ -104,8 +108,10 @@ export class VideoConferenceRaw extends BaseRaw implements IVid public async createGroup({ providerName, + mediaCallIds, ...callDetails - }: Required>): Promise { + }: Required> & + Pick): Promise { const call: InsertionModel = { type: 'videoconference', users: [], @@ -114,6 +120,7 @@ export class VideoConferenceRaw extends BaseRaw implements IVid anonymousUsers: 0, createdAt: new Date(), providerName: providerName.toLowerCase(), + ...(mediaCallIds?.length && { mediaCallIds }), ...callDetails, }; @@ -158,12 +165,20 @@ export class VideoConferenceRaw extends BaseRaw implements IVid endedBy, endedAt: endedAt || new Date(), }, + $unset: { + sipAlias: true, + }, }); } - public async setDataById(callId: string, data: Partial>): Promise { + public async setDataById(callId: string, data: Partial>): Promise { + const isOver = + data.status !== undefined && + [VideoConferenceStatus.EXPIRED, VideoConferenceStatus.ENDED, VideoConferenceStatus.DECLINED].includes(data.status); + await this.updateOneById(callId, { $set: data, + ...(isOver && { $unset: { sipAlias: true } }), }); } @@ -176,10 +191,15 @@ export class VideoConferenceRaw extends BaseRaw implements IVid } public async setStatusById(callId: string, status: VideoConference['status']): Promise { + const isOver = [VideoConferenceStatus.EXPIRED, VideoConferenceStatus.ENDED, VideoConferenceStatus.DECLINED].includes(status); + await this.updateOneById(callId, { $set: { status, }, + ...(isOver && { + $unset: { sipAlias: true }, + }), }); } @@ -302,4 +322,58 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }, ); } + + public async findOneByMediaCallId(callId: string, options?: FindOptions): Promise { + return this.findOne( + { + mediaCallIds: callId, + }, + options || {}, + ); + } + + public async addMediaCallIdByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + mediaCallId: string, + ): Promise | null> { + return this.findOneAndUpdate( + { + providerName, + sipAlias, + status: VideoConferenceStatus.STARTED, + mediaCallIds: { $not: { $eq: mediaCallId } }, + }, + { + $addToSet: { + mediaCallIds: mediaCallId, + }, + }, + { + returnDocument: 'after', + }, + ); + } + + public async setSipAliasById(callId: string, sipAlias: string): Promise { + await this.updateOne({ _id: callId }, { $set: { sipAlias } }); + } + + public async unsetSipAliasById(callId: string): Promise { + await this.updateOne({ _id: callId }, { $unset: { sipAlias: true } }); + } + + public async findOneByProviderNameAndSipAlias( + providerName: string, + sipAlias: string, + options?: FindOptions, + ): Promise { + return this.findOne( + { + providerName, + sipAlias, + }, + options || {}, + ); + } } diff --git a/packages/pexip/src/definition/PexipSettings.ts b/packages/pexip/src/definition/PexipSettings.ts index 94631c0ef1442..773652a428770 100644 --- a/packages/pexip/src/definition/PexipSettings.ts +++ b/packages/pexip/src/definition/PexipSettings.ts @@ -23,4 +23,9 @@ export type PexipSettings = { discussionsEnabled: boolean; persistentChatEnabled: boolean; }; + sip: { + addAlias: boolean; + host: string; + port: number; + }; }; diff --git a/packages/pexip/src/endpoints/endpoint.ts b/packages/pexip/src/endpoints/endpoint.ts new file mode 100644 index 0000000000000..87521772a2896 --- /dev/null +++ b/packages/pexip/src/endpoints/endpoint.ts @@ -0,0 +1,29 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; + +import type { Pexip } from '../Pexip'; + +export class PexipEndpoint { + constructor(public readonly pexip: Pexip) { + // + } + + protected getIdentificationFromAlias(alias: string): string { + if (!alias.startsWith('sip:') || !alias.includes('@')) { + return alias; + } + + return alias.substring(0, alias.indexOf('@')).replace('sip:', ''); + } + + protected async getCallByIdentification(identification: string): Promise { + if (!identification.match(/\D/g)) { + const call = await VideoConferenceModel.findOneByProviderNameAndSipAlias('core.pexip', identification); + if (call) { + return call; + } + } + + return VideoConferenceModel.findOneById(identification); + } +} diff --git a/packages/pexip/src/endpoints/eventSink.ts b/packages/pexip/src/endpoints/eventSink.ts index 5f0761c6a7e84..9de4985496126 100644 --- a/packages/pexip/src/endpoints/eventSink.ts +++ b/packages/pexip/src/endpoints/eventSink.ts @@ -1,25 +1,57 @@ import { VideoConf } from '@rocket.chat/core-services'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { MediaCalls, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; -import type { Pexip } from '../Pexip'; -import type { EventSinkRequest } from '../definition'; +import type { ConferenceEndedEventData, EventSinkRequest, ParticipantStatusEventData } from '../definition'; import { logger } from '../logger'; +import { PexipEndpoint } from './endpoint'; -export class EventSinkEndpoint { - constructor(public readonly pexip: Pexip) { - // - } - +export class EventSinkEndpoint extends PexipEndpoint { public async post(event: EventSinkRequest): Promise { - if (event.event !== 'conference_ended') { - return; + switch (event.event) { + case 'conference_ended': + return this.processConferenceEnded(event.data); + case 'participant_connected': + return this.processParticipantConnected(event.data); } + } + protected async processConferenceEnded(data: ConferenceEndedEventData): Promise { try { - await VideoConf.setStatus(event.data.name, VideoConferenceStatus.ENDED); + // TODO: end call by sip alias + await VideoConf.setStatus(data.name, VideoConferenceStatus.ENDED); } catch (err) { logger.error({ msg: 'Failed to flag conference as ended', err }); - // If the call was not found or we were unable to change the status, we must have received an alias instead of a callId and there's nothing we can do with it, so ignore any errors. + // If the call was not found or we were unable to change the status, we probably received an alias instead of a callId + } + } + + protected async processParticipantConnected(data: ParticipantStatusEventData): Promise { + logger.debug({ msg: 'Pexip Participant Connected', data }); + + const { destination_alias: conferenceUri, source_alias: participantUri, protocol } = data; + if (protocol !== 'SIP' || !conferenceUri || !participantUri) { + return; } + const conferenceSipAlias = this.getIdentificationFromAlias(conferenceUri); + const participantSipExtension = this.getIdentificationFromAlias(participantUri); + + if (!conferenceSipAlias || !participantSipExtension) { + logger.debug({ msg: 'Someone connected to a Pexip Conference via SIP, but we could not identify them.' }); + return; + } + + logger.debug({ msg: 'Pexip Participant joined via SIP', conferenceSipAlias, participantSipExtension }); + const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) + .map(({ _id }) => _id) + .toArray(); + + if (mediaCallIds.length !== 1) { + logger.debug({ msg: 'Could not identify the media call that the SIP Participant is connecting from', calls: mediaCallIds }); + return; + } + + const [mediaCallId] = mediaCallIds; + await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', conferenceSipAlias, mediaCallId); } } diff --git a/packages/pexip/src/endpoints/serviceConfiguration.ts b/packages/pexip/src/endpoints/serviceConfiguration.ts index ca8af93d3bec4..a784421e2b539 100644 --- a/packages/pexip/src/endpoints/serviceConfiguration.ts +++ b/packages/pexip/src/endpoints/serviceConfiguration.ts @@ -1,15 +1,9 @@ -import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; - -import type { Pexip } from '../Pexip'; import type { ServiceConfiguration } from '../definition/ServiceConfiguration'; import type { SerializedServiceConfigurationRequest } from '../definition/ServiceConfigurationRequest'; import { logger } from '../logger'; +import { PexipEndpoint } from './endpoint'; -export class ServerConfigurationEndpoint { - constructor(public readonly pexip: Pexip) { - // - } - +export class ServerConfigurationEndpoint extends PexipEndpoint { public async get(serviceRequest: SerializedServiceConfigurationRequest): Promise { const { local_alias: alias } = serviceRequest; if (!alias) { @@ -22,16 +16,8 @@ export class ServerConfigurationEndpoint { return this.getServiceConfigurationForIdentification(identification); } - private getIdentificationFromAlias(alias: string): string { - if (!alias.startsWith('sip:') || !alias.includes('@')) { - return alias; - } - - return alias.substring(0, alias.indexOf('@')).replace('sip:', ''); - } - private async getServiceConfigurationForIdentification(identification: string): Promise { - const call = await VideoConferenceModel.findOneById(identification); + const call = await this.getCallByIdentification(identification); if (!call) { logger.error({ msg: 'Invalid call identification', identification }); return null; diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index 720003c8f2460..a1cb7e8dc766a 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -154,7 +154,7 @@ class MediaSessionStore extends Emitter { randomStringFactory, oldSessionId: this.getOldSessionId(userId), logger: this.logger, - features: ['audio', 'screen-share', 'transfer', 'hold'], + features: ['audio', 'screen-share', 'transfer', 'hold', 'conference-escalation'], autoSync: true, }); From c6f2440fdbf8a31d6fa526f53f6694082ca3bd62 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Tue, 9 Jun 2026 19:45:12 -0300 Subject: [PATCH 02/18] feat: Voice to video escalation UI #40869 --- packages/i18n/src/locales/en.i18n.json | 5 + packages/rest-typings/src/index.ts | 2 + packages/rest-typings/src/v1/media-calls.ts | 8 + .../src/components/Actions/ActionStrip.tsx | 2 +- .../components/VideoCallButton.stories.tsx | 44 +++++ .../src/components/VideoCallButton.tsx | 29 +++ .../VideoCallWidgetAction.stories.tsx | 50 ++++++ .../src/components/VideoCallWidgetAction.tsx | 26 +++ packages/ui-voip/src/components/index.ts | 2 + .../src/context/MediaCallViewContext.ts | 5 + packages/ui-voip/src/context/definitions.d.ts | 1 + .../src/hooks/useOpenVideoCall.spec.tsx | 166 ++++++++++++++++++ .../ui-voip/src/hooks/useOpenVideoCall.tsx | 26 +++ .../src/hooks/useVoiceToVideoEscalation.tsx | 54 ++++++ .../src/providers/MediaCallViewProvider.tsx | 28 +++ .../src/providers/MockedMediaCallProvider.tsx | 3 + .../ui-voip/src/providers/useMediaSession.ts | 4 + .../ConfirmVideoEscalationModal.spec.tsx | 19 ++ .../ConfirmVideoEscalationModal.stories.tsx | 34 ++++ .../ConfirmVideoEscalationModal.tsx | 28 +++ .../ConfirmVideoEscalationModal.spec.tsx.snap | 88 ++++++++++ .../MediaCallRoomSection.tsx | 14 +- .../VideoEscalatedView.tsx | 31 ++++ .../src/views/MediaCallWidget/OngoingCall.tsx | 8 +- .../MediaCallWidget/OngoingCallWithScreen.tsx | 24 ++- .../PopupBlockedModal.spec.tsx | 19 ++ .../PopupBlockedModal.stories.tsx | 34 ++++ .../PopupBlockedModal/PopupBlockedModal.tsx | 52 ++++++ .../PopupBlockedModal.spec.tsx.snap | 112 ++++++++++++ packages/ui-voip/src/views/index.ts | 2 + 30 files changed, 912 insertions(+), 8 deletions(-) create mode 100644 packages/rest-typings/src/v1/media-calls.ts create mode 100644 packages/ui-voip/src/components/VideoCallButton.stories.tsx create mode 100644 packages/ui-voip/src/components/VideoCallButton.tsx create mode 100644 packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx create mode 100644 packages/ui-voip/src/components/VideoCallWidgetAction.tsx create mode 100644 packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx create mode 100644 packages/ui-voip/src/hooks/useOpenVideoCall.tsx create mode 100644 packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx create mode 100644 packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx create mode 100644 packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx create mode 100644 packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx create mode 100644 packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap create mode 100644 packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx create mode 100644 packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.spec.tsx create mode 100644 packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx create mode 100644 packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx create mode 100644 packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2e6e5ee959a0e..4f980628c2a1a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4880,6 +4880,7 @@ "Score": "Score", "Screen_Lock": "Screen Lock", "Screen_Share": "Screen Share", + "Screen_sharing_stopped_video_escalation": "Screen sharing was stopped because the call was escalated to a video call", "Script": "Script", "Script_Enabled": "Script Enabled", "Script_Engine": "Script Sandbox", @@ -5303,6 +5304,7 @@ "Support": "Support", "Survey": "Survey", "Survey_instructions": "Rate each question according to your satisfaction, 1 meaning you are completely unsatisfied and 5 meaning you are completely satisfied.", + "Switched_to_video_call": "Switched to video call", "Symbols": "Symbols", "Sync": "Sync", "Sync / Import": "Sync / Import", @@ -5952,6 +5954,8 @@ "VideoConf_Mobile_Ringing_Description": "When enabled, direct calls to mobile users will ring their device as a phone call.", "VideoConf_Persistent_Chat_Discussion_Name": "Persistent Chat Discussion Name", "VideoConf_Persistent_Chat_Discussion_Name_Description": "Use [date] tag to set where to include the date. Date will be added to the start if tag is not included.", + "Video_escalation_modal_description": "If you start a video call the current call will be finished, the other participants will be transferred and notified about the new call.", + "Video_escalation_modal_title": "Starting a video call", "Video_Call_unavailable_for_this_type_of_room": "Video Call is unavailable for this type of room", "Video_Chat_Window": "Video Chat", "Video_Conference": "Conference Call", @@ -7092,6 +7096,7 @@ "start-discussion-other-user": "Start Discussion (Other-User)", "start-discussion-other-user_description": "Permission to start a discussion, which gives permission to the user to create a discussion from a message sent by another user as well", "start-discussion_description": "Permission to start a discussion", + "Start_a_video_call": "Start a video call", "strike": "strike", "subscription.callout.activeUsers": "seats", "subscription.callout.allPremiumCapabilitiesDisabled": "All premium capabilities disabled", diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 9c25e22e983f8..d73f405d33602 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -29,6 +29,7 @@ import type { LDAPEndpoints } from './v1/ldap'; import type { LicensesEndpoints } from './v1/licenses'; import type { MailerEndpoints } from './v1/mailer'; import type { MeEndpoints } from './v1/me'; +import type { MediaCallsEndpoints } from './v1/media-calls'; import type { MiscEndpoints } from './v1/misc'; import type { ModerationEndpoints } from './v1/moderation'; import type { OmnichannelEndpoints } from './v1/omnichannel'; @@ -90,6 +91,7 @@ export interface Endpoints AuthEndpoints, ImportEndpoints, ServerEventsEndpoints, + MediaCallsEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< diff --git a/packages/rest-typings/src/v1/media-calls.ts b/packages/rest-typings/src/v1/media-calls.ts new file mode 100644 index 0000000000000..7f55beeafac4e --- /dev/null +++ b/packages/rest-typings/src/v1/media-calls.ts @@ -0,0 +1,8 @@ +export type MediaCallsEndpoints = { + '/v1/media-calls.escalate': { + POST: (params: { callId: string }) => { + providerName: string; + url: string; + }; + }; +}; diff --git a/packages/ui-voip/src/components/Actions/ActionStrip.tsx b/packages/ui-voip/src/components/Actions/ActionStrip.tsx index fa4606b987a7a..4bc4fe3bc18ee 100644 --- a/packages/ui-voip/src/components/Actions/ActionStrip.tsx +++ b/packages/ui-voip/src/components/Actions/ActionStrip.tsx @@ -28,7 +28,7 @@ const ActionStrip = ({ children, leftSlot, rightSlot }: ActionStripProps) => { {leftSlot} - {children} + {children && {children}} {rightSlot} diff --git a/packages/ui-voip/src/components/VideoCallButton.stories.tsx b/packages/ui-voip/src/components/VideoCallButton.stories.tsx new file mode 100644 index 0000000000000..4e08abba8b1e5 --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallButton.stories.tsx @@ -0,0 +1,44 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import VideoCallButton from './VideoCallButton'; + +const meta = { + title: 'V2/Components/VideoCallButton', + component: VideoCallButton, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Start_a_video_call: 'Start a video call', + Join_video_call: 'Join video call', + Switched_to_video_call: 'Switched to video call', + VideoConf_start_call_modal_title: 'Starting a video call', + VideoConf_start_call_modal_description: 'If you start a video call the current call will be finished. Do you want to proceed?', + Start_video_call: 'Start video call', + Cancel: 'Cancel', + }) + .buildStoryDecorator(), + ], + args: { + onClick: action('onClick'), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Start state (no active video call)', + args: { + escalated: false, + }, +}; + +export const VideoCallActive: Story = { + name: 'Active state (video call escalated)', + args: { + escalated: true, + }, +}; diff --git a/packages/ui-voip/src/components/VideoCallButton.tsx b/packages/ui-voip/src/components/VideoCallButton.tsx new file mode 100644 index 0000000000000..29ef6a34c4325 --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallButton.tsx @@ -0,0 +1,29 @@ +import { Button } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +type VideoCallButtonProps = Omit, 'onClick'> & { + escalated?: boolean; + loading?: boolean; + onClick: () => void; +}; + +const VideoCallButton = ({ escalated, loading, onClick, ...props }: VideoCallButtonProps) => { + const { t } = useTranslation(); + + return ( + <> + {escalated ? ( + + ) : ( + + )} + + ); +}; + +export default VideoCallButton; diff --git a/packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx b/packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx new file mode 100644 index 0000000000000..387a3b8b0711f --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallWidgetAction.stories.tsx @@ -0,0 +1,50 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import VideoCallWidgetAction from './VideoCallWidgetAction'; + +const meta = { + title: 'V2/Components/VideoCallWidgetAction', + component: VideoCallWidgetAction, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Start_a_video_call: 'Start a video call', + Join_video_call: 'Join video call', + Switched_to_video_call: 'Switched to video call', + }) + .buildStoryDecorator(), + ], + args: { + onClick: action('onClick'), + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default (not escalated)', + args: { + escalated: false, + loading: false, + }, +}; + +export const Escalated: Story = { + name: 'Escalated (video call active)', + args: { + escalated: true, + loading: false, + }, +}; + +export const Loading: Story = { + name: 'Loading state', + args: { + escalated: false, + loading: true, + }, +}; diff --git a/packages/ui-voip/src/components/VideoCallWidgetAction.tsx b/packages/ui-voip/src/components/VideoCallWidgetAction.tsx new file mode 100644 index 0000000000000..48e136ee03389 --- /dev/null +++ b/packages/ui-voip/src/components/VideoCallWidgetAction.tsx @@ -0,0 +1,26 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import VideoCallButton from './VideoCallButton'; + +type VideoCallWidgetActionProps = { + escalated?: boolean; + loading?: boolean; + onClick: () => void | Promise; +}; + +const VideoCallWidgetAction = ({ escalated, loading, onClick }: VideoCallWidgetActionProps) => { + const { t } = useTranslation(); + return ( + + {escalated && ( + + {t('Switched_to_video_call')} + + )} + + + ); +}; + +export default VideoCallWidgetAction; diff --git a/packages/ui-voip/src/components/index.ts b/packages/ui-voip/src/components/index.ts index aaf620b1ad009..382a9969351cf 100644 --- a/packages/ui-voip/src/components/index.ts +++ b/packages/ui-voip/src/components/index.ts @@ -15,3 +15,5 @@ export { default as DevicePicker } from './DevicePicker'; export { default as CallHistoryInternalUser } from './CallHistoryInternalUser'; export { default as CallHistoryExternalUser } from './CallHistoryExternalUser'; export { default as MediaCallWidgetSlot } from './MediaCallWidgetSlot'; +export { default as VideoCallWidgetAction } from './VideoCallWidgetAction'; +export { default as VideoCallButton } from './VideoCallButton'; diff --git a/packages/ui-voip/src/context/MediaCallViewContext.ts b/packages/ui-voip/src/context/MediaCallViewContext.ts index a85864b249607..d6eb4d33b88a0 100644 --- a/packages/ui-voip/src/context/MediaCallViewContext.ts +++ b/packages/ui-voip/src/context/MediaCallViewContext.ts @@ -12,6 +12,8 @@ export type MediaCallStreams = { export type MediaCallViewContextValue = { sessionState: SessionState; + isRequestingVideoCall: boolean; + onRequestVideoCall: () => void; onClickDirectMessage?: () => void; onMute: () => void; onHold: () => void; @@ -43,11 +45,14 @@ const defaultSessionState: SessionState = { remoteMuted: false, remoteHeld: false, callId: undefined, + escalated: false, supportedFeatures: ['audio', 'transfer', 'hold'], }; export const defaultMediaCallContextValue: MediaCallViewContextValue = { sessionState: defaultSessionState, + isRequestingVideoCall: false, + onRequestVideoCall: () => undefined, onMute: () => undefined, onHold: () => undefined, onDeviceChange: () => undefined, diff --git a/packages/ui-voip/src/context/definitions.d.ts b/packages/ui-voip/src/context/definitions.d.ts index 965d954f95675..540176d6da910 100644 --- a/packages/ui-voip/src/context/definitions.d.ts +++ b/packages/ui-voip/src/context/definitions.d.ts @@ -33,6 +33,7 @@ interface IBaseSession { remoteHeld: boolean; startedAt?: Date; hidden: boolean; + escalated?: boolean; ringing?: boolean; supportedFeatures: readonly CallFeature[]; // True when the idle dialer ('new') is docked inside a slot (the sidebar call panel) diff --git a/packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx b/packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx new file mode 100644 index 0000000000000..858d743bdf02f --- /dev/null +++ b/packages/ui-voip/src/hooks/useOpenVideoCall.spec.tsx @@ -0,0 +1,166 @@ +import { renderHook, act } from '@testing-library/react'; + +import { useOpenVideoCall } from './useOpenVideoCall'; + +const setModalMock = jest.fn(); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useSetModal: () => setModalMock, +})); + +jest.mock('../views', () => ({ + PopupBlockedModal: jest.fn(() => null), +})); + +const { PopupBlockedModal } = jest.requireMock('../views'); + +describe('useOpenVideoCall', () => { + const url = 'https://video.example.com/room'; + + beforeEach(() => { + jest.clearAllMocks(); + delete (window as any).RocketChatDesktop; + jest.spyOn(window, 'open').mockReturnValue({} as Window); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when RocketChatDesktop.openInternalVideoChatWindow is available', () => { + const openInternalVideoChatWindowMock = jest.fn(); + + beforeEach(() => { + (window as any).RocketChatDesktop = { + openInternalVideoChatWindow: openInternalVideoChatWindowMock, + }; + }); + + it('calls openInternalVideoChatWindow with the url and providerName', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url, 'Jitsi'); + }); + + expect(openInternalVideoChatWindowMock).toHaveBeenCalledWith(url, { providerName: 'Jitsi' }); + }); + + it('calls openInternalVideoChatWindow with undefined providerName when not provided', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(openInternalVideoChatWindowMock).toHaveBeenCalledWith(url, { providerName: undefined }); + }); + + it('does not call window.open', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url, 'Jitsi'); + }); + + expect(window.open).not.toHaveBeenCalled(); + }); + + it('does not show a modal', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url, 'Jitsi'); + }); + + expect(setModalMock).not.toHaveBeenCalled(); + }); + }); + + describe('when RocketChatDesktop is not available', () => { + it('calls window.open with the url', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(window.open).toHaveBeenCalledWith(url); + }); + + it('does not show a modal when window.open succeeds', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(setModalMock).not.toHaveBeenCalled(); + }); + + describe('when window.open returns null (popup blocked)', () => { + beforeEach(() => { + jest.spyOn(window, 'open').mockReturnValue(null); + }); + + it('shows PopupBlockedModal', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(setModalMock).toHaveBeenCalledTimes(1); + const [[modalElement]] = setModalMock.mock.calls; + expect(modalElement.type).toBe(PopupBlockedModal); + }); + + it('closes modal when onClose is called', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + const [[modalElement]] = setModalMock.mock.calls; + act(() => { + modalElement.props.onClose(); + }); + + expect(setModalMock).toHaveBeenLastCalledWith(null); + }); + + it('opens url in new window when onConfirm is called', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + const [[modalElement]] = setModalMock.mock.calls; + act(() => { + modalElement.props.onConfirm(); + }); + + expect(window.open).toHaveBeenLastCalledWith(url); + }); + }); + }); + + describe('when RocketChatDesktop exists but openInternalVideoChatWindow is not defined', () => { + beforeEach(() => { + (window as any).RocketChatDesktop = {}; + }); + + it('falls back to window.open', () => { + const { result } = renderHook(() => useOpenVideoCall()); + + act(() => { + result.current(url); + }); + + expect(window.open).toHaveBeenCalledWith(url); + }); + }); +}); diff --git a/packages/ui-voip/src/hooks/useOpenVideoCall.tsx b/packages/ui-voip/src/hooks/useOpenVideoCall.tsx new file mode 100644 index 0000000000000..47646bd68fa8e --- /dev/null +++ b/packages/ui-voip/src/hooks/useOpenVideoCall.tsx @@ -0,0 +1,26 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +import { PopupBlockedModal } from '../views'; + +export const useOpenVideoCall = () => { + const setModal = useSetModal(); + + return useCallback( + (url: string, providerName?: string) => { + const desktopApp = window.RocketChatDesktop; + + if (desktopApp?.openInternalVideoChatWindow) { + desktopApp.openInternalVideoChatWindow(url, { providerName }); + return; + } + + const popup = window.open(url); + + if (popup === null) { + setModal( setModal(null)} onConfirm={() => window.open(url)} />); + } + }, + [setModal], + ); +}; diff --git a/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx new file mode 100644 index 0000000000000..687f7273e7b48 --- /dev/null +++ b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx @@ -0,0 +1,54 @@ +import { useEndpoint, useSetModal } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; + +import type { SessionState } from '../context'; +import { useOpenVideoCall } from './useOpenVideoCall'; +import { ConfirmVideoEscalationModal } from '../views'; + +export const useVoiceToVideoEscalation = (sessionState: SessionState) => { + const setModal = useSetModal(); + const openVideoCall = useOpenVideoCall(); + const requestEscalation = useEndpoint('POST', '/v1/media-calls.escalate'); + + const { mutateAsync: requestVideoEscalation, isPending } = useMutation({ + mutationKey: ['request-video-escalation'], + mutationFn: requestEscalation, + }); + + const executeVideoEscalation = async () => { + if (sessionState.state !== 'ongoing') { + return; + } + + const { callId } = sessionState; + + try { + const { url, providerName } = await requestVideoEscalation({ callId }); + openVideoCall(url, providerName); + } catch (error) { + console.error('Error requesting video escalation', error); + } + }; + + const onRequestVideoCall = async () => { + if (sessionState.escalated) { + await executeVideoEscalation(); + return; + } + + setModal( + setModal(null)} + onConfirm={async () => { + setModal(null); + await executeVideoEscalation(); + }} + />, + ); + }; + + return { + isRequestingVideoCall: isPending, + onRequestVideoCall, + }; +}; diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index b51deafdd98a0..bf3c0d2a85f68 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -24,6 +24,7 @@ import MediaCallViewContext from '../context/MediaCallViewContext'; import type { PeerInfo } from '../context/definitions'; import { stopTracks, useDevicePermissionPrompt2, PermissionRequestCancelledCallRejectedError } from '../hooks/useDevicePermissionPrompt'; import { isValidTone, useTonePlayer } from '../hooks/useTonePlayer'; +import { useVoiceToVideoEscalation } from '../hooks/useVoiceToVideoEscalation'; import TransferModal from '../views/TransferModal'; export type MediaCallViewProviderProps = { @@ -41,6 +42,8 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const { sessionState, toggleWidget, openDialer, closeDialer, selectPeer } = useMediaSession(instance); const controls = useMediaSessionControls(instance); + const { onRequestVideoCall, isRequestingVideoCall } = useVoiceToVideoEscalation(sessionState); + useDesktopNotifications(sessionState); useDesktopTelephonyListener({ sessionState, toggleWidget, selectPeer }); @@ -263,8 +266,33 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { }); }, [instance, onChangePosition]); + useEffect(() => { + if (!sessionState.escalated) { + return; + } + + const state = instance?.getState(); + + if (!state?.confirmed) { + return; + } + + if (!state?.call.hasScreenVideoTrack()) { + return; + } + + try { + state.call.requestScreenShare(false); + dispatchToastMessage({ type: 'info', message: t('Screen_sharing_stopped_video_escalation') }); + } catch (error) { + console.error('Error stopping screen share', error); + } + }, [sessionState.escalated, dispatchToastMessage, t, instance]); + const contextValue = { sessionState, + isRequestingVideoCall, + onRequestVideoCall, onClickDirectMessage, onMute, onHold, diff --git a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx index a6e0b77dab472..1eb10137c26f2 100644 --- a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx @@ -142,6 +142,7 @@ const MockedMediaCallProvider = ({ remoteHeld, callId: undefined, supportedFeatures: ['audio', 'screen-share', 'transfer', 'hold'], + escalated: false, } as SessionState; const contextValue = { @@ -161,6 +162,8 @@ const MockedMediaCallProvider = ({ onToggleScreenSharing: () => undefined, onOpenPopout: () => undefined, onClosePopout: () => undefined, + isRequestingVideoCall: false, + onRequestVideoCall: () => undefined, }; const instanceContextValue = { diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts index 311e161ba6e01..a3d55ed83fbe8 100644 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -21,6 +21,7 @@ const defaultSessionInfo: SessionState = { hidden: false, supportedFeatures: ['audio', 'transfer', 'hold'], docked: false, + escalated: false, }; export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { @@ -216,6 +217,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS callId: instanceState.tempCallId, startedAt: undefined, supportedFeatures: [], + escalated: false, }, }); return; @@ -227,6 +229,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS activeTimestamp: startedAt, features: supportedFeatures, transferredBy: callTransferredBy, + escalated, remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, ringing, } = instanceState; @@ -265,6 +268,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS callId, startedAt, supportedFeatures, + escalated, ringing, }, }); diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx new file mode 100644 index 0000000000000..8ea898e59133e --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './ConfirmVideoEscalationModal.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx new file mode 100644 index 0000000000000..6b0379349c793 --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx @@ -0,0 +1,34 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ConfirmVideoEscalationModal from './ConfirmVideoEscalationModal'; + +const noop = () => undefined; + +const meta = { + title: 'Components/ConfirmVideoEscalationModal', + component: ConfirmVideoEscalationModal, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Video_escalation_modal_title: 'Start a video call?', + Video_escalation_modal_description: + 'This will escalate the current voice call to a video call. The other participant will be notified.', + Start_video_call: 'Start video call', + Cancel: 'Cancel', + }) + .buildStoryDecorator(), + (Story) => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onCancel: noop, + onConfirm: noop, + }, +}; diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx new file mode 100644 index 0000000000000..0123af2fc496e --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.tsx @@ -0,0 +1,28 @@ +import { GenericModal } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +type ConfirmVideoEscalationModalProps = { + onCancel: () => void; + onConfirm: () => void; +}; + +const ConfirmVideoEscalationModal = ({ onCancel, onConfirm }: ConfirmVideoEscalationModalProps) => { + const { t } = useTranslation(); + + return ( + + {t('Video_escalation_modal_description')} + + ); +}; + +export default ConfirmVideoEscalationModal; diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap new file mode 100644 index 0000000000000..a86c90e5b91c4 --- /dev/null +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+ +
+
+
+
+

+ Start a video call? +

+
+ +
+
+
+
+ This will escalate the current voice call to a video call. The other participant will be notified. +
+
+ +
+
+
+ +`; diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx index 250257e185c59..ca6acd7927f0e 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx +++ b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx @@ -2,6 +2,7 @@ import { Box, ButtonGroup } from '@rocket.chat/fuselage'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import VideoEscalatedView from './VideoEscalatedView'; import { ToggleButton, Timer, @@ -11,6 +12,7 @@ import { CARD_LIST_SECTION_MAX_HEIGHT, ActionStrip, ActionToggleChat, + VideoCallButton, } from '../../components'; import { useMediaCallInstance } from '../../context/MediaCallInstanceContext'; import { useMediaCallView } from '../../context/MediaCallViewContext'; @@ -57,13 +59,14 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: onToggleScreenSharing, onOpenPopout, onClosePopout, + onRequestVideoCall, streams: { localScreen }, } = useMediaCallView(); const { currentViews } = useMediaCallInstance(); const isPopout = currentViews.includes('popout'); - const { muted, held, peerInfo, connectionState, startedAt } = sessionState; + const { muted, held, peerInfo, connectionState, startedAt, escalated } = sessionState; const shouldWrapCards = useShouldWrapCards(showChat, containerHeight); @@ -99,7 +102,14 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: {...getSplitStyles(showChat)} > {showHeaderActions && } />} - {isPopout ? : } + {!escalated ? } /> : null} + {isPopout ? ( + + ) : escalated ? ( + + ) : ( + + )} diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx new file mode 100644 index 0000000000000..2b66719e8b10e --- /dev/null +++ b/packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx @@ -0,0 +1,31 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import VideoCallButton from '../../components/VideoCallButton'; +import { useMediaCallView } from '../../context'; + +const VideoEscalatedView = () => { + const { t } = useTranslation(); + const { isRequestingVideoCall, onRequestVideoCall } = useMediaCallView(); + + return ( + + + {t('Switched_to_video_call')} + + + + ); +}; + +export default VideoEscalatedView; diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx index 276170f67eb7c..ac528452db3bc 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx @@ -16,6 +16,7 @@ import { ActionButton, useKeypad, useInfoSlots, + VideoCallWidgetAction, } from '../../components'; import { useMediaCallView } from '../../context/MediaCallViewContext'; import { useMediaCallWidgetSlot } from '../../context/MediaCallWidgetSlotContext'; @@ -26,8 +27,9 @@ import { isExternalPeer } from '../../utils/isExternalPeer'; const OngoingCall = () => { const { t } = useTranslation(); - const { sessionState, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = useMediaCallView(); - const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures, startedAt } = sessionState; + const { sessionState, isRequestingVideoCall, onRequestVideoCall, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = + useMediaCallView(); + const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures, startedAt, escalated } = sessionState; const { inline } = useMediaCallWidgetSlot(); // The floating widget keeps its collapsible DTMF toggle for every ongoing call. @@ -43,6 +45,7 @@ const OngoingCall = () => { const transferDisabled = !supportedFeatures.includes('transfer'); const holdDisabled = !supportedFeatures.includes('hold'); + const videoConfAvailable = supportedFeatures.includes('conference-escalation'); const appActions = useVisibleAppActions(); @@ -63,6 +66,7 @@ const OngoingCall = () => { + {videoConfAvailable && } diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx index 0ff9038e6199d..a47ca5a988782 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx @@ -17,6 +17,7 @@ import { useInfoSlots, CardWidgetContainer, StreamCard, + VideoCallWidgetAction, } from '../../components'; import { useMediaCallInstance } from '../../context'; import { useMediaCallView } from '../../context/MediaCallViewContext'; @@ -30,6 +31,8 @@ const OngoingCall = () => { const { sessionState, + isRequestingVideoCall, + onRequestVideoCall, onMute, onHold, onForward, @@ -41,7 +44,7 @@ const OngoingCall = () => { widgetPositionTracker, onClosePopout, } = useMediaCallView(); - const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, startedAt } = sessionState; + const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, startedAt, escalated, supportedFeatures } = sessionState; const { currentViews } = useMediaCallInstance(); const isPopout = currentViews.includes('popout'); @@ -58,6 +61,10 @@ const OngoingCall = () => { const appActions = useVisibleAppActions(); + const transferDisabled = !supportedFeatures.includes('transfer'); + const holdDisabled = !supportedFeatures.includes('hold'); + const videoConfAvailable = supportedFeatures.includes('conference-escalation'); + // TODO: Figure out how to ensure this always exist before rendering the component if (!peerInfo) { throw new Error('Peer info is required'); @@ -122,6 +129,10 @@ const OngoingCall = () => { )} )} + + {videoConfAvailable && ( + + )} @@ -134,9 +145,10 @@ const OngoingCall = () => { { pressed={localScreen?.active ?? false} onToggle={onToggleScreenSharing} /> - + [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx new file mode 100644 index 0000000000000..a5563b55cb8a2 --- /dev/null +++ b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.stories.tsx @@ -0,0 +1,34 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import PopupBlockedModal from './PopupBlockedModal'; + +const noop = () => undefined; + +const meta = { + title: 'Components/PopupBlockedModal', + component: PopupBlockedModal, + decorators: [ + mockAppRoot() + .withSetting('Site_Url', 'https://your.rocket.chat') + .withTranslations('en', 'core', { + Open_call_in_new_tab: 'Open call in new tab', + Open_call: 'Open call', + Your_web_browser_blocked_Rocket_Chat_from_opening_tab: 'Your web browser blocked Rocket.Chat from opening a new tab.', + To_prevent_seeing_this_message_again_allow_popups_from_workspace_URL: 'To prevent seeing this message again, allow popups from ', + }) + .buildStoryDecorator(), + (Story) => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onClose: noop, + onConfirm: noop, + }, +}; diff --git a/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx new file mode 100644 index 0000000000000..c0db80195bb64 --- /dev/null +++ b/packages/ui-voip/src/views/PopupBlockedModal/PopupBlockedModal.tsx @@ -0,0 +1,52 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type PopupBlockedModalProps = { + onClose: () => void; + onConfirm: () => void; +}; + +const PopupBlockedModal = ({ onClose, onConfirm }: PopupBlockedModalProps) => { + const { t } = useTranslation(); + const workspaceUrl = useSetting('Site_Url'); + + const confirmButtonContent = ( + + + {t('Open_call')} + + ); + + const handleConfirm = useCallback(() => { + onConfirm(); + onClose(); + }, [onClose, onConfirm]); + + return ( + + <> + {t('Your_web_browser_blocked_Rocket_Chat_from_opening_tab')} + + {t('To_prevent_seeing_this_message_again_allow_popups_from_workspace_URL')} + + {workspaceUrl as string} + + + + + ); +}; + +export default PopupBlockedModal; diff --git a/packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap b/packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap new file mode 100644 index 0000000000000..fbc632a4106d2 --- /dev/null +++ b/packages/ui-voip/src/views/PopupBlockedModal/__snapshots__/PopupBlockedModal.spec.tsx.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+ +
+
+
+
+

+ Open call in new tab +

+
+ +
+
+
+
+
+ Your web browser blocked Rocket.Chat from opening a new tab. +
+
+ To prevent seeing this message again, allow popups from + + https://your.rocket.chat + +
+
+
+ +
+
+
+ +`; diff --git a/packages/ui-voip/src/views/index.ts b/packages/ui-voip/src/views/index.ts index 4592a1809131b..2f5fbbbc3f746 100644 --- a/packages/ui-voip/src/views/index.ts +++ b/packages/ui-voip/src/views/index.ts @@ -1,6 +1,8 @@ export { default as TransferModal } from './TransferModal'; export * from './MediaCallWidget'; export { default as PermissionFlowModal, type PermissionFlowModalType } from './PermissionFlow/PermissionFlowModal'; +export { default as ConfirmVideoEscalationModal } from './ConfirmVideoEscalationModal/ConfirmVideoEscalationModal'; +export { default as PopupBlockedModal } from './PopupBlockedModal/PopupBlockedModal'; export * from './MediaCallHistoryTable'; export * from './CallHistoryContextualbar'; export * from './MediaCallRoomSection'; From 3d26aab3afd2020d1646818c7662b7ddb551e4cd Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 18 Jun 2026 13:34:38 -0300 Subject: [PATCH 03/18] new pexip setting: extra params for escalated calls --- .../services/video-conference/service.ts | 8 ++++ apps/meteor/server/settings/pexip.ts | 8 ++++ packages/i18n/src/locales/en.i18n.json | 2 + .../src/models/IMediaCallsModel.ts | 1 + packages/models/src/models/MediaCalls.ts | 11 +++++ packages/pexip/package.json | 1 + .../pexip/src/definition/PexipSettings.ts | 1 + packages/pexip/src/videoConfProvider.ts | 47 ++++++++++++++++--- yarn.lock | 1 + 9 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 9fa17e34b2f1a..2745d17431110 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -25,6 +25,7 @@ import type { Optional, ExternalVideoConference, IVoIPVideoConference, + RequiredField, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -1088,6 +1089,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return 'Rocket.Chat'; } + private requireCallUrl(call: ExternalVideoConference): asserts call is RequiredField { + if (!call.url) { + throw new Error('Call url is missing'); + } + } + private async getUrl( call: ExternalVideoConference, user?: AtLeast, @@ -1101,6 +1108,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf call.url = await this.generateNewUrl(call); await VideoConferenceModel.setUrlById(call._id, call.url); } + this.requireCallUrl(call); const userData = user && { _id: user._id, diff --git a/apps/meteor/server/settings/pexip.ts b/apps/meteor/server/settings/pexip.ts index 827dbcadf7cfc..8c15c8cb64ef8 100644 --- a/apps/meteor/server/settings/pexip.ts +++ b/apps/meteor/server/settings/pexip.ts @@ -25,6 +25,13 @@ export function createPexipSettings(): Promise { i18nDescription: `Pexip_Integration_Meeting_Url_Description`, }); + await this.add('Pexip_Integration_Escalation_Params', 'join=1&muteCamera=true', { + type: 'string', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_Escalation_Params_Description`, + }); + await this.section('Pexip_Integration_API', async function () { await this.add('Pexip_Integration_API_Username', '', { type: 'string', @@ -129,6 +136,7 @@ export function getPexipSettings(): PexipSettings { enabled: settings.get('Pexip_Integration_Enabled'), baseUrl: settings.get('Pexip_Integration_Base_Url'), meetingUrl: settings.get('Pexip_Integration_Meeting_Url'), + escalationParams: settings.get('Pexip_Integration_Escalation_Params'), api: { username: settings.get('Pexip_Integration_API_Username'), password: settings.get('Pexip_Integration_API_Password'), diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 4f980628c2a1a..eaaca559e22e5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4223,6 +4223,8 @@ "Pexip_Integration_Customization": "Customization", "Pexip_Integration_Enabled": "Enable Pexip Integration", "Pexip_Integration_Enabled_Description": "When enabled, Pexip will be registered as a Conference Call provider. You may need to set it as your default provider on the conference settings if you have multiple providers.", + "Pexip_Integration_Escalation_Params": "Additional Params for Escalated Calls", + "Pexip_Integration_Escalation_Params_Description": "Set additional params to be used exclusively on conferences initiated from Voice Calls.", "Pexip_Integration_Guest_Pin": "Static Guest Pin", "Pexip_Integration_Guest_Pin_Description": "A pin code to grant meeting access to guests. Leave empty to generate a new unique pin for every meeting.", "Pexip_Integration_Host_Pin": "Static Host Pin", diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 28b1b99c32563..b2dd786b11b30 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -36,6 +36,7 @@ export interface IMediaCallsModel extends IBaseModel { findAllNotOverByOppositeSipExtension(sipExtension: string, options?: FindOptions): FindCursor; hasUnfinishedCalls(): Promise; hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise; + isUserInCallIds(uid: IUser['_id'], callIds: string[]): Promise; updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 5ca72359f545d..c15dacf1d4875 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -261,6 +261,17 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return count > 0; } + public async isUserInCallIds(uid: IUser['_id'], callIds: string[]): Promise { + const count = await this.countDocuments( + { + uids: uid, + _id: { $in: callIds }, + }, + { limit: 1 }, + ); + return count > 0; + } + public async updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, diff --git a/packages/pexip/package.json b/packages/pexip/package.json index e1ebb8ee4a1c2..b58bc4e8ead0d 100644 --- a/packages/pexip/package.json +++ b/packages/pexip/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@rocket.chat/core-services": "workspace:^", + "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/models": "workspace:^", "ajv": "^8.17.1" diff --git a/packages/pexip/src/definition/PexipSettings.ts b/packages/pexip/src/definition/PexipSettings.ts index 773652a428770..6231841089531 100644 --- a/packages/pexip/src/definition/PexipSettings.ts +++ b/packages/pexip/src/definition/PexipSettings.ts @@ -4,6 +4,7 @@ export type PexipSettings = { enabled: boolean; baseUrl: string; meetingUrl: string; + escalationParams: string; api: { username: string; password: string; diff --git a/packages/pexip/src/videoConfProvider.ts b/packages/pexip/src/videoConfProvider.ts index a40846eadd9db..2dae8b9cae50d 100644 --- a/packages/pexip/src/videoConfProvider.ts +++ b/packages/pexip/src/videoConfProvider.ts @@ -1,8 +1,9 @@ import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; -import type { VideoConference, AtLeast, IRoom, IVideoConferenceUser } from '@rocket.chat/core-typings'; -import { Rooms } from '@rocket.chat/models'; +import type { VideoConference, AtLeast, IRoom, IVideoConferenceUser, RequiredField } from '@rocket.chat/core-typings'; +import { MediaCalls, Rooms } from '@rocket.chat/models'; import type { Pexip } from './Pexip'; +import { logger } from './logger'; export class PexipVideoConfProvider { public readonly name = 'Pexip'; @@ -111,14 +112,48 @@ export class PexipVideoConfProvider { return url; } - public async customizeUrl(call: VideoConference, user: IVideoConferenceUser | undefined): Promise { + public async customizeUrl(call: RequiredField, user: IVideoConferenceUser | undefined): Promise { const pin = await this.getPinForUser(call, user); + const escalationParams = this.getEscalationParams(); - const { url } = call; + const { url: userUrl } = call; - const nameSuffix = user?.name ? `&name=${user.name}` : ''; + const url = new URL(userUrl); + if (user) { + const { _id: uid, name } = user; - return `${url}&pin=${pin}${nameSuffix}`; + if (escalationParams?.size && (await this.isEscalatedUser(call, uid))) { + for (const [key, value] of escalationParams) { + url.searchParams.set(key, value); + } + } + + if (name) { + url.searchParams.set('name', name); + } + } + + url.searchParams.set('pin', pin); + return url.toString(); + } + + private getEscalationParams(): URLSearchParams | null { + try { + return new URLSearchParams(this.pexip.settings.escalationParams); + } catch (err) { + logger.error({ msg: 'Failed to parse Pexip Escalation Params', err }); + return null; + } + } + + private async isEscalatedUser(conference: VideoConference, uid: string): Promise { + const { mediaCallIds } = conference; + + if (!mediaCallIds?.length) { + return false; + } + + return MediaCalls.isUserInCallIds(uid, mediaCallIds); } public async onNewVideoConference(call: VideoConference): Promise { diff --git a/yarn.lock b/yarn.lock index 08ead76eadb3c..bcadae6c7719d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10416,6 +10416,7 @@ __metadata: resolution: "@rocket.chat/pexip@workspace:packages/pexip" dependencies: "@rocket.chat/core-services": "workspace:^" + "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/models": "workspace:^" From c8d9c0448c14e6782cfb9155f6950668f3c2bc18 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 19 Jun 2026 19:05:20 -0300 Subject: [PATCH 04/18] feat: identify remote escalation --- .../server/services/media-call/service.ts | 47 ++++++++++++------- .../src/definition/IMediaCallServer.ts | 5 +- .../media-calls/src/server/MediaCallServer.ts | 11 ++++- .../src/sip/providers/BaseSipCall.ts | 29 +++++++++--- .../src/types/IMediaCallService.ts | 1 + .../core-typings/src/mediaCalls/IMediaCall.ts | 1 + .../src/models/IMediaCallsModel.ts | 1 + .../src/models/IVideoConferenceModel.ts | 1 + packages/models/src/models/MediaCalls.ts | 15 ++++++ packages/models/src/models/VideoConference.ts | 10 ++++ packages/pexip/src/endpoints/eventSink.ts | 35 ++++++++++++-- 11 files changed, 127 insertions(+), 29 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index c024fe46e822a..fddc3bb907e33 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -484,24 +484,31 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall const { callId } = params; const call = await MediaCalls.findOneById(callId); - if (!call?.acceptedAt) { + if (!call?.acceptedAt || call.ended) { throw new Error('not-found'); } if (!call.uids.includes(uid)) { throw new Error('not-found'); } + if (!call.features.includes('conference-escalation')) { + throw new Error('feature-not-available'); + } const user = await Users.findOneById(uid); if (!user) { throw new Error('internal-error'); } - // if (!call.features.includes('conference-escalation')) { - // throw new Error('feature-not-available'); - // } - const url = await this.escalateVoiceCallToConference(user, call); + + // If the peer has also escalated this call, then we can hangup as we join the conference + if (call.escalatedByPeerAt) { + void callServer.hangupEscalatedCall(call, { type: 'user', id: user._id }).catch((err) => { + logger.error({ msg: 'Unexpected error while hanging up a fully escalated voice call', err }); + }); + } + return url; } @@ -521,18 +528,8 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return result; } - private async findExistingConferenceForCall(call: IMediaCall): Promise { - const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id); - if (existingConference) { - return existingConference; - } - - // TODO: find some call escalated from the other side? - return null; - } - private async getOrCreateConferenceForEscalatingCall(call: IMediaCall, user: IUser): Promise { - const existingConference = await this.findExistingConferenceForCall(call); + const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id); if (existingConference) { return existingConference; } @@ -584,4 +581,22 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall }); } } + + public async flagAsRemotelyEscalatedByCallId(callId: string): Promise { + const call = await MediaCalls.findOneById(callId, { projection: { _id: 1, escalatedByPeerAt: 1, uids: 1 } }); + if (!call || call.escalatedByPeerAt) { + return; + } + + const updateResult = await MediaCalls.flagAsRemotelyEscalatedByCallId(call._id); + if (!updateResult.modifiedCount) { + return; + } + + if (!call.escalatedAt) { + await this.notifyEscalatedCall(call); + } + + // TODO: maybe hangup if escalatedAt is already set? + } } diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index c92e20e6c16b6..05fb81daff303 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -1,8 +1,8 @@ -import type { IUser, MediaCallContact } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser, MediaCallContact } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling'; -import type { InternalCallParams, SignalProcessingOptions } from './common'; +import type { InternalCallParams, MediaCallHeader, SignalProcessingOptions } from './common'; export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered'; export type VoipPushNotificationEventType = 'new' | 'answer' | 'end'; @@ -62,6 +62,7 @@ export interface IMediaCallServer { hangupExpiredCalls(): Promise; scheduleExpirationCheck(): void; configure(settings: IMediaCallServerSettings): void; + hangupEscalatedCall(call: MediaCallHeader, endedBy?: IMediaCall['endedBy']): Promise; requestCall(params: InternalCallParams): Promise; diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 99501d65a895d..911a313745da6 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,4 +1,4 @@ -import type { IUser, MediaCallContact } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser, MediaCallContact } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, @@ -18,7 +18,7 @@ import type { VoipPushNotificationEventType, } from '../definition/IMediaCallServer'; import { CallRejectedError } from '../definition/common'; -import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams } from '../definition/common'; +import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams, MediaCallHeader } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; import { logger } from '../logger'; @@ -132,6 +132,13 @@ export class MediaCallServer implements IMediaCallServer { return mediaCallDirector.hangupExpiredCalls(); } + public async hangupEscalatedCall(call: MediaCallHeader, endedBy?: IMediaCall['endedBy']): Promise { + return mediaCallDirector.hangupDetachedCall(call, { + reason: 'conference-escalation', + ...(endedBy && { endedBy }), + }); + } + public scheduleExpirationCheck(): void { mediaCallDirector.scheduleExpirationCheck(); } diff --git a/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts b/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts index 000f80aee3739..a783e894b00fd 100644 --- a/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/BaseSipCall.ts @@ -53,6 +53,8 @@ export abstract class BaseSipCall extends BaseCallProvider { if (newContact) { const header = req.has('p-asserted-identity') ? req.get('p-asserted-identity') : req.get('from'); + + // If the call's updated identity includes the pexip SIP host, treat it as an escalated call. if (header && this.session.isPexipIdentity(header)) { await this.processEscalatedRemotely(callingNumber); } @@ -61,19 +63,23 @@ export abstract class BaseSipCall extends BaseCallProvider { } } + /** + * Flag a call as escalated by peer based on a contact change on the SIP negotiation + */ protected async processEscalatedRemotely(sipAlias: string): Promise { - if (this.call.escalatedAt) { + // The call might have already been flagged as escalated by the event sink, so do nothing in that case + if (this.call.escalatedByPeerAt) { return; } - const updateResult = await MediaCalls.flagAsEscalatedByCallId(this.call._id); + const updateResult = await MediaCalls.flagAsRemotelyEscalatedByCallId(this.call._id); if (!updateResult.modifiedCount) { return; } const conference = await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', sipAlias, this.call._id); if (!conference) { - // TODO: maybe rollback `flagAsEscalatedByCallId` ? + // TODO: maybe rollback `flagAsRemotelyEscalatedByCallId` ? return; } @@ -300,13 +306,16 @@ export abstract class BaseSipCall extends BaseCallProvider { } } + /** + * The call has been flagged as escalated by a rocket.chat user, so update the SIP dialog accordingly + */ protected async processEscalatedCall(call: IMediaCall): Promise { if (this.lastCallState === 'hangup' || !call.escalatedAt) { return; } - if (!this.sipDialog || this.processedEscalation) { - if (call.ended) { + if (!this.sipDialog || this.processedEscalation || call.escalatedByPeerAt) { + if (call.ended || call.escalatedByPeerAt) { return this.processEndedCall(call); } return; @@ -336,6 +345,14 @@ export abstract class BaseSipCall extends BaseCallProvider { return; } + // Check again to avoid race conditions + if (this.processedEscalation) { + if (call.ended) { + return this.processEndedCall(call); + } + return; + } + logger.debug({ msg: 'Processing Call Escalation', callId: call._id, lastCallState: this.lastCallState, type: this.constructor.name }); this.processedEscalation = true; @@ -343,7 +360,7 @@ export abstract class BaseSipCall extends BaseCallProvider { // If the conference is already associated with two voice calls, then the remote SIP leg is already in it, do not refer if (mediaCallIds.length >= 2) { if (!call.ended) { - void mediaCallDirector.hangupByServer(call, 'escalated-remotely'); + void mediaCallDirector.hangupByServer(call, 'remote-conference-escalation'); } return; } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 8c777f788e247..e221ebaad2ab5 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -8,4 +8,5 @@ export interface IMediaCallService { hangupExpiredCalls(): Promise; getUserStateSignals(uid: IUser['_id'], contractId: string): Promise; escalateCall(uid: IUser['_id'], params: { callId: string }): Promise; + flagAsRemotelyEscalatedByCallId(callId: string): Promise; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 2e2faf8a20e1c..255f2c953031b 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -71,6 +71,7 @@ export interface IMediaCall extends IRocketChatRecord { uids: IUser['_id'][]; escalatedAt?: Date; + escalatedByPeerAt?: Date; /** The list of features that may be used in this call. Values are final once the call is accepted. */ features: string[]; diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index b2dd786b11b30..7ff2a0ee570ab 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -42,4 +42,5 @@ export interface IMediaCallsModel extends IBaseModel { participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, ): Promise; flagAsEscalatedByCallId(callId: string): Promise; + flagAsRemotelyEscalatedByCallId(callId: string): Promise; } diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index edc2db6b0a0cc..6ae2fa5e640f6 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -82,6 +82,7 @@ export interface IVideoConferenceModel extends IBaseModel { providerName: string, sipAlias: string, mediaCallId: string, + options?: { minCreatedAt?: Date; maxCreatedAt?: Date }, ): Promise | null>; findOneByProviderNameAndSipAlias( diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index c15dacf1d4875..8b5307bc02bfc 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -167,6 +167,21 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public async flagAsRemotelyEscalatedByCallId(callId: string): Promise { + return this.updateOne( + { + _id: callId, + ended: false, + escalatedByPeerAt: { $exists: false }, + }, + { + $set: { + escalatedByPeerAt: new Date(), + }, + }, + ); + } + public async setExpiresAtById(callId: string, expiresAt: Date): Promise { return this.updateOne( { diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index 0098712e6118d..35d7d9aa9387d 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -336,13 +336,23 @@ export class VideoConferenceRaw extends BaseRaw implements IVid providerName: string, sipAlias: string, mediaCallId: string, + options: { minCreatedAt?: Date; maxCreatedAt?: Date } = {}, ): Promise | null> { + const { minCreatedAt, maxCreatedAt } = options; + const filterCreatedAt = Boolean(minCreatedAt || maxCreatedAt); + return this.findOneAndUpdate( { providerName, sipAlias, status: VideoConferenceStatus.STARTED, mediaCallIds: { $not: { $eq: mediaCallId } }, + ...(filterCreatedAt && { + createdAt: { + ...(minCreatedAt && { $gte: minCreatedAt }), + ...(maxCreatedAt && { $lte: maxCreatedAt }), + }, + }), }, { $addToSet: { diff --git a/packages/pexip/src/endpoints/eventSink.ts b/packages/pexip/src/endpoints/eventSink.ts index 9de4985496126..8e7d8d4a83065 100644 --- a/packages/pexip/src/endpoints/eventSink.ts +++ b/packages/pexip/src/endpoints/eventSink.ts @@ -1,4 +1,4 @@ -import { VideoConf } from '@rocket.chat/core-services'; +import { VideoConf, MediaCall } from '@rocket.chat/core-services'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { MediaCalls, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; @@ -29,10 +29,17 @@ export class EventSinkEndpoint extends PexipEndpoint { protected async processParticipantConnected(data: ParticipantStatusEventData): Promise { logger.debug({ msg: 'Pexip Participant Connected', data }); - const { destination_alias: conferenceUri, source_alias: participantUri, protocol } = data; + const { destination_alias: conferenceUri, source_alias: participantUri, protocol, connect_time: participantConnectTime } = data; if (protocol !== 'SIP' || !conferenceUri || !participantUri) { return; } + + void this.detectVoiceCallEscalation(conferenceUri, participantUri, participantConnectTime).catch((err) => { + logger.debug({ msg: 'Unexpected error checking wether Conference Participant is an escalated voice call.', err }); + }); + } + + private async detectVoiceCallEscalation(conferenceUri: string, participantUri: string, participantConnectTime: number): Promise { const conferenceSipAlias = this.getIdentificationFromAlias(conferenceUri); const participantSipExtension = this.getIdentificationFromAlias(participantUri); @@ -41,6 +48,17 @@ export class EventSinkEndpoint extends PexipEndpoint { return; } + let connectTime: Date; + try { + connectTime = new Date(participantConnectTime * 1000); + if (isNaN(connectTime.valueOf())) { + throw new Error('invalid connect time'); + } + } catch { + logger.debug({ msg: 'Participant connect time could not be parsed' }); + return; + } + logger.debug({ msg: 'Pexip Participant joined via SIP', conferenceSipAlias, participantSipExtension }); const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) .map(({ _id }) => _id) @@ -52,6 +70,17 @@ export class EventSinkEndpoint extends PexipEndpoint { } const [mediaCallId] = mediaCallIds; - await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', conferenceSipAlias, mediaCallId); + // call must have been created before the user connected + const maxCreatedAt = connectTime; + // but no more than 1 minute before + const minCreatedAt = new Date(connectTime.valueOf() - 60 * 1000); + + const conference = await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', conferenceSipAlias, mediaCallId, { + minCreatedAt, + maxCreatedAt, + }); + if (conference) { + await MediaCall.flagAsRemotelyEscalatedByCallId(mediaCallId); + } } } From c74fe19153f4452f4fd4a3344caa008cbb4721aa Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 22 Jun 2026 14:05:46 -0300 Subject: [PATCH 05/18] feat: find DM for escalated conferences --- .../server/services/media-call/service.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index fddc3bb907e33..66fb0b7ffd23d 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -543,7 +543,9 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } private async createConferenceForEscalatingCall(user: IUser, call: IMediaCall): Promise { - const rid = 'GENERAL'; + // TODO: ensure there are two legs with the same uid pair + // TODO: if no DM can be used, use a fixed room from the settings instead + const rid = (await this.getRoomIdForExternalCall(call)) || 'GENERAL'; return VideoConf.createEscalatedConference({ rid, @@ -556,6 +558,43 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall }); } + private async getRoomIdForExternalCall(call: IMediaCall): Promise { + const callerUid = call.caller.uid; + const calleeUid = call.callee.uid; + + if (!callerUid || !calleeUid) { + return null; + } + + try { + const uids = [callerUid, calleeUid]; + const uniqueUids = [...new Set(uids)]; + + const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uniqueUids, { projection: { _id: 1 } }); + if (room) { + return room._id; + } + + const dmCreatorId = call.caller.type === 'user' ? callerUid : calleeUid; + + const usernames = ( + await Users.findByIds(uids, { projection: { username: 1 } }) + .map((user) => user.username) + .toArray() + ).filter((username) => username); + + if (usernames.length !== 2) { + throw new Error('Invalid usernames for DM.'); + } + + const newRoom = await createDirectMessage(usernames, dmCreatorId, false); + return newRoom.rid; + } catch (err) { + logger.error({ msg: 'Failed to determine DM room for external call', err }); + return null; + } + } + private async flagAsEscalated(call: IMediaCall): Promise { if (call.escalatedAt) { return; From 26a879e183437ba427f15a5b16396b376ed8b995 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 22 Jun 2026 15:00:26 -0300 Subject: [PATCH 06/18] feat: identify proper room to use as parent when creating an escalated conference --- .../server/services/media-call/service.ts | 25 ++++++++++++++++--- apps/meteor/server/settings/pexip.ts | 9 +++++++ packages/i18n/src/locales/en.i18n.json | 3 +++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 66fb0b7ffd23d..fb46c95d54c15 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -544,8 +544,10 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall private async createConferenceForEscalatingCall(user: IUser, call: IMediaCall): Promise { // TODO: ensure there are two legs with the same uid pair - // TODO: if no DM can be used, use a fixed room from the settings instead - const rid = (await this.getRoomIdForExternalCall(call)) || 'GENERAL'; + const rid = await this.getRoomIdForExternalCall(call); + if (!rid) { + throw new Error('Could not find parent room to create the conference on'); + } return VideoConf.createEscalatedConference({ rid, @@ -563,7 +565,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall const calleeUid = call.callee.uid; if (!callerUid || !calleeUid) { - return null; + return this.parsePersistentChatExternalRoom(); } try { @@ -591,8 +593,25 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return newRoom.rid; } catch (err) { logger.error({ msg: 'Failed to determine DM room for external call', err }); + return this.parsePersistentChatExternalRoom(); + } + } + + private parsePersistentChatExternalRoom(): string | null { + const settingValue = settings.get('Pexip_Integration_PersistentChat_ExternalRoom'); + if (!settingValue || typeof settingValue !== 'object' || !Array.isArray(settingValue) || !settingValue.length) { return null; } + + for (const value of settingValue) { + if (!value || typeof value !== 'object' || !value._id) { + continue; + } + + return value._id; + } + + return null; } private async flagAsEscalated(call: IMediaCall): Promise { diff --git a/apps/meteor/server/settings/pexip.ts b/apps/meteor/server/settings/pexip.ts index 8c15c8cb64ef8..0569de0e43931 100644 --- a/apps/meteor/server/settings/pexip.ts +++ b/apps/meteor/server/settings/pexip.ts @@ -128,6 +128,15 @@ export function createPexipSettings(): Promise { i18nDescription: `Pexip_Integration_SIP_Port_Description`, }); }); + + await this.section('Pexip_Integration_PersistentChat', async function () { + await this.add('Pexip_Integration_PersistentChat_ExternalRoom', '', { + type: 'roomPick', + public: true, + invalidValue: '', + i18nDescription: `Pexip_Integration_PersistentChat_ExternalRoom_Description`, + }); + }); }); } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index eaaca559e22e5..c28b4e1d58b13 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4247,6 +4247,9 @@ "Pexip_Integration_Meeting_Url_Description": "The path added to the base URL to form the full meeting URL.", "Pexip_Integration_Overlay_Text": "Participant name overlay text", "Pexip_Integration_Overlay_Text_Description": "The display names or aliases of all participants are shown in a text overlay along the bottom of their video image.", + "Pexip_Integration_PersistentChat": "Persistent Chat", + "Pexip_Integration_PersistentChat_ExternalRoom": "Parent Room for External Conferences", + "Pexip_Integration_PersistentChat_ExternalRoom_Description": "Define a room to use as parent when a conference is created automatically.", "Pexip_Integration_Pins": "Static Pins", "Pexip_Integration_Theme_Name": "Theme Name", "Pexip_Integration_Theme_Name_Description": "Name of the pexip theme to be used on Rocket.Chat calls", From fe5930001f2095b03d6432bd19433950aeeec725 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 22 Jun 2026 15:39:29 -0300 Subject: [PATCH 07/18] feat: send videoconf message for escalated calls --- .../server/services/media-call/service.ts | 15 +++++----- .../services/video-conference/service.ts | 28 +++++++++++++++++-- .../src/types/IVideoConfService.ts | 4 ++- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index fb46c95d54c15..fef86472ecc35 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -9,6 +9,7 @@ import type { VideoConference, AtLeast, IGroupVideoConference, + IRegisterUser, } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; @@ -549,15 +550,13 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('Could not find parent room to create the conference on'); } - return VideoConf.createEscalatedConference({ - rid, - createdBy: { - _id: user._id, - name: user.name as string, - username: user.username as string, + return VideoConf.createEscalatedConference( + { + rid, + mediaCallIds: [call._id], }, - mediaCallIds: [call._id], - }); + user as IRegisterUser, + ); } private async getRoomIdForExternalCall(call: IMediaCall): Promise { diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 2745d17431110..74b24f344d79b 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -26,6 +26,7 @@ import type { ExternalVideoConference, IVoIPVideoConference, RequiredField, + IRegisterUser, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -567,7 +568,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return videoConfTypes.getTypeForRoom(room, allowRinging); } - private async createMessage(call: VideoConference, createdBy?: IUser, customBlocks?: IMessage['blocks']): Promise { + private async createMessage( + call: AtLeast, + createdBy?: IUser, + customBlocks?: IMessage['blocks'], + ): Promise { const record = { t: 'videoconf', msg: i18n.t('Video_Conference', { @@ -897,18 +902,37 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } public async createEscalatedConference( - data: Required>, + data: Required>, + user: IRegisterUser, ): Promise { const providerName = 'core.pexip'; + const { _id, name, username } = user; + const callId = await VideoConferenceModel.createGroup({ ...data, // TODO: custom title title: 'Escalated Media Call', providerName, + createdBy: { + _id, + name, + username, + }, }); await this.maybeAddSipAliasToCall(callId, providerName); + await this.maybeCreateDiscussion(callId); + + const callData = { + _id: callId, + providerName, + rid: data.rid, + }; + + const messageId = await this.createMessage(callData, user); + await VideoConferenceModel.setMessageById(callId, 'started', messageId); + return VideoConferenceModel.findOneById(callId); } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index d445e44b1e436..93c034b5e1e57 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -2,6 +2,7 @@ import type { AtLeast, ExternalVideoConference, IGroupVideoConference, + IRegisterUser, IRoom, IStats, IUser, @@ -53,6 +54,7 @@ export interface IVideoConfService { options: VideoConferenceJoinOptions, ): Promise; createEscalatedConference( - data: Required>, + data: Required>, + user: IRegisterUser, ): Promise; } From 3c15dec4cb9c2dc9a1b2e8d007197bad35c99ee6 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 24 Jun 2026 14:50:16 -0300 Subject: [PATCH 08/18] feat: skip "call ended" sound effect on escalated calls --- .../src/internal/agents/UserActorAgent.ts | 30 ++++++++++++++- .../src/definition/call/IClientMediaCall.ts | 3 ++ .../definition/signals/server/notification.ts | 4 +- packages/media-signaling/src/lib/Call.ts | 37 +++++++++++++++---- packages/media-signaling/src/lib/Session.ts | 37 +++++++++---------- .../src/providers/MediaCallViewProvider.tsx | 6 +-- 6 files changed, 85 insertions(+), 32 deletions(-) diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 45c0284418c3f..c0d866131da8c 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -1,6 +1,6 @@ import type { IMediaCall, MediaCallSignedContact } from '@rocket.chat/core-typings'; -import { isBusyState } from '@rocket.chat/media-signaling'; -import type { ClientMediaSignal, ServerMediaSignal, CallFeature } from '@rocket.chat/media-signaling'; +import { isBusyState, isCallHangupReason } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaSignal, CallFeature, CallHangupReason } from '@rocket.chat/media-signaling'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; @@ -56,9 +56,35 @@ export class UserActorAgent extends BaseMediaCallAgent { callId, type: 'notification', notification: 'hangup', + hangupReason: await this.getCallHangupReasonForClient(callId), }); } + private async getCallHangupReasonForClient(callId: string): Promise { + const call = await MediaCalls.findOneById>(callId, { + projection: { endedBy: 1, hangupReason: 1, escalatedAt: 1 }, + }); + if (!call) { + return 'remote'; + } + + const { endedBy, hangupReason, escalatedAt } = call; + // If we requested an escalation, treat the hangup as normal + if (escalatedAt) { + return 'normal'; + } + + if (endedBy?.type !== this.actorType || endedBy?.id !== this.actorId) { + return 'remote'; + } + + if (hangupReason && isCallHangupReason(hangupReason)) { + return hangupReason; + } + + return 'remote'; + } + public async onCallActive(callId: string): Promise { return this.sendSignal({ callId, diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 7ca263c206c2c..215c66e91c253 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -48,6 +48,8 @@ export const callHangupReasonList = [ ] as const; export type CallHangupReason = (typeof callHangupReasonList)[number]; +export const isCallHangupReason = (reason: string): reason is CallHangupReason => + (callHangupReasonList as readonly string[]).includes(reason); export const callAnswerList = [ 'accept', // actor accepts the call @@ -120,6 +122,7 @@ export interface IClientMediaCall { getStats(selector?: MediaStreamTrack | null): Promise; isFeatureAvailable(feature: CallFeature): boolean; hasFlag(flag: CallFlag): boolean; + shouldSkipSoundEffects(): boolean; readonly localParticipant: IClientMediaCallLocalParticipant; readonly remoteParticipants: IClientMediaCallRemoteParticipant[]; diff --git a/packages/media-signaling/src/definition/signals/server/notification.ts b/packages/media-signaling/src/definition/signals/server/notification.ts index 5581bfa263093..e29c0a43395c2 100644 --- a/packages/media-signaling/src/definition/signals/server/notification.ts +++ b/packages/media-signaling/src/definition/signals/server/notification.ts @@ -1,4 +1,4 @@ -import type { CallFeature, CallNotification } from '../../call'; +import type { CallFeature, CallHangupReason, CallNotification } from '../../call'; /** Server is sending a notification about the call state */ export type ServerMediaSignalNotification = { @@ -13,4 +13,6 @@ export type ServerMediaSignalNotification = { signedContractId?: string; features?: CallFeature[]; + + hangupReason?: CallHangupReason; }; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 4d67eea60e5fb..ca01dffbfba2c 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -248,6 +248,8 @@ export class ClientMediaCall implements IClientMediaCall { private escalated: boolean; + private hangupReason: CallHangupReason | null; + private _flags: CallFlag[]; public get flags(): CallFlag[] { @@ -338,6 +340,7 @@ export class ClientMediaCall implements IClientMediaCall { this.receivedRemoteSdp = false; this.enabledFeatures = null; this.escalated = false; + this.hangupReason = null; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); @@ -669,7 +672,7 @@ export class ClientMediaCall implements IClientMediaCall { if (!this.hasRemoteData) { // if the call is over, we no longer need to wait for its data if (signal.type === 'notification' && signal.notification === 'hangup') { - this.changeState('hangup'); + this.setHangupState(signal.hangupReason); return; } @@ -730,7 +733,7 @@ export class ClientMediaCall implements IClientMediaCall { } this.config.transporter.answer(this.callId, 'reject'); - this.changeState('hangup'); + this.setHangupState('rejected'); } public transfer(callee: { type: CallActorType; id: string }): void { @@ -944,6 +947,18 @@ export class ClientMediaCall implements IClientMediaCall { return this._flags.includes(flag); } + public shouldSkipSoundEffects(): boolean { + if (this.hidden) { + return true; + } + + if (this.hangupReason === 'normal') { + return true; + } + + return false; + } + private canChangeToState(newState: CallState): boolean { if (newState === this._state) { return false; @@ -1174,7 +1189,7 @@ export class ClientMediaCall implements IClientMediaCall { } this.config.transporter.answer(this.callId, 'unavailable'); - this.changeState('hangup'); + this.setHangupState('unavailable'); } protected async processEarlySignals(): Promise { @@ -1223,7 +1238,7 @@ export class ClientMediaCall implements IClientMediaCall { break; case 'hangup': - return this.flagAsEnded('remote'); + return this.flagAsEnded('remote', signal.hangupReason); case 'escalated': return this.flagAsEscalated(); } @@ -1272,16 +1287,24 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('accepted'); } - private flagAsEnded(reason: CallHangupReason): void { - this.config.logger?.debug('ClientMediaCall.flagAsEnded', reason); + private flagAsEnded(reasonForServer: CallHangupReason, reasonForClient?: CallHangupReason): void { + this.config.logger?.debug('ClientMediaCall.flagAsEnded', reasonForServer, reasonForClient); if (this._state === 'hangup') { return; } if (!this.hidden && this.hasRemoteData) { - this.config.transporter.hangup(this.callId, reason); + this.config.transporter.hangup(this.callId, reasonForServer); } + this.setHangupState(reasonForClient || reasonForServer); + } + + private setHangupState(reason?: CallHangupReason): void { + if (reason) { + this.hangupReason = reason; + this.config.logger?.debug('Hangup Reason:', reason); + } this.changeState('hangup'); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 5b2c16a1189d3..09cd917db97d6 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -22,7 +22,7 @@ export type MediaSignalingEvents = { sessionStateChange: void; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; - endedCall: void; + endedCall: { call: IClientMediaCall; wasHidden: boolean }; hiddenCall: void; registered: { activeCalls: IClientMediaCall['callId'][] }; outOfSync: { missingCalls: IClientMediaCall['callId'][] }; @@ -71,7 +71,7 @@ export class MediaSignalingSession extends Emitter { private lastRegisterTimestamp: Date | null = null; - private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + private lastState: { mainCall: ClientMediaCall | null; hidden: boolean; busy: boolean }; private sessionEnded = false; @@ -101,7 +101,7 @@ export class MediaSignalingSession extends Emitter { this.deviceId = null; this.currentDeviceId = null; this.callsToGetUserMedia = 0; - this.lastState = { hasCall: false, hasVisibleCall: false, hasBusyCall: false }; + this.lastState = { mainCall: null, hidden: false, busy: false }; this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); this.registration = new SessionRegistration({ @@ -750,40 +750,39 @@ export class MediaSignalingSession extends Emitter { } private onSessionStateChange(): void { - const hadCall = this.lastState.hasCall; - const hadVisibleCall = this.lastState.hasVisibleCall; - const hadBusyCall = this.lastState.hasBusyCall; + const { mainCall: oldCall, hidden: wasHidden, busy: wasBusy } = this.lastState; if (!this.registration.active) { - if (hadCall) { - this.emit('endedCall'); + if (oldCall) { + this.emit('endedCall', { call: oldCall, wasHidden }); } this.config.logger?.debug('skipping session events on inactive session'); return; } // Do not skip local calls if we transitioned from a different active call to it - const mainCall = this.getMainCall(!hadCall); - const hasCall = Boolean(mainCall); - const hasVisibleCall = Boolean(mainCall && !mainCall.hidden); - const hasBusyCall = Boolean(hasVisibleCall && mainCall?.busy); + const mainCall = this.getMainCall(!oldCall); + const hidden = mainCall?.hidden ?? false; + const busy = mainCall?.busy ?? false; - this.lastState = { hasCall, hasVisibleCall, hasBusyCall }; + this.lastState = { mainCall, hidden, busy }; - if (mainCall && !hadCall) { + if (mainCall && !oldCall) { this.emit('newCall', { call: mainCall }); } - if (mainCall && hasBusyCall && !hadBusyCall) { + if (mainCall && busy && !wasBusy) { this.emit('acceptedCall', { call: mainCall }); } this.emit('sessionStateChange'); this.requestInputTrackUpdate(); - if (hadCall && !hasCall) { - this.emit('endedCall'); - } else if (hadVisibleCall && !hasVisibleCall) { - this.emit('hiddenCall'); + if (oldCall) { + if (!mainCall) { + this.emit('endedCall', { call: oldCall, wasHidden }); + } else if (!wasHidden && hidden) { + this.emit('hiddenCall'); + } } } } diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index bf3c0d2a85f68..0a24ef18989e5 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -73,14 +73,14 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { if (!instance) { return; } - return instance.on('endedCall', () => { - if (sessionState.hidden) { + return instance.on('endedCall', ({ call, wasHidden }) => { + if (wasHidden || call.shouldSkipSoundEffects()) { return; } callback(); }); }, - [instance, sessionState.hidden], + [instance], ), ); From c3f8dab6da890b1e53fac39ea1f811117917a3b3 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 24 Jun 2026 16:40:47 -0300 Subject: [PATCH 09/18] feat: identify escalated calls on pexip policy server and skip PIN requirement --- .../src/models/IMediaCallsModel.ts | 1 + .../src/models/IVideoConferenceModel.ts | 3 +- packages/models/src/models/MediaCalls.ts | 14 ++++ packages/models/src/models/VideoConference.ts | 18 ++--- packages/pexip/src/endpoints/eventSink.ts | 44 +---------- .../src/endpoints/serviceConfiguration.ts | 74 +++++++++++++++++-- 6 files changed, 98 insertions(+), 56 deletions(-) diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 7ff2a0ee570ab..56800897ff4b0 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -37,6 +37,7 @@ export interface IMediaCallsModel extends IBaseModel { hasUnfinishedCalls(): Promise; hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise; isUserInCallIds(uid: IUser['_id'], callIds: string[]): Promise; + isUserSipExtensionInCallIds(sipExtension: string, callIds: string[]): Promise; updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 6ae2fa5e640f6..fcd1849f75e16 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -82,7 +82,6 @@ export interface IVideoConferenceModel extends IBaseModel { providerName: string, sipAlias: string, mediaCallId: string, - options?: { minCreatedAt?: Date; maxCreatedAt?: Date }, ): Promise | null>; findOneByProviderNameAndSipAlias( @@ -90,4 +89,6 @@ export interface IVideoConferenceModel extends IBaseModel { sipAlias: string, options?: FindOptions, ): Promise; + + addMediaCallIdByConferenceId(conferenceId: string, mediaCallId: string): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 8b5307bc02bfc..d2b21850f31ba 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -287,6 +287,20 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return count > 0; } + public async isUserSipExtensionInCallIds(sipExtension: string, callIds: string[]): Promise { + const count = await this.countDocuments( + { + _id: { $in: callIds }, + $or: [ + { 'caller.type': 'user', 'caller.sipExtension': sipExtension }, + { 'callee.type': 'user', 'callee.sipExtension': sipExtension }, + ], + }, + { limit: 1 }, + ); + return count > 0; + } + public async updateParticipantsById( callId: string, participants: { caller?: MediaCallSignedContact; callee?: MediaCallSignedContact }, diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index 35d7d9aa9387d..10d8b33d1c9a2 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -336,23 +336,13 @@ export class VideoConferenceRaw extends BaseRaw implements IVid providerName: string, sipAlias: string, mediaCallId: string, - options: { minCreatedAt?: Date; maxCreatedAt?: Date } = {}, ): Promise | null> { - const { minCreatedAt, maxCreatedAt } = options; - const filterCreatedAt = Boolean(minCreatedAt || maxCreatedAt); - return this.findOneAndUpdate( { providerName, sipAlias, status: VideoConferenceStatus.STARTED, mediaCallIds: { $not: { $eq: mediaCallId } }, - ...(filterCreatedAt && { - createdAt: { - ...(minCreatedAt && { $gte: minCreatedAt }), - ...(maxCreatedAt && { $lte: maxCreatedAt }), - }, - }), }, { $addToSet: { @@ -365,6 +355,14 @@ export class VideoConferenceRaw extends BaseRaw implements IVid ); } + public async addMediaCallIdByConferenceId(conferenceId: string, mediaCallId: string): Promise { + return this.updateOneById(conferenceId, { + $addToSet: { + mediaCallIds: mediaCallId, + }, + }); + } + public async setSipAliasById(callId: string, sipAlias: string): Promise { await this.updateOne({ _id: callId }, { $set: { sipAlias } }); } diff --git a/packages/pexip/src/endpoints/eventSink.ts b/packages/pexip/src/endpoints/eventSink.ts index 8e7d8d4a83065..a2398db0887cf 100644 --- a/packages/pexip/src/endpoints/eventSink.ts +++ b/packages/pexip/src/endpoints/eventSink.ts @@ -1,6 +1,5 @@ -import { VideoConf, MediaCall } from '@rocket.chat/core-services'; +import { VideoConf } from '@rocket.chat/core-services'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; -import { MediaCalls, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; import type { ConferenceEndedEventData, EventSinkRequest, ParticipantStatusEventData } from '../definition'; import { logger } from '../logger'; @@ -29,17 +28,15 @@ export class EventSinkEndpoint extends PexipEndpoint { protected async processParticipantConnected(data: ParticipantStatusEventData): Promise { logger.debug({ msg: 'Pexip Participant Connected', data }); - const { destination_alias: conferenceUri, source_alias: participantUri, protocol, connect_time: participantConnectTime } = data; + const { destination_alias: conferenceUri, source_alias: participantUri, protocol } = data; if (protocol !== 'SIP' || !conferenceUri || !participantUri) { return; } - void this.detectVoiceCallEscalation(conferenceUri, participantUri, participantConnectTime).catch((err) => { - logger.debug({ msg: 'Unexpected error checking wether Conference Participant is an escalated voice call.', err }); - }); + void this.detectVoiceCallEscalation(conferenceUri, participantUri).catch(() => null); } - private async detectVoiceCallEscalation(conferenceUri: string, participantUri: string, participantConnectTime: number): Promise { + private async detectVoiceCallEscalation(conferenceUri: string, participantUri: string): Promise { const conferenceSipAlias = this.getIdentificationFromAlias(conferenceUri); const participantSipExtension = this.getIdentificationFromAlias(participantUri); @@ -48,39 +45,6 @@ export class EventSinkEndpoint extends PexipEndpoint { return; } - let connectTime: Date; - try { - connectTime = new Date(participantConnectTime * 1000); - if (isNaN(connectTime.valueOf())) { - throw new Error('invalid connect time'); - } - } catch { - logger.debug({ msg: 'Participant connect time could not be parsed' }); - return; - } - logger.debug({ msg: 'Pexip Participant joined via SIP', conferenceSipAlias, participantSipExtension }); - const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) - .map(({ _id }) => _id) - .toArray(); - - if (mediaCallIds.length !== 1) { - logger.debug({ msg: 'Could not identify the media call that the SIP Participant is connecting from', calls: mediaCallIds }); - return; - } - - const [mediaCallId] = mediaCallIds; - // call must have been created before the user connected - const maxCreatedAt = connectTime; - // but no more than 1 minute before - const minCreatedAt = new Date(connectTime.valueOf() - 60 * 1000); - - const conference = await VideoConferenceModel.addMediaCallIdByProviderNameAndSipAlias('core.pexip', conferenceSipAlias, mediaCallId, { - minCreatedAt, - maxCreatedAt, - }); - if (conference) { - await MediaCall.flagAsRemotelyEscalatedByCallId(mediaCallId); - } } } diff --git a/packages/pexip/src/endpoints/serviceConfiguration.ts b/packages/pexip/src/endpoints/serviceConfiguration.ts index a784421e2b539..1b95830cea4d6 100644 --- a/packages/pexip/src/endpoints/serviceConfiguration.ts +++ b/packages/pexip/src/endpoints/serviceConfiguration.ts @@ -1,3 +1,7 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import type { VideoConference } from '@rocket.chat/core-typings'; +import { MediaCalls, VideoConference as VideoConferenceModel } from '@rocket.chat/models'; + import type { ServiceConfiguration } from '../definition/ServiceConfiguration'; import type { SerializedServiceConfigurationRequest } from '../definition/ServiceConfigurationRequest'; import { logger } from '../logger'; @@ -5,18 +9,24 @@ import { PexipEndpoint } from './endpoint'; export class ServerConfigurationEndpoint extends PexipEndpoint { public async get(serviceRequest: SerializedServiceConfigurationRequest): Promise { - const { local_alias: alias } = serviceRequest; + const { local_alias: alias, protocol = null, remote_alias: participantUri = null } = serviceRequest; + logger.debug({ msg: 'Processing Pexip Policy Server Request', alias, protocol }); + if (!alias) { logger.error(`No call identification received in the request.`); return null; } const identification = this.getIdentificationFromAlias(alias); + const participantSipUri = protocol === 'sip' ? participantUri : null; - return this.getServiceConfigurationForIdentification(identification); + return this.getServiceConfigurationForIdentification(identification, participantSipUri); } - private async getServiceConfigurationForIdentification(identification: string): Promise { + private async getServiceConfigurationForIdentification( + identification: string, + participantSipUri: string | null, + ): Promise { const call = await this.getCallByIdentification(identification); if (!call) { logger.error({ msg: 'Invalid call identification', identification }); @@ -28,10 +38,13 @@ export class ServerConfigurationEndpoint extends PexipEndpoint { const [hostPin, guestPin] = await this.pexip.createAndStorePinsForCall(call); - return this.makeServiceConfiguration(call._id, title, hostPin, guestPin); + const canSkipPin = await this.detectVoiceCallEscalation(call, participantSipUri); + const guestPinToUse = canSkipPin ? null : guestPin; + + return this.makeServiceConfiguration(call._id, title, hostPin, guestPinToUse); } - private makeServiceConfiguration(name: string, title: string, hostPin: string, guestPin: string): ServiceConfiguration { + private makeServiceConfiguration(name: string, title: string, hostPin: string, guestPin: string | null): ServiceConfiguration { const { customization } = this.pexip.settings; return { @@ -50,4 +63,55 @@ export class ServerConfigurationEndpoint extends PexipEndpoint { enable_overlay_text: customization.overlayText, }; } + + private async detectVoiceCallEscalation(conference: VideoConference, participantUri: string | null): Promise { + if (!participantUri) { + return false; + } + + const { sipAlias, mediaCallIds: linkedMediaCallIds } = conference; + + if (!sipAlias) { + return false; + } + + const participantSipExtension = this.getIdentificationFromAlias(participantUri); + + if (!participantSipExtension) { + logger.debug({ msg: 'Someone connected to a Pexip Conference via SIP, but we could not identify them.' }); + return false; + } + + logger.debug({ + msg: 'Pexip Participant joined via SIP', + sipAlias: conference.sipAlias, + conferenceId: conference._id, + participantSipExtension, + }); + + const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) + .map(({ _id }) => _id) + .toArray(); + + if (mediaCallIds.length !== 1) { + // Check if the user is already linked to the conference + if (linkedMediaCallIds?.length) { + if (await MediaCalls.isUserSipExtensionInCallIds(participantSipExtension, linkedMediaCallIds)) { + return true; + } + } + + logger.debug({ msg: 'Could not identify the media call that the SIP Participant is connecting from', calls: mediaCallIds }); + return mediaCallIds.length > 0; + } + + const [mediaCallId] = mediaCallIds; + + const updateResult = await VideoConferenceModel.addMediaCallIdByConferenceId(conference._id, mediaCallId); + if (updateResult.modifiedCount) { + await MediaCall.flagAsRemotelyEscalatedByCallId(mediaCallId); + } + + return true; + } } From 35d859da4f255274ee305256988b02145613a0b9 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Mon, 22 Jun 2026 16:59:50 -0300 Subject: [PATCH 10/18] refactor(ui-voip): memoize MediaCallRoomSection content --- .../MediaCallRoomSection.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx index ca6acd7927f0e..8b975c1151cc3 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx +++ b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx @@ -1,5 +1,5 @@ import { Box, ButtonGroup } from '@rocket.chat/fuselage'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import VideoEscalatedView from './VideoEscalatedView'; @@ -85,6 +85,18 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: const holdAvailable = features.includes('hold'); const transferAvailable = features.includes('transfer'); + const content = useMemo(() => { + if (isPopout) { + return ; + } + + if (escalated) { + return ; + } + + return ; + }, [isPopout, escalated, user, shouldWrapCards, onClosePopout]); + if (!peerInfo || !('userId' in peerInfo) || !peerInfo.userId) { return null; } @@ -103,13 +115,9 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: > {showHeaderActions && } />} {!escalated ? } /> : null} - {isPopout ? ( - - ) : escalated ? ( - - ) : ( - - )} + + {content} + From 5a8c2a01595bc74d3cf20a3d7172c9f9c7df8378 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 24 Jun 2026 16:00:28 -0300 Subject: [PATCH 11/18] refactor: memoize callbacks in useVoiceToVideoEscalation --- packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx index 687f7273e7b48..1c4ddf56c54f3 100644 --- a/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx +++ b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx @@ -1,5 +1,6 @@ import { useEndpoint, useSetModal } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; import type { SessionState } from '../context'; import { useOpenVideoCall } from './useOpenVideoCall'; @@ -15,7 +16,7 @@ export const useVoiceToVideoEscalation = (sessionState: SessionState) => { mutationFn: requestEscalation, }); - const executeVideoEscalation = async () => { + const executeVideoEscalation = useCallback(async () => { if (sessionState.state !== 'ongoing') { return; } @@ -28,9 +29,9 @@ export const useVoiceToVideoEscalation = (sessionState: SessionState) => { } catch (error) { console.error('Error requesting video escalation', error); } - }; + }, [sessionState, requestVideoEscalation, openVideoCall]); - const onRequestVideoCall = async () => { + const onRequestVideoCall = useCallback(async () => { if (sessionState.escalated) { await executeVideoEscalation(); return; @@ -45,7 +46,7 @@ export const useVoiceToVideoEscalation = (sessionState: SessionState) => { }} />, ); - }; + }, [sessionState.escalated, setModal, executeVideoEscalation]); return { isRequestingVideoCall: isPending, From 5d0267058bff88f0c0d8f69c45ae374feea8bcca Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 24 Jun 2026 16:01:53 -0300 Subject: [PATCH 12/18] refactor: add error toast when escalation fails --- packages/i18n/src/locales/en.i18n.json | 1 + packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c28b4e1d58b13..e7757d59a62cb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5651,6 +5651,7 @@ "Unable_to_load_active_connections": "Unable to load active connections", "Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing", "Unable_to_negotiate_call_params": "Unable to negotiate call params.", + "Unable_to_start_video_call": "Unable to start video call.", "Unarchive": "Unarchive", "Unassign_extension": "Unassign extension", "Unassigned": "Unassigned", diff --git a/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx index 1c4ddf56c54f3..22f00134cc342 100644 --- a/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx +++ b/packages/ui-voip/src/hooks/useVoiceToVideoEscalation.tsx @@ -1,4 +1,4 @@ -import { useEndpoint, useSetModal } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import { useCallback } from 'react'; @@ -7,6 +7,7 @@ import { useOpenVideoCall } from './useOpenVideoCall'; import { ConfirmVideoEscalationModal } from '../views'; export const useVoiceToVideoEscalation = (sessionState: SessionState) => { + const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); const openVideoCall = useOpenVideoCall(); const requestEscalation = useEndpoint('POST', '/v1/media-calls.escalate'); @@ -27,9 +28,10 @@ export const useVoiceToVideoEscalation = (sessionState: SessionState) => { const { url, providerName } = await requestVideoEscalation({ callId }); openVideoCall(url, providerName); } catch (error) { + dispatchToastMessage({ type: 'error', message: 'Unable_to_start_video_call' }); console.error('Error requesting video escalation', error); } - }, [sessionState, requestVideoEscalation, openVideoCall]); + }, [sessionState, requestVideoEscalation, openVideoCall, dispatchToastMessage]); const onRequestVideoCall = useCallback(async () => { if (sessionState.escalated) { From 22118c71c14df78a0104193b8dbb1e9a282b08dc Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Wed, 24 Jun 2026 16:04:23 -0300 Subject: [PATCH 13/18] feat: integrate video escalation to popout window --- .../ui-voip/src/hooks/useOpenVideoCall.tsx | 8 +++++--- .../src/providers/useMediaSessionInstance.ts | 8 ++++++++ ...alatedView.tsx => EscalatedCallPrompt.tsx} | 9 +++++---- .../ui-voip/src/views/MediaCallPopoutView.tsx | 17 +++++++++++----- .../MediaCallRoomSection.tsx | 20 +++++++++++-------- 5 files changed, 42 insertions(+), 20 deletions(-) rename packages/ui-voip/src/views/{MediaCallRoomSection/VideoEscalatedView.tsx => EscalatedCallPrompt.tsx} (75%) diff --git a/packages/ui-voip/src/hooks/useOpenVideoCall.tsx b/packages/ui-voip/src/hooks/useOpenVideoCall.tsx index 47646bd68fa8e..6d09ac77dd9d7 100644 --- a/packages/ui-voip/src/hooks/useOpenVideoCall.tsx +++ b/packages/ui-voip/src/hooks/useOpenVideoCall.tsx @@ -1,10 +1,12 @@ import { useSetModal } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; +import { useActiveCallWindow } from '../providers/useMediaSessionInstance'; import { PopupBlockedModal } from '../views'; export const useOpenVideoCall = () => { const setModal = useSetModal(); + const activeWindow = useActiveCallWindow(); return useCallback( (url: string, providerName?: string) => { @@ -15,12 +17,12 @@ export const useOpenVideoCall = () => { return; } - const popup = window.open(url); + const popup = activeWindow.open(url); if (popup === null) { - setModal( setModal(null)} onConfirm={() => window.open(url)} />); + setModal( setModal(null)} onConfirm={() => activeWindow.open(url)} />); } }, - [setModal], + [activeWindow, setModal], ); }; diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index a1cb7e8dc766a..d97412572d97c 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -46,6 +46,10 @@ class MediaSessionStore extends Emitter { private popoutWindow: Window | undefined; + public get callExternalWindow(): Window | undefined { + return this.popoutWindow; + } + constructor() { super(); } @@ -217,6 +221,10 @@ export const useSetPopoutWindow = (popoutWindow?: Window) => { }); }; +export const useActiveCallWindow = () => { + return mediaSession.callExternalWindow || window; +}; + export const useMediaSessionInstance = (userId?: string) => { const { t } = useTranslation(); const iceServers = useIceServers(); diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx b/packages/ui-voip/src/views/EscalatedCallPrompt.tsx similarity index 75% rename from packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx rename to packages/ui-voip/src/views/EscalatedCallPrompt.tsx index 2b66719e8b10e..fb20218e3ff3f 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/VideoEscalatedView.tsx +++ b/packages/ui-voip/src/views/EscalatedCallPrompt.tsx @@ -1,15 +1,16 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from 'react-i18next'; -import VideoCallButton from '../../components/VideoCallButton'; -import { useMediaCallView } from '../../context'; +import VideoCallButton from '../components/VideoCallButton'; +import { useMediaCallView } from '../context'; -const VideoEscalatedView = () => { +const EscalatedCallPrompt = () => { const { t } = useTranslation(); const { isRequestingVideoCall, onRequestVideoCall } = useMediaCallView(); return ( { ); }; -export default VideoEscalatedView; +export default EscalatedCallPrompt; diff --git a/packages/ui-voip/src/views/MediaCallPopoutView.tsx b/packages/ui-voip/src/views/MediaCallPopoutView.tsx index c5ed862b3ae7e..d38422812395a 100644 --- a/packages/ui-voip/src/views/MediaCallPopoutView.tsx +++ b/packages/ui-voip/src/views/MediaCallPopoutView.tsx @@ -3,7 +3,8 @@ import { useResizeObserver } from '@rocket.chat/fuselage-hooks'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ToggleButton, Timer, DevicePicker, ActionButton, useShouldWrapCards, ActionStrip } from '../components'; +import { ToggleButton, Timer, DevicePicker, ActionButton, useShouldWrapCards, ActionStrip, VideoCallButton } from '../components'; +import EscalatedCallPrompt from './EscalatedCallPrompt'; import MediaCallCardList from './MediaCallCardList'; import { useMediaCallView } from '../context/MediaCallViewContext'; import AppActions from '../experimental/AppActionButtons/components/AppActions'; @@ -29,10 +30,11 @@ const MediaCallPopoutView = ({ user, onClickClosePopout, onClickFullscreen, full onForward, onEndCall, onToggleScreenSharing, + onRequestVideoCall, streams: { localScreen }, } = useMediaCallView(); - const { muted, held, peerInfo, connectionState, startedAt } = sessionState; + const { muted, held, peerInfo, connectionState, startedAt, escalated, supportedFeatures } = sessionState; const { ref, borderBoxSize } = useResizeObserver(); @@ -43,7 +45,11 @@ const MediaCallPopoutView = ({ user, onClickClosePopout, onClickFullscreen, full const appActions = useVisibleAppActions(); - const showHeaderActions = appActions.length > 0; + const showAppActions = appActions.length > 0; + + const escalationAvailable = supportedFeatures.includes('conference-escalation'); + + const showHeaderActions = escalationAvailable && !escalated; if (!peerInfo || 'number' in peerInfo) { return null; @@ -62,8 +68,9 @@ const MediaCallPopoutView = ({ user, onClickClosePopout, onClickFullscreen, full flexDirection='column' ref={ref} > - {showHeaderActions && } />} - + {showAppActions && } />} + {showHeaderActions ? } /> : null} + {escalationAvailable && escalated ? : } diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx index 8b975c1151cc3..5f59900107160 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx +++ b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx @@ -2,7 +2,6 @@ import { Box, ButtonGroup } from '@rocket.chat/fuselage'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import VideoEscalatedView from './VideoEscalatedView'; import { ToggleButton, Timer, @@ -20,6 +19,7 @@ import { usePeekMediaSessionFeatures } from '../../context/usePeekMediaSessionFe import useRegisterView from '../../context/useRegisterView'; import AppActions from '../../experimental/AppActionButtons/components/AppActions'; import { useVisibleAppActions } from '../../experimental/AppActionButtons/hooks/useVisibleAppActions'; +import EscalatedCallPrompt from '../EscalatedCallPrompt'; import MediaCallCardList from '../MediaCallCardList'; import PopoutDockPrompt from '../PopoutDockPrompt'; @@ -66,18 +66,20 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: const isPopout = currentViews.includes('popout'); - const { muted, held, peerInfo, connectionState, startedAt, escalated } = sessionState; + const { muted, held, peerInfo, connectionState, startedAt, escalated, supportedFeatures } = sessionState; const shouldWrapCards = useShouldWrapCards(showChat, containerHeight); const connecting = connectionState === 'CONNECTING'; const reconnecting = connectionState === 'RECONNECTING'; + const escalationAvailable = supportedFeatures.includes('conference-escalation'); + useRegisterView('room'); const appActions = useVisibleAppActions(); - const showHeaderActions = appActions.length > 0; + const showAppActions = appActions.length > 0; const features = usePeekMediaSessionFeatures(); @@ -90,12 +92,14 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: return ; } - if (escalated) { - return ; + if (escalationAvailable && escalated) { + return ; } return ; - }, [isPopout, escalated, user, shouldWrapCards, onClosePopout]); + }, [isPopout, escalationAvailable, escalated, user, shouldWrapCards, onClosePopout]); + + const showHeaderActions = escalationAvailable && !escalated; if (!peerInfo || !('userId' in peerInfo) || !peerInfo.userId) { return null; @@ -113,8 +117,8 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: aria-label={t('Voice_call')} {...getSplitStyles(showChat)} > - {showHeaderActions && } />} - {!escalated ? } /> : null} + {showAppActions && } />} + {showHeaderActions ? } /> : null} {content} From 2dc9c7d5fb7a5f9ddc666afae7c90c59b32f9c6e Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 30 Jun 2026 13:46:13 -0300 Subject: [PATCH 14/18] chore: disable transfer / hold / screen-share on escalated calls --- .../server/services/media-call/service.ts | 14 +++-- ee/packages/media-calls/src/constants.ts | 1 + ee/packages/media-calls/src/index.ts | 1 + packages/media-signaling/src/lib/Call.ts | 9 ++-- .../src/endpoints/serviceConfiguration.ts | 52 ++++++++++--------- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index fef86472ecc35..b338897bda929 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -12,7 +12,7 @@ import type { IRegisterUser, } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; -import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls'; +import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall, ESCALATED_CALL_FEATURES } from '@rocket.chat/media-calls'; import type { CallFeature, ClientMediaSignal, @@ -623,24 +623,28 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return; } - await this.notifyEscalatedCall(call); + await this.notifyEscalatedCall(call, Boolean(call.escalatedByPeerAt)); api.broadcast('media-call.updated', { callId: call._id, }); } - private async notifyEscalatedCall(call: AtLeast): Promise { + private async notifyEscalatedCall(call: AtLeast, escalatedByPeer = false): Promise { for (const uid of call.uids) { await this.sendSignal(uid, { callId: call._id, type: 'notification', notification: 'escalated', + ...(escalatedByPeer && + call.features && { + features: call.features.filter((feature: any): feature is CallFeature => ESCALATED_CALL_FEATURES.includes(feature)), + }), }); } } public async flagAsRemotelyEscalatedByCallId(callId: string): Promise { - const call = await MediaCalls.findOneById(callId, { projection: { _id: 1, escalatedByPeerAt: 1, uids: 1 } }); + const call = await MediaCalls.findOneById(callId, { projection: { _id: 1, escalatedByPeerAt: 1, uids: 1, features: 1 } }); if (!call || call.escalatedByPeerAt) { return; } @@ -651,7 +655,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } if (!call.escalatedAt) { - await this.notifyEscalatedCall(call); + await this.notifyEscalatedCall(call, true); } // TODO: maybe hangup if escalatedAt is already set? diff --git a/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts index 11684481b79f1..15b1ce331f905 100644 --- a/ee/packages/media-calls/src/constants.ts +++ b/ee/packages/media-calls/src/constants.ts @@ -2,3 +2,4 @@ import type { CallFeature } from '@rocket.chat/media-signaling'; export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio']; export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold', 'screen-share', 'conference-escalation']; +export const ESCALATED_CALL_FEATURES: CallFeature[] = ['audio', 'conference-escalation']; diff --git a/ee/packages/media-calls/src/index.ts b/ee/packages/media-calls/src/index.ts index 26cc20f190ec1..58864b6b1de30 100644 --- a/ee/packages/media-calls/src/index.ts +++ b/ee/packages/media-calls/src/index.ts @@ -1,4 +1,5 @@ export type * from './definition/IMediaCallServer'; +export * from './constants'; export { callServer } from './server/configuration'; export { getSignalsForExistingCall } from './server/signals/getSignalsForExistingCall'; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index ca01dffbfba2c..d48166283d3c0 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -1240,7 +1240,7 @@ export class ClientMediaCall implements IClientMediaCall { case 'hangup': return this.flagAsEnded('remote', signal.hangupReason); case 'escalated': - return this.flagAsEscalated(); + return this.flagAsEscalated(signal.features); } } @@ -1308,12 +1308,15 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('hangup'); } - private flagAsEscalated(): void { + private flagAsEscalated(overrideFeatures?: CallFeature[]): void { if (this.escalated) { return; } - this.config.logger?.debug('ClientMediaCall.flagAsEscalated'); + this.config.logger?.debug('ClientMediaCall.flagAsEscalated', overrideFeatures || ''); + if (overrideFeatures) { + this.enabledFeatures = overrideFeatures; + } this.escalated = true; this.emitter.emit('escalated'); diff --git a/packages/pexip/src/endpoints/serviceConfiguration.ts b/packages/pexip/src/endpoints/serviceConfiguration.ts index 1b95830cea4d6..70cfd3318afb3 100644 --- a/packages/pexip/src/endpoints/serviceConfiguration.ts +++ b/packages/pexip/src/endpoints/serviceConfiguration.ts @@ -82,34 +82,38 @@ export class ServerConfigurationEndpoint extends PexipEndpoint { return false; } - logger.debug({ - msg: 'Pexip Participant joined via SIP', - sipAlias: conference.sipAlias, - conferenceId: conference._id, - participantSipExtension, - }); - - const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) - .map(({ _id }) => _id) - .toArray(); - - if (mediaCallIds.length !== 1) { - // Check if the user is already linked to the conference - if (linkedMediaCallIds?.length) { - if (await MediaCalls.isUserSipExtensionInCallIds(participantSipExtension, linkedMediaCallIds)) { - return true; + try { + logger.debug({ + msg: 'Pexip Participant joined via SIP', + sipAlias: conference.sipAlias, + conferenceId: conference._id, + participantSipExtension, + }); + + const mediaCallIds = await MediaCalls.findAllNotOverByOppositeSipExtension(participantSipExtension, { projection: { _id: 1 } }) + .map(({ _id }) => _id) + .toArray(); + + if (mediaCallIds.length !== 1) { + // Check if the user is already linked to the conference + if (linkedMediaCallIds?.length) { + if (await MediaCalls.isUserSipExtensionInCallIds(participantSipExtension, linkedMediaCallIds)) { + return true; + } } - } - logger.debug({ msg: 'Could not identify the media call that the SIP Participant is connecting from', calls: mediaCallIds }); - return mediaCallIds.length > 0; - } + logger.debug({ msg: 'Could not identify the media call that the SIP Participant is connecting from', calls: mediaCallIds }); + return mediaCallIds.length > 0; + } - const [mediaCallId] = mediaCallIds; + const [mediaCallId] = mediaCallIds; - const updateResult = await VideoConferenceModel.addMediaCallIdByConferenceId(conference._id, mediaCallId); - if (updateResult.modifiedCount) { - await MediaCall.flagAsRemotelyEscalatedByCallId(mediaCallId); + const updateResult = await VideoConferenceModel.addMediaCallIdByConferenceId(conference._id, mediaCallId); + if (updateResult.modifiedCount) { + await MediaCall.flagAsRemotelyEscalatedByCallId(mediaCallId); + } + } catch (err) { + logger.error({ msg: 'Unexpected error handling Pexip Voice to Video Escalation', err }); } return true; From 8400247b596d8ac29e89406fdfdbfb748012874b Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Thu, 2 Jul 2026 16:14:17 -0300 Subject: [PATCH 15/18] chore: Escalation modal text change and german translation --- packages/i18n/src/locales/de.i18n.json | 2 ++ packages/i18n/src/locales/en.i18n.json | 2 +- .../ConfirmVideoEscalationModal.stories.tsx | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/locales/de.i18n.json b/packages/i18n/src/locales/de.i18n.json index 4bf8faa8fd58d..89fd83f174ccc 100644 --- a/packages/i18n/src/locales/de.i18n.json +++ b/packages/i18n/src/locales/de.i18n.json @@ -4374,6 +4374,8 @@ "VideoConf_Enable_DMs": "Aktivieren in Direktnachrichten", "VideoConf_Enable_Groups": "Aktivieren in privaten Kanälen", "VideoConf_Enable_Teams": "Aktivieren in Teams", + "Video_escalation_modal_description": "Bitte informieren Sie Ihren Gesprächspartner, dass er diesem zustimmt und dies ebenfalls akzeptieren muss.", + "Video_escalation_modal_title": "Möchten Sie dieses Gespräch zu einem Video Gespräch erweitern?", "Video_Chat_Window": "Video-Chat", "Video_Conference": "Videokonferenz", "Video_Conference_Description": "Konfigurieren Sie Telefonkonferenzen für Ihren Arbeitsbereich.", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index e7757d59a62cb..e3f70264a37de 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5961,7 +5961,7 @@ "VideoConf_Persistent_Chat_Discussion_Name": "Persistent Chat Discussion Name", "VideoConf_Persistent_Chat_Discussion_Name_Description": "Use [date] tag to set where to include the date. Date will be added to the start if tag is not included.", "Video_escalation_modal_description": "If you start a video call the current call will be finished, the other participants will be transferred and notified about the new call.", - "Video_escalation_modal_title": "Starting a video call", + "Video_escalation_modal_title": "Are you sure you want to extend this to a Video Call?", "Video_Call_unavailable_for_this_type_of_room": "Video Call is unavailable for this type of room", "Video_Chat_Window": "Video Chat", "Video_Conference": "Conference Call", diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx index 6b0379349c793..d46309a8600a9 100644 --- a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/ConfirmVideoEscalationModal.stories.tsx @@ -11,7 +11,7 @@ const meta = { decorators: [ mockAppRoot() .withTranslations('en', 'core', { - Video_escalation_modal_title: 'Start a video call?', + Video_escalation_modal_title: 'Are you sure you want to extend this to a Video Call?', Video_escalation_modal_description: 'This will escalate the current voice call to a video call. The other participant will be notified.', Start_video_call: 'Start video call', From e68037f66ccc8c0a5cba0f103caf2734015cdc42 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 2 Jul 2026 23:45:21 -0300 Subject: [PATCH 16/18] test: regenerate escalation snapshots after develop rebase React useId format changed on develop (':r0:' -> '_r_0_') and the video escalation modal copy was updated; refresh ConfirmVideoEscalationModal and PopupBlockedModal snapshots. --- .../__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap | 6 +++--- .../__snapshots__/PopupBlockedModal.spec.tsx.snap | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap index a86c90e5b91c4..2f1349bbe0f2b 100644 --- a/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap +++ b/packages/ui-voip/src/views/ConfirmVideoEscalationModal/__snapshots__/ConfirmVideoEscalationModal.spec.tsx.snap @@ -4,7 +4,7 @@ exports[`renders Default without crashing 1`] = `

- Start a video call? + Are you sure you want to extend this to a Video Call?