UNPKG

communication-react-19

Version:

React library for building modern communication user experiences utilizing Azure Communication Services (React 19 compatible fork)

322 lines 24.9 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { ErrorBar, useTheme } from "../../../../react-components/src"; import { NotificationStack } from "../../../../react-components/src"; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BaseProvider } from '../common/BaseComposite'; import { useLocale } from '../localization'; import { CallAdapterProvider, useAdapter } from './adapter/CallAdapterProvider'; import { CallPage } from './pages/CallPage'; import { ConfigurationPage } from './pages/ConfigurationPage'; import { NoticePage } from './pages/NoticePage'; import { useSelector } from './hooks/useSelector'; import { getAlternateCallerId, getEndedCall, getPage, getRole, getTargetCallees } from './selectors/baseSelectors'; /* @conditional-compile-remove(unsupported-browser) */ import { getEnvironmentInfo } from './selectors/baseSelectors'; import { LobbyPage } from './pages/LobbyPage'; import { TransferPage } from './pages/TransferPage'; import { leavingPageStyle, mainScreenContainerStyleDesktop, mainScreenContainerStyleMobile } from './styles/CallComposite.styles'; import { LayerHost, mergeStyles } from '@fluentui/react'; import { modalLayerHostStyle } from '../common/styles/ModalLocalAndRemotePIP.styles'; import { useId } from '@fluentui/react-hooks'; import { HoldPage } from './pages/HoldPage'; /* @conditional-compile-remove(unsupported-browser) */ import { UnsupportedBrowserPage } from './pages/UnsupportedBrowser'; import { SidePaneProvider } from './components/SidePane/SidePaneProvider'; import { filterLatestNotifications, getEndedCallPageProps, trackNotificationAsDismissed, updateTrackedNotificationsWithActiveNotifications } from './utils'; import { computeComplianceNotification } from './utils'; import { usePropsFor } from './hooks/usePropsFor'; import { deviceCountSelector } from './selectors/deviceCountSelector'; import { capabilitiesChangedInfoAndRoleSelector } from './selectors/capabilitiesChangedInfoAndRoleSelector'; import { useTrackedCapabilityChangedNotifications } from './utils/TrackCapabilityChangedNotifications'; import { useEndedCallConsoleErrors } from './utils/useConsoleErrors'; import { SurveyPage } from './pages/SurveyPage'; import { useAudio } from '../common/AudioProvider'; import { complianceBannerSelector } from './selectors/complianceBannerSelector'; import { devicePermissionSelector } from './selectors/devicePermissionSelector'; const isShowing = (overrideSidePane) => { return !!(overrideSidePane === null || overrideSidePane === void 0 ? void 0 : overrideSidePane.isActive); }; const MainScreen = (props) => { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w; const adapter = useAdapter(); const { camerasCount, microphonesCount } = useSelector(deviceCountSelector); const hasCameras = camerasCount > 0; const hasMicrophones = microphonesCount > 0; const role = useSelector(getRole); const { video: cameraHasPermission, audio: micHasPermission } = useSelector(devicePermissionSelector); useEffect(() => { (() => __awaiter(void 0, void 0, void 0, function* () { var _a, _b, _c, _d; const constrain = getQueryOptions({ role }); /* @conditional-compile-remove(call-readiness) */ { constrain.audio = ((_b = (_a = props.options) === null || _a === void 0 ? void 0 : _a.deviceChecks) === null || _b === void 0 ? void 0 : _b.microphone) === 'doNotPrompt' ? false : constrain.audio; constrain.video = ((_d = (_c = props.options) === null || _c === void 0 ? void 0 : _c.deviceChecks) === null || _d === void 0 ? void 0 : _d.camera) === 'doNotPrompt' ? false : constrain.video; } const permissionsResult = yield adapter.askDevicePermission(constrain); if (permissionsResult === null || permissionsResult === void 0 ? void 0 : permissionsResult.audio) { adapter.queryMicrophones(); } if (permissionsResult === null || permissionsResult === void 0 ? void 0 : permissionsResult.video) { adapter.queryCameras(); } adapter.querySpeakers(); }))(); }, [ adapter, role, /* @conditional-compile-remove(call-readiness) */ (_a = props.options) === null || _a === void 0 ? void 0 : _a.deviceChecks, // Ensure we re-ask for permissions if the number of devices goes from 0 -> n during a call // as we cannot request permissions when there are no devices. hasCameras, hasMicrophones, // Ensure we re-query for devices when permission for the device is granted. cameraHasPermission, micHasPermission ]); const { callInvitationUrl, onFetchAvatarPersonaData, onFetchParticipantMenuItems } = props; const page = useSelector(getPage); const endedCall = useSelector(getEndedCall); const [sidePaneRenderer, setSidePaneRenderer] = React.useState(); const [injectedSidePaneProps, setInjectedSidePaneProps] = React.useState(); const [userSetGalleryLayout, setUserSetGalleryLayout] = useState((_d = (_c = (_b = props.options) === null || _b === void 0 ? void 0 : _b.galleryOptions) === null || _c === void 0 ? void 0 : _c.layout) !== null && _d !== void 0 ? _d : 'floatingLocalVideo'); const [userSetOverflowGalleryPosition, setUserSetOverflowGalleryPosition] = useState('Responsive'); const overridePropsRef = useRef(props.overrideSidePane); useEffect(() => { setInjectedSidePaneProps(props.overrideSidePane); // When the injected side pane is opened, clear the previous side pane active state. // this ensures when the injected side pane is "closed", the previous side pane is not "re-opened". if (!isShowing(overridePropsRef.current) && isShowing(props.overrideSidePane)) { setSidePaneRenderer(undefined); } overridePropsRef.current = props.overrideSidePane; }, [props.overrideSidePane]); const onSidePaneIdChange = props.onSidePaneIdChange; useEffect(() => { onSidePaneIdChange === null || onSidePaneIdChange === void 0 ? void 0 : onSidePaneIdChange(sidePaneRenderer === null || sidePaneRenderer === void 0 ? void 0 : sidePaneRenderer.id); }, [sidePaneRenderer === null || sidePaneRenderer === void 0 ? void 0 : sidePaneRenderer.id, onSidePaneIdChange]); // When the call ends ensure the side pane is set to closed to prevent the side pane being open if the call is re-joined. useEffect(() => { const closeSidePane = () => { setSidePaneRenderer(undefined); }; const resetUserGalleryLayout = () => { if (userSetGalleryLayout === 'togetherMode') { setUserSetGalleryLayout('floatingLocalVideo'); } }; const handleCallEnded = () => { closeSidePane(); resetUserGalleryLayout(); }; adapter.on('callEnded', handleCallEnded); return () => { adapter.off('callEnded', handleCallEnded); }; }, [adapter, userSetGalleryLayout]); const compositeAudioContext = useAudio(); const capabilitiesChangedInfoAndRole = useSelector(capabilitiesChangedInfoAndRoleSelector); const capabilitiesChangedNotificationBarProps = useTrackedCapabilityChangedNotifications(capabilitiesChangedInfoAndRole); // Track the last dismissed errors of any error kind to prevent errors from re-appearing on subsequent page navigation // This works by tracking the most recent timestamp of any active error type. // And then tracking when that error type was last dismissed. const activeErrors = usePropsFor(ErrorBar).activeErrorMessages; const activeInCallErrors = usePropsFor(NotificationStack).activeErrorMessages; const activeNotifications = usePropsFor(NotificationStack).activeNotifications; const complianceProps = useSelector(complianceBannerSelector); const cachedProps = useRef({ latestBooleanState: { callTranscribeState: false, callRecordState: false }, latestStringState: { callTranscribeState: 'off', callRecordState: 'off' }, lastUpdated: Date.now() }); const complianceNotification = useMemo(() => { return computeComplianceNotification(complianceProps, cachedProps); }, [complianceProps, cachedProps]); useEffect(() => { if (complianceNotification) { for (let i = activeNotifications.length - 1; i >= 0; i--) { const notification = activeNotifications[i]; if (notification && [ 'recordingStarted', 'transcriptionStarted', 'recordingStopped', 'transcriptionStopped', 'recordingAndTranscriptionStarted', 'recordingAndTranscriptionStopped', 'recordingStoppedStillTranscribing', 'transcriptionStoppedStillRecording' ].includes(notification.type)) { activeNotifications.splice(i, 1); } } activeNotifications.push(complianceNotification); } setTrackedNotifications((prev) => updateTrackedNotificationsWithActiveNotifications(prev, activeNotifications)); }, [complianceNotification, activeNotifications]); const [trackedErrors, setTrackedErrors] = useState({}); const [trackedInCallErrors, setTrackedInCallErrors] = useState({}); const [trackedNotifications, setTrackedNotifications] = useState({}); useEffect(() => { setTrackedErrors((prev) => updateTrackedNotificationsWithActiveNotifications(prev, activeErrors)); setTrackedInCallErrors((prev) => updateTrackedNotificationsWithActiveNotifications(prev, activeInCallErrors)); }, [activeErrors, activeInCallErrors]); const onDismissError = useCallback((error) => { setTrackedErrors((prev) => trackNotificationAsDismissed(error.type, prev)); setTrackedInCallErrors((prev) => trackNotificationAsDismissed(error.type, prev)); }, []); const onDismissNotification = useCallback((notification) => { setTrackedNotifications((prev) => trackNotificationAsDismissed(notification.type, prev)); }, []); const latestErrors = useMemo(() => filterLatestNotifications(activeErrors, trackedErrors), [activeErrors, trackedErrors]); const latestInCallErrors = useMemo(() => filterLatestNotifications(activeInCallErrors, trackedInCallErrors), [activeInCallErrors, trackedInCallErrors]); const latestNotifications = useMemo(() => { const result = filterLatestNotifications(activeNotifications, trackedNotifications); // sort notifications by timestamp from earliest to latest result.sort((a, b) => { var _a, _b; return ((_a = a.timestamp) !== null && _a !== void 0 ? _a : new Date(Date.now())).getTime() - ((_b = b.timestamp) !== null && _b !== void 0 ? _b : new Date(Date.now())).getTime(); }); return result; }, [activeNotifications, trackedNotifications]); const callees = useSelector(getTargetCallees); const locale = useLocale(); const palette = useTheme().palette; const alternateCallerId = useSelector(getAlternateCallerId); const leavePageStyle = useMemo(() => leavingPageStyle(palette), [palette]); let pageElement; const [pinnedParticipants, setPinnedParticipants] = useState([]); switch (page) { case 'configuration': pageElement = (React.createElement(ConfigurationPage, { joinCallOptions: (_e = props.options) === null || _e === void 0 ? void 0 : _e.joinCallOptions, mobileView: props.mobileView, startCallHandler: () => { if (callees) { adapter.startCall(callees, alternateCallerId ? { alternateCallerId: { phoneNumber: alternateCallerId } } : {}); } else { adapter.joinCall({ microphoneOn: 'keep', cameraOn: 'keep' }); } }, updateSidePaneRenderer: setSidePaneRenderer, latestErrors: latestErrors, onDismissError: onDismissError, modalLayerHostId: props.modalLayerHostId, /* @conditional-compile-remove(call-readiness) */ deviceChecks: (_f = props.options) === null || _f === void 0 ? void 0 : _f.deviceChecks, /* @conditional-compile-remove(call-readiness) */ onPermissionsTroubleshootingClick: (_g = props.options) === null || _g === void 0 ? void 0 : _g.onPermissionsTroubleshootingClick, /* @conditional-compile-remove(call-readiness) */ onNetworkingTroubleShootingClick: (_h = props.options) === null || _h === void 0 ? void 0 : _h.onNetworkingTroubleShootingClick, capabilitiesChangedNotificationBarProps: capabilitiesChangedNotificationBarProps, logo: (_k = (_j = props.options) === null || _j === void 0 ? void 0 : _j.branding) === null || _k === void 0 ? void 0 : _k.logo, backgroundImage: (_m = (_l = props.options) === null || _l === void 0 ? void 0 : _l.branding) === null || _m === void 0 ? void 0 : _m.backgroundImage })); break; case 'accessDeniedTeamsMeeting': pageElement = (React.createElement(NoticePage, { iconName: "NoticePageAccessDeniedTeamsMeeting", title: locale.strings.call.failedToJoinTeamsMeetingReasonAccessDeniedTitle, moreDetails: locale.strings.call.failedToJoinTeamsMeetingReasonAccessDeniedMoreDetails, dataUiId: 'access-denied-teams-meeting-page' })); break; case 'removedFromCall': pageElement = (React.createElement(NoticePage, { iconName: "NoticePageRemovedFromCall", title: locale.strings.call.removedFromCallTitle, moreDetails: locale.strings.call.removedFromCallMoreDetails, dataUiId: 'removed-from-call-page' })); break; case 'joinCallFailedDueToNoNetwork': pageElement = (React.createElement(NoticePage, { iconName: "NoticePageJoinCallFailedDueToNoNetwork", title: locale.strings.call.failedToJoinCallDueToNoNetworkTitle, moreDetails: locale.strings.call.failedToJoinCallDueToNoNetworkMoreDetails, dataUiId: 'join-call-failed-due-to-no-network-page' })); break; case 'leaving': pageElement = (React.createElement(NoticePage, { title: (_o = locale.strings.call.leavingCallTitle) !== null && _o !== void 0 ? _o : 'Leaving...', dataUiId: 'leaving-page', pageStyle: leavePageStyle, disableStartCallButton: true })); break; case 'badRequest': { const { title, moreDetails, disableStartCallButton, iconName } = getEndedCallPageProps(locale, endedCall); pageElement = (React.createElement(NoticePage, { iconName: iconName, title: title, moreDetails: callees ? '' : moreDetails, dataUiId: 'left-call-page', disableStartCallButton: disableStartCallButton })); break; } case 'leftCall': { const { title, moreDetails, disableStartCallButton, iconName } = getEndedCallPageProps(locale, endedCall); if (!((_q = (_p = props.options) === null || _p === void 0 ? void 0 : _p.surveyOptions) === null || _q === void 0 ? void 0 : _q.disableSurvey)) { pageElement = (React.createElement(SurveyPage, { dataUiId: 'left-call-page', surveyOptions: (_r = props.options) === null || _r === void 0 ? void 0 : _r.surveyOptions, iconName: iconName, title: title, moreDetails: moreDetails, disableStartCallButton: disableStartCallButton, mobileView: props.mobileView })); break; } pageElement = (React.createElement(NoticePage, { iconName: iconName, title: title, moreDetails: callees ? '' : moreDetails, dataUiId: 'left-call-page', disableStartCallButton: disableStartCallButton })); break; } case 'lobby': pageElement = (React.createElement(LobbyPage, { mobileView: props.mobileView, modalLayerHostId: props.modalLayerHostId, options: props.options, updateSidePaneRenderer: setSidePaneRenderer, mobileChatTabHeader: props.mobileChatTabHeader, latestErrors: latestInCallErrors, latestNotifications: latestNotifications, onDismissError: onDismissError, onDismissNotification: onDismissNotification, capabilitiesChangedNotificationBarProps: capabilitiesChangedNotificationBarProps })); break; case 'transferring': pageElement = (React.createElement(TransferPage, { mobileView: props.mobileView, modalLayerHostId: props.modalLayerHostId, options: props.options, updateSidePaneRenderer: setSidePaneRenderer, mobileChatTabHeader: props.mobileChatTabHeader, onFetchAvatarPersonaData: onFetchAvatarPersonaData, latestErrors: latestInCallErrors, latestNotifications: latestNotifications, onDismissError: onDismissError, onDismissNotification: onDismissNotification, capabilitiesChangedNotificationBarProps: capabilitiesChangedNotificationBarProps })); break; case 'call': pageElement = (React.createElement(CallPage, { callInvitationURL: callInvitationUrl, onFetchAvatarPersonaData: onFetchAvatarPersonaData, onFetchParticipantMenuItems: onFetchParticipantMenuItems, mobileView: props.mobileView, modalLayerHostId: props.modalLayerHostId, options: props.options, updateSidePaneRenderer: setSidePaneRenderer, mobileChatTabHeader: props.mobileChatTabHeader, onCloseChatPane: props.onCloseChatPane, latestErrors: latestInCallErrors, latestNotifications: latestNotifications, onDismissError: onDismissError, onDismissNotification: onDismissNotification, galleryLayout: userSetGalleryLayout, onUserSetGalleryLayoutChange: setUserSetGalleryLayout, onSetUserSetOverflowGalleryPosition: setUserSetOverflowGalleryPosition, userSetOverflowGalleryPosition: userSetOverflowGalleryPosition, capabilitiesChangedNotificationBarProps: capabilitiesChangedNotificationBarProps, pinnedParticipants: pinnedParticipants, setPinnedParticipants: setPinnedParticipants, compositeAudioContext: compositeAudioContext, disableAutoShowDtmfDialer: (_s = props.options) === null || _s === void 0 ? void 0 : _s.disableAutoShowDtmfDialer, notificationOptions: (_t = props.options) === null || _t === void 0 ? void 0 : _t.notificationOptions })); break; case 'hold': pageElement = (React.createElement(React.Fragment, null, React.createElement(HoldPage, { mobileView: props.mobileView, modalLayerHostId: props.modalLayerHostId, options: props.options, updateSidePaneRenderer: setSidePaneRenderer, mobileChatTabHeader: props.mobileChatTabHeader, latestErrors: latestInCallErrors, latestNotifications: latestNotifications, onDismissError: onDismissError, onDismissNotification: onDismissNotification, capabilitiesChangedNotificationBarProps: capabilitiesChangedNotificationBarProps }))); break; } if (page === 'returningFromBreakoutRoom') { pageElement = (React.createElement(CallPage, { callInvitationURL: callInvitationUrl, onFetchAvatarPersonaData: onFetchAvatarPersonaData, onFetchParticipantMenuItems: onFetchParticipantMenuItems, mobileView: props.mobileView, modalLayerHostId: props.modalLayerHostId, options: props.options, updateSidePaneRenderer: setSidePaneRenderer, mobileChatTabHeader: props.mobileChatTabHeader, onCloseChatPane: props.onCloseChatPane, latestErrors: latestInCallErrors, latestNotifications: latestNotifications, onDismissError: onDismissError, onDismissNotification: onDismissNotification, galleryLayout: userSetGalleryLayout, onUserSetGalleryLayoutChange: setUserSetGalleryLayout, onSetUserSetOverflowGalleryPosition: setUserSetOverflowGalleryPosition, userSetOverflowGalleryPosition: userSetOverflowGalleryPosition, capabilitiesChangedNotificationBarProps: capabilitiesChangedNotificationBarProps, pinnedParticipants: pinnedParticipants, setPinnedParticipants: setPinnedParticipants, compositeAudioContext: compositeAudioContext, disableAutoShowDtmfDialer: (_u = props.options) === null || _u === void 0 ? void 0 : _u.disableAutoShowDtmfDialer, notificationOptions: (_v = props.options) === null || _v === void 0 ? void 0 : _v.notificationOptions })); } useEndedCallConsoleErrors(endedCall); /* @conditional-compile-remove(unsupported-browser) */ const environmentInfo = useSelector(getEnvironmentInfo); /* @conditional-compile-remove(unsupported-browser) */ switch (page) { case 'unsupportedEnvironment': pageElement = (React.createElement(React.Fragment, null, /* @conditional-compile-remove(unsupported-browser) */ React.createElement(UnsupportedBrowserPage, { onTroubleshootingClick: (_w = props.options) === null || _w === void 0 ? void 0 : _w.onEnvironmentInfoTroubleshootingClick, environmentInfo: environmentInfo }))); break; } if (!pageElement) { throw new Error('Invalid call composite page'); } return (React.createElement(SidePaneProvider, { sidePaneRenderer: sidePaneRenderer, overrideSidePane: injectedSidePaneProps }, pageElement)); }; /** * A customizable UI composite for calling experience. * * @remarks Call composite min width/height are as follow: * - mobile: 17.5rem x 21rem (280px x 336px, with default rem at 16px) * - desktop: 30rem x 22rem (480px x 352px, with default rem at 16px) * * @public */ export const CallComposite = (props) => React.createElement(CallCompositeInner, Object.assign({}, props)); /** @private */ export const CallCompositeInner = (props) => { const { adapter, callInvitationUrl, onFetchAvatarPersonaData, onFetchParticipantMenuItems, options, formFactor = 'desktop' } = props; const mobileView = formFactor === 'mobile'; const modalLayerHostId = useId('modalLayerhost'); const mainScreenContainerClassName = useMemo(() => { return mobileView ? mainScreenContainerStyleMobile : mainScreenContainerStyleDesktop; }, [mobileView]); return (React.createElement("div", { className: mainScreenContainerClassName }, React.createElement(BaseProvider, Object.assign({}, props), React.createElement(CallAdapterProvider, { adapter: adapter }, React.createElement(MainScreen, { callInvitationUrl: callInvitationUrl, onFetchAvatarPersonaData: onFetchAvatarPersonaData, onFetchParticipantMenuItems: onFetchParticipantMenuItems, mobileView: mobileView, modalLayerHostId: modalLayerHostId, options: options, onSidePaneIdChange: props.onSidePaneIdChange, overrideSidePane: props.overrideSidePane, mobileChatTabHeader: props.mobileChatTabHeader, onCloseChatPane: props.onCloseChatPane }), // This layer host is for ModalLocalAndRemotePIP in SidePane. This LayerHost cannot be inside the SidePane // because when the SidePane is hidden, ie. style property display is 'none', it takes up no space. This causes problems when dragging // the Modal because the draggable bounds thinks it has no space and will always return to its initial position after dragging. // Additionally, this layer host cannot be in the Call Arrangement as it needs to be rendered before useMinMaxDragPosition() in // common/utils useRef is called. // Warning: this is fragile and works because the call arrangement page is only rendered after the call has connected and thus this // LayerHost will be guaranteed to have rendered (and subsequently mounted in the DOM). This ensures the DOM element will be available // before the call to `document.getElementById(modalLayerHostId)` is made. React.createElement(LayerHost, { id: modalLayerHostId, className: mergeStyles(modalLayerHostStyle) }))))); }; const getQueryOptions = (options) => { if (options.role === 'Consumer') { return { video: false, audio: true }; } return { video: true, audio: true }; }; //# sourceMappingURL=CallComposite.js.map