diff --git a/ee/packages/media-calls/src/constants.ts b/ee/packages/media-calls/src/constants.ts new file mode 100644 index 0000000000000..365772b89e490 --- /dev/null +++ b/ee/packages/media-calls/src/constants.ts @@ -0,0 +1,4 @@ +import type { CallFeature } from '@rocket.chat/media-signaling'; + +export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold']; +export const SIP_CALL_FEATURES = DEFAULT_CALL_FEATURES; diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index cb0af26822ffa..b9683a6d6a3fc 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -15,7 +15,7 @@ export type InternalCallParams = { requestedService?: CallService; parentCallId?: string; requestedBy?: MediaCallSignedContact; - features?: CallFeature[]; + features: CallFeature[]; }; export type MediaCallHeader = AtLeast; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 19a0e9f619330..a4d30666afaf1 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -10,6 +10,7 @@ import type { } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; +import { DEFAULT_CALL_FEATURES } from '../constants'; import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { mediaCallDirector } from '../server/CallDirector'; @@ -181,7 +182,7 @@ export class GlobalSignalProcessor { const services = signal.supportedServices ?? []; const requestedService = services.includes('webrtc') ? 'webrtc' : services[0]; - const features = signal.supportedFeatures ?? ['audio']; + const features = signal.supportedFeatures ?? DEFAULT_CALL_FEATURES; const params: InternalCallParams = { caller: { diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index a7592891d256c..ae8b1f29ab319 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -20,6 +20,7 @@ import type { } from '@rocket.chat/media-signaling'; import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; +import { DEFAULT_CALL_FEATURES } from '../../constants'; import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; import { logger } from '../../logger'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -137,7 +138,7 @@ export class UserActorSignalProcessor { case 'ack': return this.clientIsReachable(); case 'accept': - return this.clientHasAccepted(signal.supportedFeatures || ['audio']); + return this.clientHasAccepted(signal.supportedFeatures || DEFAULT_CALL_FEATURES); case 'unavailable': return this.clientIsUnavailable(); case 'reject': diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 112e5fa3e5c61..a7574d63a6f09 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -5,6 +5,7 @@ import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; +import { DEFAULT_CALL_FEATURES } from '../../constants'; import { logger } from '../../logger'; import { buildNewCallSignal } from '../../server/buildNewCallSignal'; import { getMediaCallServer } from '../../server/injection'; @@ -151,6 +152,7 @@ export class UserActorAgent extends BaseMediaCallAgent { requestedService: call.service, requestedBy: call.transferredBy, parentCallId: call._id, + features: DEFAULT_CALL_FEATURES, }); } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index e59a6dca3412e..b0f7b56686d34 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -11,6 +11,7 @@ import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { getCastDirector, getMediaCallServer } from './injection'; +import { DEFAULT_CALL_FEATURES } from '../constants'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; import type { InternalCallParams, MediaCallHeader } from '../definition/common'; @@ -79,7 +80,7 @@ class MediaCallDirector { this.scheduleExpirationCheckByCallId(call._id); const updatedCall = await MediaCalls.findOneById(call._id, { projection: { features: 1 } }); - const features = (updatedCall?.features || ['audio']) as CallFeature[]; + const features = (updatedCall?.features || DEFAULT_CALL_FEATURES) as CallFeature[]; await calleeAgent.onCallAccepted(call._id, { signedContractId: data.calleeContractId, features }); await calleeAgent.oppositeAgent?.onCallAccepted(call._id, { signedContractId: call.caller.contractId, features }); @@ -186,7 +187,7 @@ class MediaCallDirector { calleeAgent, parentCallId, requestedBy, - features = ['audio'], + features = DEFAULT_CALL_FEATURES, } = params; // The caller must always have a contract to create the call diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts index c340d2acb9602..33670bbce9ff4 100644 --- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts @@ -11,6 +11,7 @@ import type { SipMessage, SrfRequest, SrfResponse } from 'drachtio-srf'; import type Srf from 'drachtio-srf'; import { BaseSipCall } from './BaseSipCall'; +import { SIP_CALL_FEATURES } from '../../constants'; import { logger } from '../../logger'; import { BroadcastActorAgent } from '../../server/BroadcastAgent'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -103,6 +104,7 @@ export class IncomingSipCall extends BaseSipCall { callee, callerAgent, calleeAgent, + features: SIP_CALL_FEATURES, }); const negotiationId = await mediaCallDirector.startNewNegotiation(call, 'caller', webrtcOffer); diff --git a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts index fca9efd3ca010..850e684c468ea 100644 --- a/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts @@ -5,6 +5,7 @@ import type Srf from 'drachtio-srf'; import type { SrfRequest, SrfResponse } from 'drachtio-srf'; import { BaseSipCall } from './BaseSipCall'; +import { SIP_CALL_FEATURES } from '../../constants'; import type { InternalCallParams } from '../../definition/common'; import { logger } from '../../logger'; import { BroadcastActorAgent } from '../../server/BroadcastAgent'; @@ -72,7 +73,7 @@ export class OutgoingSipCall extends BaseSipCall { callee: signedCallee, calleeAgent, callerAgent, - features: ['audio'], + features: SIP_CALL_FEATURES, }); const channel = await calleeAgent.getOrCreateChannel(call, session.sessionId); @@ -267,7 +268,7 @@ export class OutgoingSipCall extends BaseSipCall { await mediaCallDirector.acceptCall(call, this.agent, { calleeContractId: this.session.sessionId, webrtcAnswer: { type: 'answer', sdp: this.sipDialog.remote.sdp }, - supportedFeatures: ['audio'], + supportedFeatures: SIP_CALL_FEATURES, }); } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9255e9ed033f4..397107432e389 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -989,6 +989,7 @@ "Call_ended_bold": "*Voice call ended*", "Call_not_answered_bold": "*Voice call not answered*", "Call_failed_bold": "*Voice call failed*", + "Call_feature_unsupported": "Other party doesn't support this", "Call_transferred_bold": "*Voice call transferred*", "Call_history": "Call history", "Call_history_provides_a_record_of_when_calls_took_place_and_who_joined": "Call history provides a record of when calls took place and who joined.", diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index ca18c3668484a..23559f9043d09 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -19,7 +19,7 @@ export type CallRole = 'caller' | 'callee'; export type CallService = 'webrtc'; -export const callFeatureList = ['audio'] as const; +export const callFeatureList = ['audio', 'transfer', 'hold'] as const; export type CallFeature = (typeof callFeatureList)[number]; @@ -80,6 +80,7 @@ export interface IClientMediaCall { role: CallRole; service: CallService | null; flags: readonly CallFlag[]; + features: readonly CallFeature[]; state: CallState; ignored: boolean; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index f98c076afdd82..024109a55c61b 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -205,6 +205,10 @@ export class ClientMediaCall implements IClientMediaCall { return this._flags; } + public get features(): CallFeature[] { + return [...(this.enabledFeatures || [])]; + } + constructor( private readonly config: IClientMediaCallConfig, callId: string, diff --git a/packages/ui-voip/src/components/ActionButton.tsx b/packages/ui-voip/src/components/ActionButton.tsx index 499b34f93d36d..cba521566b5b5 100644 --- a/packages/ui-voip/src/components/ActionButton.tsx +++ b/packages/ui-voip/src/components/ActionButton.tsx @@ -8,10 +8,10 @@ type ActionButtonProps = { icon: Keys; disabled?: boolean; onClick?: () => void; -} & Omit, 'icon' | 'title' | 'aria-label' | 'disabled' | 'onClick'>; +} & Omit, 'icon' | 'aria-label' | 'disabled' | 'onClick'>; const ActionButton = forwardRef(function ActionButton( - { disabled, label, icon, onClick, secondary = true, ...props }, + { disabled, label, icon, onClick, title, secondary = true, ...props }, ref, ) { return ( @@ -20,7 +20,7 @@ const ActionButton = forwardRef(function A medium secondary={secondary} icon={} - title={label} + title={title || label} aria-label={label} disabled={disabled} onClick={onClick} diff --git a/packages/ui-voip/src/context/MediaCallViewContext.ts b/packages/ui-voip/src/context/MediaCallViewContext.ts index 1b4d4dfeb9779..0433095608cb4 100644 --- a/packages/ui-voip/src/context/MediaCallViewContext.ts +++ b/packages/ui-voip/src/context/MediaCallViewContext.ts @@ -28,6 +28,7 @@ const defaultSessionState: SessionState = { remoteMuted: false, remoteHeld: false, callId: undefined, + supportedFeatures: ['audio', 'transfer', 'hold'], }; export const defaultMediaCallContextValue: MediaCallViewContextValue = { diff --git a/packages/ui-voip/src/context/definitions.d.ts b/packages/ui-voip/src/context/definitions.d.ts index 45bcccb1b7086..7add44b2d7426 100644 --- a/packages/ui-voip/src/context/definitions.d.ts +++ b/packages/ui-voip/src/context/definitions.d.ts @@ -1,4 +1,5 @@ import type { UserStatus } from '@rocket.chat/core-typings'; +import type { CallFeature } from '@rocket.chat/media-signaling'; export type InternalPeerInfo = { displayName: string; @@ -30,6 +31,7 @@ interface IBaseSession { remoteHeld: boolean; startedAt?: Date | null; // todo not sure if I need this hidden: boolean; + supportedFeatures: readonly CallFeature[]; } interface IEmptySession extends IBaseSession { diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts index 5a938a8b7bdde..a7c8f7551702e 100644 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -19,6 +19,7 @@ const defaultSessionInfo: SessionState = { remoteHeld: false, startedAt: new Date(), hidden: false, + supportedFeatures: ['audio', 'transfer', 'hold'], }; export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { @@ -135,6 +136,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS remoteHeld, remoteMute, callId, + features: supportedFeatures, } = mainCall; const state = deriveWidgetStateFromCallState(callState, role); @@ -161,6 +163,7 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS remoteHeld, remoteMuted: remoteMute, callId, + supportedFeatures, }, }); return; @@ -182,7 +185,19 @@ export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionS dispatch({ type: 'instance_updated', - payload: { state, peerInfo, transferredBy, muted, held, connectionState, hidden, remoteHeld, remoteMuted: remoteMute, callId }, + payload: { + state, + peerInfo, + transferredBy, + muted, + held, + connectionState, + hidden, + remoteHeld, + remoteMuted: remoteMute, + callId, + supportedFeatures, + }, }); }; diff --git a/packages/ui-voip/src/providers/useMediaSessionInstance.ts b/packages/ui-voip/src/providers/useMediaSessionInstance.ts index e5436321c64a5..34bf1d23752d7 100644 --- a/packages/ui-voip/src/providers/useMediaSessionInstance.ts +++ b/packages/ui-voip/src/providers/useMediaSessionInstance.ts @@ -93,7 +93,7 @@ class MediaSessionStore extends Emitter<{ change: void }> { randomStringFactory, oldSessionId: this.getOldSessionId(userId), logger: new MediaCallLogger(), - features: ['audio'], + features: ['audio', 'transfer', 'hold'], }); if (window.sessionStorage) { diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx index 1697be4fabdc8..929f91d820a9e 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx @@ -22,7 +22,7 @@ const OngoingCall = () => { const { t } = useTranslation(); const { sessionState, onMute, onHold, onForward, onEndCall, onTone, onClickDirectMessage } = useMediaCallView(); - const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState } = sessionState; + const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, supportedFeatures } = sessionState; const { element: keypad, buttonProps: keypadButtonProps } = useKeypad(onTone); @@ -32,6 +32,9 @@ const OngoingCall = () => { const connecting = connectionState === 'CONNECTING'; const reconnecting = connectionState === 'RECONNECTING'; + const transferDisabled = !supportedFeatures.includes('transfer'); + const holdDisabled = !supportedFeatures.includes('hold'); + // TODO: Figure out how to ensure this always exist before rendering the component if (!peerInfo) { throw new Error('Peer info is required'); @@ -58,11 +61,18 @@ const OngoingCall = () => { + -