diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index b2856303abd06..d2c80df79b74d 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -1,21 +1,12 @@ import type { Emitter } from '@rocket.chat/emitter'; import type { CallEvents } from './CallEvents'; -import type { IMediaStreamWrapper } from '../media/IMediaStreamWrapper'; - -export type CallActorType = 'user' | 'sip'; - -export type CallContact = { - type?: CallActorType; - id?: string; - contractId?: string; - - displayName?: string; - username?: string; - sipExtension?: string; -}; - -export type CallRole = 'caller' | 'callee'; +import type { + AnyClientMediaCallParticipant, + IClientMediaCallLocalParticipant, + IClientMediaCallRemoteParticipant, +} from './IClientMediaCallParticipant'; +import type { CallActorType } from './common'; export type CallService = 'webrtc'; @@ -77,26 +68,13 @@ export type CallFlag = 'internal' | 'create-data-channel'; export interface IClientMediaCall { callId: string; - role: CallRole; - service: CallService | null; - flags: readonly CallFlag[]; - features: readonly CallFeature[]; state: CallState; ignored: boolean; signed: boolean; hidden: boolean; - muted: boolean; - /* if the call was put on hold */ - held: boolean; /* busy = state >= 'accepted' && state < 'hangup' */ busy: boolean; - /* if the other side has put the call on hold */ - remoteHeld: boolean; - remoteMute: boolean; - - contact: CallContact; - transferredBy: CallContact | null; /** The timestamp of the moment the call was marked as active for the first time */ activeTimestamp?: Date; @@ -108,14 +86,9 @@ export interface IClientMediaCall { emitter: Emitter; - getLocalMediaStream(tag?: string): IMediaStreamWrapper | null; - getRemoteMediaStream(tag?: string): IMediaStreamWrapper | null; - accept(): void; reject(): void; hangup(): void; - setMuted(muted: boolean): void; - setHeld(onHold: boolean): void; requestScreenShare(requested: boolean): void; setScreenVideoTrack(videoTrack: MediaStreamTrack | null): Promise; hasScreenVideoTrack(): boolean; @@ -126,4 +99,9 @@ export interface IClientMediaCall { getStats(selector?: MediaStreamTrack | null): Promise; isFeatureAvailable(feature: CallFeature): boolean; + hasFlag(flag: CallFlag): boolean; + + readonly localParticipant: IClientMediaCallLocalParticipant; + readonly remoteParticipants: IClientMediaCallRemoteParticipant[]; + readonly participants: AnyClientMediaCallParticipant[]; } diff --git a/packages/media-signaling/src/definition/call/IClientMediaCallParticipant.ts b/packages/media-signaling/src/definition/call/IClientMediaCallParticipant.ts new file mode 100644 index 0000000000000..9cc3ebd5393c8 --- /dev/null +++ b/packages/media-signaling/src/definition/call/IClientMediaCallParticipant.ts @@ -0,0 +1,35 @@ +import type { IMediaStreamWrapper } from '../media'; +import type { CallActorType, CallContact, CallRole } from './common'; + +export interface IClientMediaCallParticipant { + readonly local: boolean; + + readonly participantId: string; + + readonly actorType: CallActorType; + + readonly actorId: string; + + readonly role: CallRole; + + readonly muted: boolean; + + readonly held: boolean; + + readonly contact: CallContact; + + getMediaStream(tag?: string): IMediaStreamWrapper | null; +} + +export interface IClientMediaCallLocalParticipant extends IClientMediaCallParticipant { + readonly local: true; + + setMuted(muted: boolean): void; + setHeld(onHold: boolean): void; +} + +export interface IClientMediaCallRemoteParticipant extends IClientMediaCallParticipant { + readonly local: false; +} + +export type AnyClientMediaCallParticipant = IClientMediaCallLocalParticipant | IClientMediaCallRemoteParticipant; diff --git a/packages/media-signaling/src/definition/call/callStates/AnyMediaCallData.ts b/packages/media-signaling/src/definition/call/callStates/AnyMediaCallData.ts new file mode 100644 index 0000000000000..6633f7d8a2e82 --- /dev/null +++ b/packages/media-signaling/src/definition/call/callStates/AnyMediaCallData.ts @@ -0,0 +1,4 @@ +import type { IDirectMediaCallData } from './IDirectMediaCallData'; +import type { ITempMediaCallData } from './ITempMediaCallData'; + +export type AnyMediaCallData = ITempMediaCallData | IDirectMediaCallData; diff --git a/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts new file mode 100644 index 0000000000000..8b35b081ebae6 --- /dev/null +++ b/packages/media-signaling/src/definition/call/callStates/IDirectMediaCallData.ts @@ -0,0 +1,23 @@ +import type { CallFeature, CallFlag, CallService, CallState } from '../IClientMediaCall'; +import type { IClientMediaCallLocalParticipant, IClientMediaCallRemoteParticipant } from '../IClientMediaCallParticipant'; +import type { CallContact } from '../common'; + +export interface IDirectMediaCallData { + readonly confirmed: true; + + readonly callId: string; + readonly service: CallService | null; + readonly flags: readonly CallFlag[]; + readonly features: readonly CallFeature[]; + readonly state: CallState; + readonly hidden: boolean; + + readonly transferredBy: CallContact | null; + + readonly activeTimestamp?: Date; + + readonly tempCallId: string; + + readonly localParticipant: IClientMediaCallLocalParticipant; + readonly remoteParticipant: IClientMediaCallRemoteParticipant; +} diff --git a/packages/media-signaling/src/definition/call/callStates/ITempMediaCallData.ts b/packages/media-signaling/src/definition/call/callStates/ITempMediaCallData.ts new file mode 100644 index 0000000000000..78931e59a4428 --- /dev/null +++ b/packages/media-signaling/src/definition/call/callStates/ITempMediaCallData.ts @@ -0,0 +1,13 @@ +import type { CallState } from '../IClientMediaCall'; +import type { IClientMediaCallLocalParticipant } from '../IClientMediaCallParticipant'; + +export interface ITempMediaCallData { + readonly confirmed: false; + readonly tempCallId: string; + + readonly state: CallState; + + readonly title: string; + + readonly localParticipant: IClientMediaCallLocalParticipant; +} diff --git a/packages/media-signaling/src/definition/call/callStates/index.ts b/packages/media-signaling/src/definition/call/callStates/index.ts new file mode 100644 index 0000000000000..59b21110e5833 --- /dev/null +++ b/packages/media-signaling/src/definition/call/callStates/index.ts @@ -0,0 +1,3 @@ +export type * from './AnyMediaCallData'; +export type * from './IDirectMediaCallData'; +export type * from './ITempMediaCallData'; diff --git a/packages/media-signaling/src/definition/call/common.ts b/packages/media-signaling/src/definition/call/common.ts new file mode 100644 index 0000000000000..542f5cf527a04 --- /dev/null +++ b/packages/media-signaling/src/definition/call/common.ts @@ -0,0 +1,13 @@ +export type CallActorType = 'user' | 'sip'; + +export type CallContact = { + type?: CallActorType; + id?: string; + contractId?: string; + + displayName?: string; + username?: string; + sipExtension?: string; +}; + +export type CallRole = 'caller' | 'callee'; diff --git a/packages/media-signaling/src/definition/call/index.ts b/packages/media-signaling/src/definition/call/index.ts index 770d495d82472..e4479a60af6a9 100644 --- a/packages/media-signaling/src/definition/call/index.ts +++ b/packages/media-signaling/src/definition/call/index.ts @@ -1,2 +1,5 @@ export type * from './CallEvents'; +export type * from './callStates'; +export type * from './common'; export * from './IClientMediaCall'; +export type * from './IClientMediaCallParticipant'; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 0cf9426f487d2..f33adb523fb1f 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -14,7 +14,11 @@ import type { CallActorType, CallFlag, CallFeature, + IClientMediaCallLocalParticipant, + IClientMediaCallRemoteParticipant, + AnyClientMediaCallParticipant, } from '../definition/call'; +import type { AnyMediaCallData } from '../definition/call/callStates'; import type { ClientContractState, ClientState } from '../definition/client'; import type { IMediaSignalLogger } from '../definition/logger'; import type { MediaStreamIdentification, IMediaStreamWrapper } from '../definition/media'; @@ -30,6 +34,7 @@ import type { } from '../definition/signals/server'; export interface IClientMediaCallConfig { + userId: string; logger?: IMediaSignalLogger; transporter: MediaSignalTransportWrapper; processorFactories: IServiceProcessorFactoryList; @@ -88,7 +93,11 @@ export class ClientMediaCall implements IClientMediaCall { private _transferredBy: CallContact | null; public get transferredBy(): CallContact | null { - return this._transferredBy; + if (!this._transferredBy) { + return null; + } + + return { ...this._transferredBy }; } private _service: CallService | null; @@ -158,7 +167,11 @@ export class ClientMediaCall implements IClientMediaCall { private _activeTimestamp: Date | undefined; public get activeTimestamp(): Date | undefined { - return this._activeTimestamp; + if (!this._activeTimestamp) { + return undefined; + } + + return new Date(this._activeTimestamp); } protected webrtcProcessor: IWebRTCProcessor | null = null; @@ -209,13 +222,61 @@ export class ClientMediaCall implements IClientMediaCall { private _flags: CallFlag[]; public get flags(): CallFlag[] { - return this._flags; + return [...this._flags]; } public get features(): CallFeature[] { return [...(this.enabledFeatures || [])]; } + public readonly localParticipant: IClientMediaCallLocalParticipant; + + private selfContact: CallContact | null; + + private remoteParticipant: IClientMediaCallRemoteParticipant | null; + + public get remoteParticipants(): IClientMediaCallRemoteParticipant[] { + if (!this.remoteParticipant) { + return []; + } + + return [this.remoteParticipant]; + } + + public get participants(): AnyClientMediaCallParticipant[] { + return [this.localParticipant, ...this.remoteParticipants]; + } + + public get callStateData(): AnyMediaCallData { + if (!this.confirmed || !this.remoteParticipant) { + const number = this.contact.type === 'sip' ? this.contact.id : ''; + + return { + confirmed: false, + tempCallId: this.tempCallId, + state: this.state, + title: this.contact.displayName || number || 'unknown', + localParticipant: this.localParticipant, + }; + } + + return { + confirmed: this.confirmed, + callId: this.callId, + service: this.service, + flags: this.flags, + features: this.features, + state: this.state, + transferredBy: this.transferredBy, + activeTimestamp: this.activeTimestamp, + tempCallId: this.tempCallId, + hidden: this.hidden, + + localParticipant: this.localParticipant, + remoteParticipant: this.remoteParticipant, + }; + } + constructor( private readonly config: IClientMediaCallConfig, callId: string, @@ -256,6 +317,9 @@ export class ClientMediaCall implements IClientMediaCall { this._remoteHeld = false; this._remoteMute = false; this._flags = []; + this.selfContact = null; + this.localParticipant = this.createLocalParticipantProxy(); + this.remoteParticipant = null; this.negotiationManager = new NegotiationManager(this, { logger: config.logger }); } @@ -325,6 +389,7 @@ export class ClientMediaCall implements IClientMediaCall { this._service = signal.service; this._role = signal.role; this._flags = signal.flags || []; + this.selfContact = { type: 'user', id: this.config.userId, ...signal.self }; this._transferredBy = signal.transferredBy || null; @@ -341,53 +406,57 @@ export class ClientMediaCall implements IClientMediaCall { } } - this.changeContact(signal.contact); - - // If the call is already flagged as over before the initialization, do not process anything other than filling in the basic information - if (this.isOver()) { - return; - } + this.changeContact(signal.contact, { skipEvent: true }); + this.remoteParticipant = this.createRemoteParticipantProxy(); - // If it's flagged as ignored even before the initialization, tell the server we're unavailable - if (this.ignored) { - return this.rejectAsUnavailable(); - } + try { + // If the call is already flagged as over before the initialization, do not process anything other than filling in the basic information + if (this.isOver()) { + return; + } - if (this._service === 'webrtc') { - try { - this.prepareWebRtcProcessor(); - } catch (e) { - this.sendError({ - errorType: 'service', - errorCode: 'service-initialization-failed', - critical: true, - errorDetails: serializeError(e), - }); - await this.rejectAsUnavailable(); - throw e; + // If it's flagged as ignored even before the initialization, tell the server we're unavailable + if (this.ignored) { + return this.rejectAsUnavailable(); } - } - // Send an ACK so the server knows that this session exists and is reachable - this.acknowledge(); + if (this._service === 'webrtc') { + try { + this.prepareWebRtcProcessor(); + } catch (e) { + this.sendError({ + errorType: 'service', + errorCode: 'service-initialization-failed', + critical: true, + errorDetails: serializeError(e), + }); + await this.rejectAsUnavailable(); + throw e; + } + } - // Adds a secondary timeout for all sessions of the call; Won't matter if the original caller session is still active, but is needed for transferred calls. - this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); + // Send an ACK so the server knows that this session exists and is reachable + this.acknowledge(); - // If the call was requested by this specific session, assume we're signed already. - if ( - this._role === 'caller' && - this.acceptedLocally && - this.contractState !== 'ignored' && - (signal.requestedCallId === this.localCallId || Boolean(oldCall)) - ) { - this.contractState = 'pre-signed'; - } + // Adds a secondary timeout for all sessions of the call; Won't matter if the original caller session is still active, but is needed for transferred calls. + this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); - if (!wasInitialized) { - this.emitter.emit('initialized'); + // If the call was requested by this specific session, assume we're signed already. + if ( + this._role === 'caller' && + this.acceptedLocally && + this.contractState !== 'ignored' && + (signal.requestedCallId === this.localCallId || Boolean(oldCall)) + ) { + this.contractState = 'pre-signed'; + } + } finally { + if (!wasInitialized) { + this.emitter.emit('initialized'); + } + this.emitter.emit('contactUpdate'); + this.emitter.emit('confirmed'); } - this.emitter.emit('confirmed'); await this.processEarlySignals(); } @@ -820,6 +889,10 @@ export class ClientMediaCall implements IClientMediaCall { return this.enabledFeatures.includes(feature); } + public hasFlag(flag: CallFlag): boolean { + return this._flags.includes(flag); + } + private changeState(newState: CallState): void { if (newState === this._state) { return; @@ -885,7 +958,10 @@ export class ClientMediaCall implements IClientMediaCall { } } - private changeContact(contact: CallContact | null, { prioritizeExisting }: { prioritizeExisting?: boolean } = {}): void { + private changeContact( + contact: CallContact | null, + { prioritizeExisting, skipEvent }: { prioritizeExisting?: boolean; skipEvent?: boolean } = {}, + ): void { this.config.logger?.debug('ClientMediaCall.changeContact'); const lowPriorityContact = prioritizeExisting ? contact : this._contact; const highPriorityContact = prioritizeExisting ? this._contact : contact; @@ -893,7 +969,7 @@ export class ClientMediaCall implements IClientMediaCall { const finalContact = highPriorityContact || lowPriorityContact; this._contact = finalContact && { ...finalContact }; - if (this._contact) { + if (this._contact && !skipEvent) { this.emitter.emit('contactUpdate'); } } @@ -1316,6 +1392,81 @@ export class ClientMediaCall implements IClientMediaCall { return this.signed; } + private createLocalParticipantProxy(): IClientMediaCallLocalParticipant { + const localParticipant: IClientMediaCallLocalParticipant = { + local: true, + participantId: this.config.userId, + actorType: 'user', + actorId: this.config.userId, + role: this._role, + muted: this.muted, + held: this.held, + contact: this.selfContact || { type: 'user', id: this.config.userId }, + getMediaStream: (tag?: string) => this.getLocalMediaStream(tag), + setMuted: (muted: boolean) => this.setMuted(muted), + setHeld: (held: boolean) => this.setHeld(held), + }; + + return new Proxy(localParticipant, { + get: (target: typeof localParticipant, prop: keyof typeof localParticipant, receiver): any => { + switch (prop) { + case 'role': + return this._role; + case 'contact': + return this.selfContact || { type: 'user', id: this.config.userId }; + case 'muted': + return this.muted; + case 'held': + return this.held; + default: + return Reflect.get(target, prop, receiver); + } + }, + }); + } + + private createRemoteParticipantProxy(): IClientMediaCallRemoteParticipant { + if (!this.hasRemoteData) { + throw new Error('Unable to initialize remote participant without remote data'); + } + + const { type: actorType, id: actorId } = this.contact; + + if (!actorType || !actorId) { + throw new Error('Unable to initialize remote participant without actor identification'); + } + + const participantId = actorType === 'user' ? actorId : `${actorType}/${actorId}`; + const role = this._role === 'callee' ? 'caller' : 'callee'; + + const remote: IClientMediaCallRemoteParticipant = { + local: false, + participantId, + actorType, + actorId, + role, + muted: this.remoteMute, + held: this.remoteHeld, + contact: this.contact, + getMediaStream: (tag?: string) => this.getRemoteMediaStream(tag), + }; + + return new Proxy(remote, { + get: (target: typeof remote, prop: keyof typeof remote, receiver): any => { + switch (prop) { + case 'contact': + return this.contact; + case 'muted': + return this.remoteMute; + case 'held': + return this.remoteHeld; + default: + return Reflect.get(target, prop, receiver); + } + }, + }); + } + private mayUseStreams(): this is ClientMediaCallWebRTC { if (this.hidden || !this.signed) { return false; diff --git a/packages/media-signaling/src/lib/NegotiationManager.ts b/packages/media-signaling/src/lib/NegotiationManager.ts index 31dbcc4538a47..514b5ea7ab53f 100644 --- a/packages/media-signaling/src/lib/NegotiationManager.ts +++ b/packages/media-signaling/src/lib/NegotiationManager.ts @@ -144,7 +144,7 @@ export class NegotiationManager { } protected isPoliteClient(): boolean { - return this.call.role === 'callee'; + return this.call.localParticipant.role === 'callee'; } protected addToQueue(negotiation: Negotiation): void { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index b3f4f80668d2c..ff86b89a9897d 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -10,7 +10,7 @@ import type { RandomStringFactory, ServerMediaSignal, } from '../definition'; -import type { IClientMediaCall, CallActorType, CallContact, CallFeature } from '../definition/call'; +import type { IClientMediaCall, CallActorType, CallContact, CallFeature, AnyMediaCallData } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; export type MediaSignalingEvents = { @@ -132,9 +132,23 @@ export class MediaSignalingSession extends Emitter { return this.knownCalls.get(callId) || null; } - public getMainCall(skipLocal = false): IClientMediaCall | null { - let ringingCall: IClientMediaCall | null = null; - let pendingCall: IClientMediaCall | null = null; + public getState(skipLocal = false): (AnyMediaCallData & { call: IClientMediaCall }) | null { + const call = this.getMainCall(skipLocal); + if (!call) { + return null; + } + + const state = call.callStateData; + + return { + ...state, + call, + }; + } + + private getMainCall(skipLocal = false): ClientMediaCall | null { + let ringingCall: ClientMediaCall | null = null; + let pendingCall: ClientMediaCall | null = null; for (const call of this.knownCalls.values()) { if (call.state === 'hangup' || call.ignored) { @@ -527,6 +541,7 @@ export class MediaSignalingSession extends Emitter { private createCall(callId: string): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { + userId: this.config.userId, logger: this.config.logger, transporter: this.transporter, processorFactories: this.config.processorFactories, diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 129029f05ce6e..e0ff49e665890 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -478,7 +478,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private createDataChannel(): void { - if (this._dataChannel || this._dataChannelEnded || !this.config.call.flags.includes('create-data-channel')) { + if (this._dataChannel || this._dataChannelEnded || !this.config.call.hasFlag('create-data-channel')) { return; } diff --git a/packages/ui-voip/src/context/usePeekMediaSessionFeatures.tsx b/packages/ui-voip/src/context/usePeekMediaSessionFeatures.tsx index 7bc5f13575b24..36cef76b0f894 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionFeatures.tsx +++ b/packages/ui-voip/src/context/usePeekMediaSessionFeatures.tsx @@ -33,11 +33,12 @@ export const usePeekMediaSessionFeatures = (): PeekMediaSessionFeaturesReturn => return emptyFeatures; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState?.confirmed) { return emptyFeatures; } - const { features } = mainCall; + + const { features } = instanceState; if (!cache.current || !areEqual(features, cache.current)) { cache.current = features; diff --git a/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx b/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx index 6c6eaae51d214..464daafc926ae 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx +++ b/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx @@ -8,7 +8,7 @@ import { MediaCallInstanceContext } from './MediaCallInstanceContext'; import { usePeekMediaSessionPeerInfo } from './usePeekMediaSessionPeerInfo'; type MockInstance = { - getMainCall: () => { contact: CallContact } | null; + getState: () => { confirmed: true; remoteParticipant: { contact: CallContact } } | null; on: (event: 'sessionStateChange', onStoreChange: () => void) => () => void; }; @@ -43,7 +43,7 @@ describe('usePeekMediaSessionPeerInfo', () => { it('returns undefined when instance has no main call', () => { const instance: MockInstance = { - getMainCall: () => null, + getState: () => null, on: () => () => undefined, }; @@ -57,10 +57,13 @@ describe('usePeekMediaSessionPeerInfo', () => { describe('when main call has a contact', () => { it('returns external peer info for SIP contact', () => { const instance: MockInstance = { - getMainCall: () => ({ - contact: { - type: 'sip', - id: '+5511999999999', + getState: () => ({ + confirmed: true, + remoteParticipant: { + contact: { + type: 'sip', + id: '+5511999999999', + }, }, }), on: () => () => undefined, @@ -75,13 +78,16 @@ describe('usePeekMediaSessionPeerInfo', () => { it('returns internal peer info for user contact', () => { const instance: MockInstance = { - getMainCall: () => ({ - contact: { - type: 'user', - id: 'userId123', - displayName: 'John Doe', - username: 'johndoe', - sipExtension: '1001', + getState: () => ({ + confirmed: true, + remoteParticipant: { + contact: { + type: 'user', + id: 'userId123', + displayName: 'John Doe', + username: 'johndoe', + sipExtension: '1001', + }, }, }), on: () => () => undefined, @@ -104,15 +110,20 @@ describe('usePeekMediaSessionPeerInfo', () => { it('updates peer info when sessionStateChange is emitted', () => { const emitter = new Emitter<{ sessionStateChange: void }>(); - let mainCall: { contact: CallContact } | null = { - contact: { - type: 'sip', - id: '+5511999999999', + const defaultInstanceState = { + confirmed: true as const, + remoteParticipant: { + contact: { + type: 'sip' as const, + id: '+5511999999999', + } as CallContact, }, }; + let instanceState: typeof defaultInstanceState | null = defaultInstanceState; + const instance: MockInstance = { - getMainCall: () => mainCall, + getState: () => instanceState, on: (event, onStoreChange) => emitter.on(event, onStoreChange), }; @@ -123,18 +134,21 @@ describe('usePeekMediaSessionPeerInfo', () => { expect(result.current).toEqual({ number: '+5511999999999' }); act(() => { - mainCall = null; + instanceState = null; emitter.emit('sessionStateChange'); }); expect(result.current).toBeUndefined(); act(() => { - mainCall = { - contact: { - type: 'user', - id: 'userId456', - displayName: 'Jane Smith', + instanceState = { + confirmed: true, + remoteParticipant: { + contact: { + type: 'user', + id: 'userId456', + displayName: 'Jane Smith', + }, }, }; emitter.emit('sessionStateChange'); diff --git a/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts b/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts index ca2df3728bc61..bb8d90395684c 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts +++ b/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.ts @@ -2,7 +2,7 @@ import { useCallback, useRef, useSyncExternalStore } from 'react'; import { useMediaCallInstance } from './MediaCallInstanceContext'; import type { PeerInfo } from './definitions'; -import { derivePeerInfoFromInstanceContact } from '../utils/derivePeerInfoFromInstanceContact'; +import { derivePeerInfoFromInstanceState } from '../utils/derivePeerInfoFromInstanceState'; const areEqual = (a: PeerInfo, b: PeerInfo) => { if (Object.keys(a).length !== Object.keys(b).length) { @@ -29,12 +29,11 @@ export const usePeekMediaSessionPeerInfo = (): PeerInfo | undefined => { if (!instance) { return undefined; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState) { return undefined; } - const { contact } = mainCall; - const peerInfo = derivePeerInfoFromInstanceContact(contact); + const peerInfo = derivePeerInfoFromInstanceState(instanceState); if (!cache.current || !areEqual(peerInfo, cache.current)) { cache.current = peerInfo; diff --git a/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx b/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx index 39418beb37a15..d493ff77d466d 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx +++ b/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx @@ -8,7 +8,7 @@ import { MediaCallInstanceContext } from './MediaCallInstanceContext'; import { usePeekMediaSessionState } from './usePeekMediaSessionState'; type MockInstance = { - getMainCall: () => { state: CallState; role: CallRole } | null; + getState: () => { state: CallState; localParticipant: { role: CallRole } } | null; on: (event: 'sessionStateChange', onStoreChange: () => void) => () => void; }; @@ -43,7 +43,7 @@ describe('usePeekMediaSessionState', () => { it('returns "available" when instance has no main call', () => { const instance: MockInstance = { - getMainCall: () => null, + getState: () => null, on: () => () => undefined, }; @@ -56,7 +56,7 @@ describe('usePeekMediaSessionState', () => { it('returns "available" when main call state does not map to a widget state (e.g. hangup)', () => { const instance: MockInstance = { - getMainCall: () => ({ state: 'hangup', role: 'caller' }), + getState: () => ({ state: 'hangup', localParticipant: { role: 'caller' } }), on: () => () => undefined, }; @@ -75,7 +75,7 @@ describe('usePeekMediaSessionState', () => { ['renegotiating', 'callee', 'ongoing'] as const, ])('returns "ongoing" for state "%s" and role "%s"', (callState, role, expected) => { const instance: MockInstance = { - getMainCall: () => ({ state: callState, role }), + getState: () => ({ state: callState, localParticipant: { role } }), on: () => () => undefined, }; @@ -88,7 +88,7 @@ describe('usePeekMediaSessionState', () => { it.each(['ringing', 'none'] as const)('returns "ringing" for callee when state is "%s"', (state) => { const instance: MockInstance = { - getMainCall: () => ({ state, role: 'callee' }), + getState: () => ({ state, localParticipant: { role: 'callee' } }), on: () => () => undefined, }; @@ -101,7 +101,7 @@ describe('usePeekMediaSessionState', () => { it.each(['ringing', 'none'] as const)('returns "calling" for caller when state is "%s"', (state) => { const instance: MockInstance = { - getMainCall: () => ({ state, role: 'caller' }), + getState: () => ({ state, localParticipant: { role: 'caller' } }), on: () => () => undefined, }; @@ -116,13 +116,15 @@ describe('usePeekMediaSessionState', () => { describe('sessionStateChange subscription', () => { it('updates state when sessionStateChange is emitted', () => { const emitter = new Emitter<{ sessionStateChange: void }>(); - let mainCall: { state: CallState; role: CallRole } | null = { + let instanceState: { state: CallState; localParticipant: { role: CallRole } } | null = { state: 'active', - role: 'caller', + localParticipant: { + role: 'caller', + }, }; const instance: MockInstance = { - getMainCall: () => mainCall, + getState: () => instanceState, on: (event, onStoreChange) => emitter.on(event, onStoreChange), }; @@ -133,14 +135,14 @@ describe('usePeekMediaSessionState', () => { expect(result.current).toBe('ongoing'); act(() => { - mainCall = null; + instanceState = null; emitter.emit('sessionStateChange'); }); expect(result.current).toBe('available'); act(() => { - mainCall = { state: 'ringing', role: 'callee' }; + instanceState = { state: 'ringing', localParticipant: { role: 'callee' } }; emitter.emit('sessionStateChange'); }); diff --git a/packages/ui-voip/src/context/usePeekMediaSessionState.ts b/packages/ui-voip/src/context/usePeekMediaSessionState.ts index 3bd8e0c0f11ce..045fe9d5ceb07 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionState.ts +++ b/packages/ui-voip/src/context/usePeekMediaSessionState.ts @@ -23,12 +23,15 @@ export const usePeekMediaSessionState = (): PeekMediaSessionStateReturn => { return 'unavailable'; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState) { return 'available'; } - const { state: callState, role } = mainCall; + const { + state: callState, + localParticipant: { role }, + } = instanceState; const state = deriveWidgetStateFromCallState(callState, role); if (!state) { return 'available'; diff --git a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx index dab7c70396fc6..ec13c9f75113b 100644 --- a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx @@ -162,15 +162,7 @@ const MockedMediaCallProvider = ({ const instanceContextValue = { instance: { - getMainCall: () => ({ - contact: { - type: 'user', - id: '1234567890', - }, - role: 'caller', - reject: () => undefined, - hangup: () => undefined, - }), + getState: () => null, on: () => undefined, } as unknown as MediaSignalingSession, signalEmitter: new Emitter(), diff --git a/packages/ui-voip/src/providers/useAudioStream.ts b/packages/ui-voip/src/providers/useAudioStream.ts index 03bf197c76468..d2f31b26e2f92 100644 --- a/packages/ui-voip/src/providers/useAudioStream.ts +++ b/packages/ui-voip/src/providers/useAudioStream.ts @@ -5,16 +5,16 @@ import { usePlayMediaStream } from './usePlayMediaStream'; const getAudioStream = (instance?: MediaSignalingSession) => { try { - const mainCall = instance?.getMainCall(); - if (!mainCall) { + const instanceState = instance?.getState(); + if (!instanceState?.confirmed) { return null; } - if (mainCall.hidden) { + if (instanceState.hidden) { return null; } - return mainCall.getRemoteMediaStream('main')?.stream || null; + return instanceState.remoteParticipant.getMediaStream('main')?.stream || null; } catch (error) { console.error('MediaCall: useAudioStream - Error getting remote media stream (main audio)', error); return null; diff --git a/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts b/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts index 20a7532f450b7..1761c7121b1fb 100644 --- a/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts +++ b/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts @@ -16,7 +16,8 @@ export const useGetAutocompleteOptions = (instance: MediaSignalingSession | unde return []; } - const contact = instance.getMainCall()?.contact; + const instanceState = instance.getState(); + const contact = instanceState?.confirmed && instanceState.remoteParticipant.contact; const peerUsername = contact && 'username' in contact ? contact.username : undefined; const peerExtension = contact ? getExtensionFromInstanceContact(contact) : undefined; diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts index 5e62a2150c7b3..893bbd06ca5d0 100644 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -118,26 +118,16 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS } const updateSessionState = () => { - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState) { dispatch({ type: 'reset' }); return; } const { - contact, - transferredBy: callTransferredBy, state: callState, - role, - muted, - held, - hidden, - remoteHeld, - remoteMute, - callId, - activeTimestamp: startedAt, - features: supportedFeatures, - } = mainCall; + localParticipant: { role, muted, held }, + } = instanceState; const state = deriveWidgetStateFromCallState(callState, role); if (!state) { @@ -147,6 +137,41 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS const connectionState = deriveConnectionStateFromCallState(callState); + if (!instanceState.confirmed) { + dispatch({ + type: 'instance_updated', + payload: { + peerInfo: { + displayName: instanceState.title, + userId: 'unknown', + username: undefined, + callerId: undefined, + }, + transferredBy: undefined, + state, + muted, + held, + connectionState, + hidden: false, + remoteHeld: false, + remoteMuted: false, + callId: instanceState.tempCallId, + startedAt: undefined, + supportedFeatures: [], + }, + }); + return; + } + + const { + hidden, + callId, + activeTimestamp: startedAt, + features: supportedFeatures, + transferredBy: callTransferredBy, + remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, + } = instanceState; + const transferredBy = callTransferredBy?.displayName || callTransferredBy?.username || undefined; if (contact.type === 'sip') { @@ -161,7 +186,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS connectionState, hidden, remoteHeld, - remoteMuted: remoteMute, + remoteMuted, callId, startedAt, supportedFeatures, @@ -195,7 +220,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS connectionState, hidden, remoteHeld, - remoteMuted: remoteMute, + remoteMuted, callId, startedAt, supportedFeatures, diff --git a/packages/ui-voip/src/providers/useMediaSessionControls.ts b/packages/ui-voip/src/providers/useMediaSessionControls.ts index 3c4cd2bd27da7..38a897476e7a4 100644 --- a/packages/ui-voip/src/providers/useMediaSessionControls.ts +++ b/packages/ui-voip/src/providers/useMediaSessionControls.ts @@ -18,19 +18,19 @@ export type MediaSessionControls = { export const useMediaSessionControls = (instance?: MediaSignalingSession): MediaSessionControls => { return useMemo(() => { const toggleMute = () => { - const mainCall = instance?.getMainCall(); - if (!mainCall) { + const instanceState = instance?.getState(); + if (!instanceState) { return; } - mainCall.setMuted(!mainCall.muted); + instanceState.localParticipant.setMuted(!instanceState.localParticipant.muted); }; const toggleHold = () => { - const mainCall = instance?.getMainCall(); - if (!mainCall) { + const instanceState = instance?.getState(); + if (!instanceState) { return; } - mainCall.setHeld(!mainCall.held); + instanceState.localParticipant.setHeld(!instanceState.localParticipant.held); }; const endCall = getEndCall(instance); @@ -39,11 +39,11 @@ export const useMediaSessionControls = (instance?: MediaSignalingSession): Media if (!instance) { return; } - const call = instance.getMainCall(); - if (call?.state !== 'ringing') { + const instanceState = instance.getState(); + if (!instanceState?.confirmed || instanceState.state !== 'ringing') { return; } - call.accept(); + instanceState.call.accept(); }; const startCall = async (id: string, kind: 'user' | 'sip') => { @@ -68,23 +68,24 @@ export const useMediaSessionControls = (instance?: MediaSignalingSession): Media if (!instance) { return; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState?.confirmed) { return; } - mainCall.transfer({ type, id }); + instanceState.call.transfer({ type, id }); }; const sendTone = (tone: string) => { if (!instance) { return; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState?.confirmed) { return; } + try { - mainCall.sendDTMF(tone); + instanceState.call.sendDTMF(tone); } catch (error) { console.error('Error sending tone', error); } @@ -95,13 +96,13 @@ export const useMediaSessionControls = (instance?: MediaSignalingSession): Media return; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState?.confirmed) { return; } try { - mainCall.requestScreenShare(!mainCall.hasScreenVideoTrack()); + instanceState.call.requestScreenShare(!instanceState.call.hasScreenVideoTrack()); } catch (error) { console.error('Error toggling screen share', error); } diff --git a/packages/ui-voip/src/providers/useScreenShareStreams.ts b/packages/ui-voip/src/providers/useScreenShareStreams.ts index a182fa1d0cff3..cb4421b789fe3 100644 --- a/packages/ui-voip/src/providers/useScreenShareStreams.ts +++ b/packages/ui-voip/src/providers/useScreenShareStreams.ts @@ -5,14 +5,20 @@ import type { MediaCallStreams } from '../context/MediaCallViewContext'; const getStreamWrappers = (instance?: MediaSignalingSession) => { try { - const mainCall = instance?.getMainCall(); - if (!mainCall) { + const instanceState = instance?.getState(); + if (!instanceState) { return null; } - const localStream = mainCall.getLocalMediaStream('screen-share'); + if (!instanceState.confirmed) { + return null; + } + + const { localParticipant, remoteParticipant } = instanceState; + + const localStream = localParticipant.getMediaStream('screen-share'); - const remoteStream = mainCall.getRemoteMediaStream('screen-share'); + const remoteStream = remoteParticipant.getMediaStream('screen-share'); return { localScreen: localStream ?? undefined, diff --git a/packages/ui-voip/src/utils/derivePeerInfoFromInstanceState.spec.ts b/packages/ui-voip/src/utils/derivePeerInfoFromInstanceState.spec.ts new file mode 100644 index 0000000000000..eb754a42a929f --- /dev/null +++ b/packages/ui-voip/src/utils/derivePeerInfoFromInstanceState.spec.ts @@ -0,0 +1,110 @@ +import type { IDirectMediaCallData, ITempMediaCallData } from '@rocket.chat/media-signaling'; + +import { derivePeerInfoFromInstanceState } from './derivePeerInfoFromInstanceState'; + +describe('derivePeerInfoFromInstanceState', () => { + const localParticipant = { + local: true, + participantId: 'participantId', + actorType: 'user', + actorId: 'userId', + role: 'caller', + muted: false, + held: false, + contact: { + displayName: 'User Display Name', + }, + getMediaStream: () => null, + setMuted: () => null, + setHeld: () => null, + } as const; + + describe('Temp Call', () => { + it('returns internal peer info with title from instance state', () => { + const state: ITempMediaCallData = { + confirmed: false, + tempCallId: 'tempId', + title: 'Someone', + localParticipant, + state: 'ringing', + }; + expect(derivePeerInfoFromInstanceState(state)).toEqual({ + displayName: 'Someone', + userId: 'unknown', + username: undefined, + }); + }); + }); + + describe('Direct Call', () => { + it('returns internal peer info with user data from remote participant', () => { + const state: IDirectMediaCallData = { + confirmed: true, + callId: 'callId', + tempCallId: 'callId', + service: 'webrtc', + flags: ['internal'], + features: ['audio'], + hidden: false, + transferredBy: null, + localParticipant, + remoteParticipant: { + local: false, + participantId: 'participantId', + actorType: 'user', + actorId: 'userId', + role: 'caller', + muted: false, + held: false, + contact: { + type: 'user', + id: 'userId123', + displayName: 'John Doe', + username: 'johndoe', + sipExtension: '1001', + }, + getMediaStream: () => null, + }, + state: 'active', + }; + expect(derivePeerInfoFromInstanceState(state)).toEqual({ + displayName: 'John Doe', + userId: 'userId123', + username: 'johndoe', + callerId: '1001', + }); + }); + + it('returns external peer info with number from remote participant', () => { + const state: IDirectMediaCallData = { + confirmed: true, + callId: 'callId', + tempCallId: 'callId', + service: 'webrtc', + flags: ['internal'], + features: ['audio'], + hidden: false, + transferredBy: null, + localParticipant, + remoteParticipant: { + local: false, + participantId: 'participantId', + actorType: 'user', + actorId: 'userId', + role: 'caller', + muted: false, + held: false, + contact: { + type: 'sip', + id: '+5511999999999', + }, + getMediaStream: () => null, + }, + state: 'active', + }; + expect(derivePeerInfoFromInstanceState(state)).toEqual({ + number: '+5511999999999', + }); + }); + }); +}); diff --git a/packages/ui-voip/src/utils/derivePeerInfoFromInstanceState.ts b/packages/ui-voip/src/utils/derivePeerInfoFromInstanceState.ts new file mode 100644 index 0000000000000..3ed05357bf452 --- /dev/null +++ b/packages/ui-voip/src/utils/derivePeerInfoFromInstanceState.ts @@ -0,0 +1,16 @@ +import type { AnyMediaCallData } from '@rocket.chat/media-signaling'; + +import { derivePeerInfoFromInstanceContact } from './derivePeerInfoFromInstanceContact'; + +export const derivePeerInfoFromInstanceState = (callState: AnyMediaCallData) => { + if (!callState.confirmed) { + return { + displayName: callState.title, + userId: 'unknown', + username: undefined, + callerId: undefined, + }; + } + + return derivePeerInfoFromInstanceContact(callState.remoteParticipant.contact); +}; diff --git a/packages/ui-voip/src/utils/instanceControlsGetters.ts b/packages/ui-voip/src/utils/instanceControlsGetters.ts index 4cbd6c7bebba2..5e756f8326f45 100644 --- a/packages/ui-voip/src/utils/instanceControlsGetters.ts +++ b/packages/ui-voip/src/utils/instanceControlsGetters.ts @@ -4,14 +4,19 @@ export const getEndCall = (instance?: MediaSignalingSession) => () => { if (!instance) { return; } - const mainCall = instance.getMainCall(); - if (!mainCall) { + const instanceState = instance.getState(); + if (!instanceState) { return; } - const { role } = mainCall; - if (role === 'caller' || mainCall.state !== 'ringing') { - mainCall.hangup(); + + const { + call, + localParticipant: { role }, + } = instanceState; + + if (role === 'caller' || instanceState.state !== 'ringing') { + call.hangup(); return; } - mainCall.reject(); + call.reject(); };