UNPKG

@azure/communication-react

Version:

React library for building modern communication user experiences utilizing Azure Communication Services

518 lines • 25 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { END_CALL_PAGES } from '../adapter/CallAdapter'; import { _isInCall, _isPreviewOn, _isInLobbyOrConnecting } from "../../../../../calling-component-bindings/src"; import { isPhoneNumberIdentifier } from '@azure/communication-common'; const ACCESS_DENIED_TEAMS_MEETING_SUB_CODE = 5854; const REMOTE_PSTN_USER_HUNG_UP = 560000; const REMOVED_FROM_CALL_SUB_CODES = [5000, 5300, REMOTE_PSTN_USER_HUNG_UP]; const CALL_REJECTED_CODE = 603; const INVALID_MEETING_IDENTIFIER = 5751; /** @private */ export const ROOM_NOT_FOUND_SUB_CODE = 5732; /** @private */ export const ROOM_NOT_VALID_SUB_CODE = 5829; /** @private */ export const NOT_INVITED_TO_ROOM_SUB_CODE = 5828; /** @private */ export const INVITE_TO_ROOM_REMOVED_SUB_CODE = 5317; /** @private */ export const CALL_TIMEOUT_SUB_CODE = 10004; /** @private */ export const CALL_TIMEOUT_CODE = 487; /** @private */ export const BOT_TIMEOUT_CODE = 486; /** @private */ export const BOT_TIMEOUT_SUB_CODE = 10321; /** * @private */ export const isCameraOn = (state) => { if (state.call) { const stream = state.call.localVideoStreams.find(stream => stream.mediaStreamType === 'Video'); return !!stream; } else { if (state.devices.selectedCamera) { const previewOn = _isPreviewOn(state.devices); return previewOn; } } return false; }; /** * Reduce the set of call controls visible on mobile. * For example do not show screenshare button. * * @private */ export const reduceCallControlsForMobile = (callControlOptions) => { if (callControlOptions === false) { return false; } // Ensure call controls a valid object. const reduceCallControlOptions = callControlOptions === true ? {} : callControlOptions || {}; // Set to compressed mode when composite is optimized for mobile reduceCallControlOptions.displayType = 'compact'; // Do not show screen share button when composite is optimized for mobile unless the developer // has explicitly opted in. if (reduceCallControlOptions.screenShareButton !== true) { reduceCallControlOptions.screenShareButton = false; } return reduceCallControlOptions; }; var CallEndReasons; (function (CallEndReasons) { CallEndReasons[CallEndReasons["LEFT_CALL"] = 0] = "LEFT_CALL"; CallEndReasons[CallEndReasons["ACCESS_DENIED"] = 1] = "ACCESS_DENIED"; CallEndReasons[CallEndReasons["REMOVED_FROM_CALL"] = 2] = "REMOVED_FROM_CALL"; CallEndReasons[CallEndReasons["BAD_REQUEST"] = 3] = "BAD_REQUEST"; })(CallEndReasons || (CallEndReasons = {})); const getCallEndReason = (call) => { var _a, _b, _c, _d; const remoteParticipantsEndedArray = Array.from(Object.values(call.remoteParticipantsEnded)); /** * Handle the special case in a PSTN call where removing the last user kicks the caller out of the call. * The code and subcode is the same as when a user is removed from a teams interop call. * Hence, we look at the last remote participant removed to determine if the last participant removed was a phone number. * If yes, the caller was kicked out of the call, but we need to show them that they left the call. * Note: This check will only work for 1:1 PSTN Calls. The subcode is different for 1:N PSTN calls, and we do not need to handle that case. */ if (remoteParticipantsEndedArray[0] && isPhoneNumberIdentifier(remoteParticipantsEndedArray[0].identifier) && ((_a = call.callEndReason) === null || _a === void 0 ? void 0 : _a.subCode) !== REMOTE_PSTN_USER_HUNG_UP) { return CallEndReasons.LEFT_CALL; } if (((_b = call.callEndReason) === null || _b === void 0 ? void 0 : _b.subCode) && call.callEndReason.subCode === ACCESS_DENIED_TEAMS_MEETING_SUB_CODE) { return CallEndReasons.ACCESS_DENIED; } if (((_c = call.callEndReason) === null || _c === void 0 ? void 0 : _c.subCode) && REMOVED_FROM_CALL_SUB_CODES.includes(call.callEndReason.subCode)) { return CallEndReasons.REMOVED_FROM_CALL; } // If the call end reason code is 400, the call is ended due to a bad request. Keep this line at the bottom right before returning normal left call to catch the scenarios not including the ones above. if (((_d = call.callEndReason) === null || _d === void 0 ? void 0 : _d.code) === 400) { return CallEndReasons.BAD_REQUEST; } if (call.callEndReason) { // No error codes match, assume the user simply left the call regularly return CallEndReasons.LEFT_CALL; } throw new Error('No matching call end reason'); }; /** * Helper function for determine strings and icons for end call page * @private */ export const getEndedCallPageProps = (locale, endedCall) => { var _a, _b, _c, _d, _e, _f, _g; let title = locale.strings.call.leftCallTitle; let moreDetails = locale.strings.call.leftCallMoreDetails; let disableStartCallButton = false; let iconName = 'NoticePageLeftCall'; switch ((_a = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _a === void 0 ? void 0 : _a.subCode) { case ROOM_NOT_FOUND_SUB_CODE: if (locale.strings.call.roomNotFoundTitle) { title = locale.strings.call.roomNotFoundTitle; moreDetails = locale.strings.call.roomNotFoundDetails; disableStartCallButton = true; iconName = 'NoticePageRoomNotFound'; } break; case ROOM_NOT_VALID_SUB_CODE: if (locale.strings.call.roomNotValidTitle) { title = locale.strings.call.roomNotValidTitle; moreDetails = locale.strings.call.roomNotValidDetails; disableStartCallButton = true; iconName = 'NoticePageRoomNotValid'; } break; case NOT_INVITED_TO_ROOM_SUB_CODE: if (locale.strings.call.notInvitedToRoomTitle) { title = locale.strings.call.notInvitedToRoomTitle; moreDetails = locale.strings.call.notInvitedToRoomDetails; disableStartCallButton = true; iconName = 'NoticePageNotInvitedToRoom'; } break; case INVITE_TO_ROOM_REMOVED_SUB_CODE: if (locale.strings.call.inviteToRoomRemovedTitle) { title = locale.strings.call.inviteToRoomRemovedTitle; moreDetails = locale.strings.call.inviteToRoomRemovedDetails; disableStartCallButton = true; iconName = 'NoticePageInviteToRoomRemoved'; } break; case CALL_TIMEOUT_SUB_CODE: if (((_b = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _b === void 0 ? void 0 : _b.code) === CALL_TIMEOUT_CODE && locale.strings.call.callTimeoutTitle) { title = locale.strings.call.callTimeoutTitle; moreDetails = locale.strings.call.callTimeoutDetails; disableStartCallButton = true; iconName = 'NoticePageCallTimeout'; } break; case BOT_TIMEOUT_SUB_CODE: if (((_c = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _c === void 0 ? void 0 : _c.code) === BOT_TIMEOUT_CODE && locale.strings.call.callTimeoutBotTitle) { title = locale.strings.call.callTimeoutBotTitle; moreDetails = locale.strings.call.callTimeoutBotDetails; disableStartCallButton = true; iconName = 'NoticePageCallTimeout'; } break; } switch ((_d = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _d === void 0 ? void 0 : _d.code) { case CALL_REJECTED_CODE: if (locale.strings.call.callRejectedTitle) { title = locale.strings.call.callRejectedTitle; moreDetails = locale.strings.call.callRejectedMoreDetails; disableStartCallButton = true; iconName = 'NoticePageCallRejected'; } break; } switch ((_e = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _e === void 0 ? void 0 : _e.subCode) { case 10037: if (locale.strings.call.participantCouldNotBeReachedTitle) { title = locale.strings.call.participantCouldNotBeReachedTitle; moreDetails = locale.strings.call.participantCouldNotBeReachedMoreDetails; disableStartCallButton = true; } break; case 10124: if (locale.strings.call.permissionToReachTargetParticipantNotAllowedTitle) { title = locale.strings.call.permissionToReachTargetParticipantNotAllowedTitle; moreDetails = locale.strings.call.permissionToReachTargetParticipantNotAllowedMoreDetails; disableStartCallButton = true; } break; case 10119: if (locale.strings.call.unableToResolveTenantTitle) { title = locale.strings.call.unableToResolveTenantTitle; moreDetails = locale.strings.call.unableToResolveTenantMoreDetails; disableStartCallButton = true; } break; case 10044: if (locale.strings.call.participantIdIsMalformedTitle) { title = locale.strings.call.participantIdIsMalformedTitle; moreDetails = locale.strings.call.participantIdIsMalformedMoreDetails; disableStartCallButton = true; } break; } switch ((_f = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _f === void 0 ? void 0 : _f.subCode) { case INVALID_MEETING_IDENTIFIER: if (locale.strings.call.callRejectedTitle) { title = locale.strings.call.callRejectedTitle; moreDetails = locale.strings.call.invalidMeetingIdentifier; disableStartCallButton = true; } break; } // keep this at the bottom to catch the scenarios not including the ones above. switch ((_g = endedCall === null || endedCall === void 0 ? void 0 : endedCall.callEndReason) === null || _g === void 0 ? void 0 : _g.code) { case 400: if (locale.strings.call.callRejectedTitle) { title = locale.strings.call.callRejectedTitle; disableStartCallButton = true; } break; } return { title, moreDetails, disableStartCallButton, iconName }; }; /** * Get the current call composite page based on the current call composite state * * @param Call - The current call state * @param previousCall - The state of the most recent previous call that has ended. * * @remarks - The previousCall state is needed to determine if the call has ended. * When the call ends a new call object is created, and so we must lookback at the * previous call state to understand how the call has ended. If there is no previous * call we know that this is a fresh call and can display the configuration page. * * @private */ export const getCallCompositePage = (call, previousCall, transferCall, isReturningFromBreakoutRoom, unsupportedBrowserInfo) => { if (transferCall !== undefined) { return 'transferring'; } if (isReturningFromBreakoutRoom) { return 'returningFromBreakoutRoom'; } if (call) { // Must check for ongoing call *before* looking at any previous calls. // If the composite completes one call and joins another, the previous calls // will be populated, but not relevant for determining the page. // `_isInLobbyOrConnecting` needs to be checked first because `_isInCall` also returns true when call is in lobby. if (_isInLobbyOrConnecting(call === null || call === void 0 ? void 0 : call.state)) { return 'lobby'; // `LocalHold` needs to be checked before `isInCall` since it is also a state that's considered in call. } else if ((call === null || call === void 0 ? void 0 : call.state) === 'LocalHold') { return 'hold'; } else if ((call === null || call === void 0 ? void 0 : call.state) === 'Disconnecting') { return 'leaving'; } else if (_isInCall(call === null || call === void 0 ? void 0 : call.state)) { return 'call'; } else { // When the call object has been constructed after clicking , but before 'connecting' has been // set on the call object, we continue to show the configuration screen. // The call object does not correctly reflect local device state until `call.state` moves to `connecting`. // Moving to the 'lobby' page too soon leads to components that depend on the `call` object to show incorrect // transitional state. return 'configuration'; } } if (previousCall) { const reason = getCallEndReason(previousCall); switch (reason) { case CallEndReasons.ACCESS_DENIED: return 'accessDeniedTeamsMeeting'; case CallEndReasons.REMOVED_FROM_CALL: return 'removedFromCall'; case CallEndReasons.BAD_REQUEST: return 'badRequest'; case CallEndReasons.LEFT_CALL: if (previousCall.diagnostics.network.latest.noNetwork) { return 'joinCallFailedDueToNoNetwork'; } return 'leftCall'; } } // No call state - show starting page (configuration) return 'configuration'; }; /** @private */ export const IsCallEndedPage = (page) => END_CALL_PAGES.includes(page); /** * Creates a new call control options object and sets the correct values for disabling * the buttons provided in the `disabledControls` array. * Returns a new object without changing the original object. * @param callControlOptions options for the call control component that need to be modified. * @param disabledControls An array of controls to disable. * @returns a copy of callControlOptions with disabledControls disabled * @private */ export const disableCallControls = (callControlOptions, disabledControls) => { var _a; if (callControlOptions === false) { return false; } // Ensure we clone the prop if it is an object to ensure we do not mutate the original prop. let newOptions = (_a = (callControlOptions instanceof Object ? Object.assign({}, callControlOptions) : callControlOptions)) !== null && _a !== void 0 ? _a : {}; if (newOptions === true || newOptions === undefined) { newOptions = disabledControls.reduce((acc, key) => { // @ts-expect-error TODO: fix noImplicitAny error here // Not solveable at this time due to typescript limitations. The typing is too complex for typescript to // understand. Will need to revisit when either typescript or the calling component bindings are updated. acc[key] = { disabled: true }; return acc; }, {}); } else { disabledControls.forEach(key => { // @ts-expect-error refer to above comment if (newOptions[key] !== false) { // @ts-expect-error refer to above comment newOptions[key] = { disabled: true }; } }); } return newOptions; }; /** * Check if a disabled object is provided for a button and returns if the button is disabled. * A button is only disabled if is explicitly set to disabled. * * @param option * @returns whether a button is disabled * @private */ export const isDisabled = (option) => { if (option === undefined || typeof option === 'boolean') { return false; } return option.disabled; }; /** * Check if we are using safari browser * @private */ export const _isSafari = (environmentInfo) => { return (environmentInfo === null || environmentInfo === void 0 ? void 0 : environmentInfo.environment.browser.toLowerCase()) === 'safari'; }; /** * @private * This is the util function to create a participant modifier for remote participantList * It memoize previous original participant items and only update the changed participant * It takes in one modifier function to generate one single participant object, it returns undefined if the object keeps unmodified */ export const createParticipantModifier = (createModifiedParticipant) => { let previousParticipantState = undefined; let modifiedParticipants = {}; const memoizedParticipants = {}; return (state) => { var _a, _b, _c, _d; // if root state is the same, we don't need to update the participants if (((_a = state.call) === null || _a === void 0 ? void 0 : _a.remoteParticipants) !== previousParticipantState) { modifiedParticipants = {}; const originalParticipants = Object.entries(((_b = state.call) === null || _b === void 0 ? void 0 : _b.remoteParticipants) || {}); for (const [key, originalParticipant] of originalParticipants) { const modifiedParticipant = createModifiedParticipant(key, originalParticipant); if (modifiedParticipant === undefined) { modifiedParticipants[key] = originalParticipant; continue; } // Generate the new item if original cached item has been changed if (((_c = memoizedParticipants[key]) === null || _c === void 0 ? void 0 : _c.originalRef) !== originalParticipant) { memoizedParticipants[key] = { newParticipant: modifiedParticipant, originalRef: originalParticipant }; } // the modified participant is always coming from the memoized cache, whether is was refreshed // from the previous closure or not const memoizedParticipant = memoizedParticipants[key]; if (!memoizedParticipant) { throw new Error('Participant modifier encountered an unhandled exception.'); } modifiedParticipants[key] = memoizedParticipant.newParticipant; } previousParticipantState = (_d = state.call) === null || _d === void 0 ? void 0 : _d.remoteParticipants; } return Object.assign(Object.assign({}, state), { call: state.call ? Object.assign(Object.assign({}, state.call), { remoteParticipants: modifiedParticipants }) : undefined }); }; }; /** @private */ export const getBackgroundEffectFromSelectedEffect = (selectedEffect, VideoBackgroundEffectsDependency) => (selectedEffect === null || selectedEffect === void 0 ? void 0 : selectedEffect.effectName) === 'blur' ? VideoBackgroundEffectsDependency.createBackgroundBlurEffect() : (selectedEffect === null || selectedEffect === void 0 ? void 0 : selectedEffect.effectName) === 'replacement' ? VideoBackgroundEffectsDependency.createBackgroundReplacementEffect({ backgroundImageUrl: selectedEffect.backgroundImageUrl }) : undefined; /** * @remarks this logic should mimic the onToggleCamera in the common call handlers. * @private */ export const getSelectedCameraFromAdapterState = (state) => state.devices.selectedCamera || state.devices.cameras[0]; /** * Helper to determine if the adapter has a locator or targetCallees * @param locatorOrTargetCallees * @returns boolean to determine if the adapter has a locator or targetCallees, true is locator, false is targetCallees * @private */ export const getLocatorOrTargetCallees = (locatorOrTargetCallees) => { return !!Array.isArray(locatorOrTargetCallees); }; /** * Return different conditions based on the current and previous state of recording and transcribing * * @param callRecordState - The current call record state: on, off, stopped * @param callTranscribeState - The current call transcribe state: on, off, stopped * * @remarks - The stopped state means: previously on but currently off * * @private */ export const computeVariant = (callRecordState, callTranscribeState) => { if (callRecordState === 'on' && callTranscribeState === 'on') { return 'recordingAndTranscriptionStarted'; } else if (callRecordState === 'on' && callTranscribeState === 'off') { return 'recordingStarted'; } else if (callRecordState === 'off' && callTranscribeState === 'on') { return 'transcriptionStarted'; } else if (callRecordState === 'on' && callTranscribeState === 'stopped') { return 'transcriptionStoppedStillRecording'; } else if (callRecordState === 'stopped' && callTranscribeState === 'on') { return 'recordingStoppedStillTranscribing'; } else if (callRecordState === 'off' && callTranscribeState === 'stopped') { return 'transcriptionStopped'; } else if (callRecordState === 'stopped' && callTranscribeState === 'off') { return 'recordingStopped'; } else if (callRecordState === 'stopped' && callTranscribeState === 'stopped') { return 'recordingAndTranscriptionStopped'; } else { return 'noState'; } }; /** * @private */ export function determineStates(previous, current) { // if current state is on, then return on if (current) { return 'on'; } // if current state is off else { // if previous state is on and current state is off, return stopped (on -> off) if (previous === 'on') { return 'stopped'; } // otherwise remain previous state unchanged else { return previous; } } } // The debounce time for the stopped state to be shown after both states are stopped. // This is to prevent stopped messages from being lost by transitioning to "Off" too // quickly if the states are toggled in quick succession. // This also prevents React strict mode from transitioning to "Off" too quickly. const ComplianceNotificationOffDebounceTimeMs = 2000; /** * Compute compliance notification based on latest compliance state and cached props. * @private */ export function computeComplianceNotification(complianceProps, cachedProps) { // Only update cached props and variant if there is _some_ change in the latest props. // This ensures that state machine is only updated if there is an actual change in the props. const shouldUpdateCached = complianceProps.callRecordState !== cachedProps.current.latestBooleanState.callRecordState || complianceProps.callTranscribeState !== cachedProps.current.latestBooleanState.callTranscribeState; // The following three operations must be performed in this exact order: // [1]: Update cached state to transition the state machine. if (shouldUpdateCached) { cachedProps.current = { latestBooleanState: complianceProps, latestStringState: { callRecordState: determineStates(cachedProps.current.latestStringState.callRecordState, complianceProps.callRecordState), callTranscribeState: determineStates(cachedProps.current.latestStringState.callTranscribeState, complianceProps.callTranscribeState) }, lastUpdated: Date.now() }; } // [2]: If the callRecordState and callTranscribeState are both stopped for a predetermined amount of time, mark both states as off. // NOTE: this can be removed once lastStoppedRecording in the calling stateful client is GA. if (shouldUpdateCached && cachedProps.current.latestStringState.callRecordState === 'stopped' && cachedProps.current.latestStringState.callTranscribeState === 'stopped' && Date.now() - cachedProps.current.lastUpdated > ComplianceNotificationOffDebounceTimeMs) { // When both states are stopped, after displaying message "RECORDING_AND_TRANSCRIPTION_STOPPED", change both states to off (going back to the default state). cachedProps.current.latestStringState.callRecordState = 'off'; cachedProps.current.latestStringState.callTranscribeState = 'off'; } // [3]: Compute the variant, using the transitioned state machine. const variant = computeVariant(cachedProps.current.latestStringState.callRecordState, cachedProps.current.latestStringState.callTranscribeState); // If the variant is not 'noState', then show the notification. if (variant !== 'noState') { return { type: variant, timestamp: new Date(Date.now()) }; } else { return undefined; } } //# sourceMappingURL=Utils.js.map